Skip to main content

relix

relix is a small SQLite ORM built around Python dataclasses. Models behave like ordinary dataclass objects while providing simple persistence, querying, foreign keys, transactions, datetime conversion, and JSON-backed container fields.

Features

Defining a model

Subclass Model and decorate the class with @dataclass.

from dataclasses import dataclass

from relix import Model


@dataclass
class User(Model):
    name: str
    email: str
    is_admin: bool = False

Every model automatically has an optional integer id.

New objects start without an ID:

user = User(
    name="Alice",
    email="alice@example.com",
)

print(user.id)  # None

The ID is assigned when the object is inserted into SQLite.


Initializing a database

Call Database.init() after defining your models.

from relix import Database

database = Database.init("app.db")

This registers the database with Model and creates tables for the currently registered models.

For an in-memory database:

database = Database.init(":memory:")

Models should be defined before calling Database.init():

from dataclasses import dataclass

from relix import Database, Model


@dataclass
class User(Model):
    name: str


database = Database.init("app.db")

Saving a model

Call save() to insert a new object.

user = User(
    name="Alice",
    email="alice@example.com",
)

user.save()

print(user.id)

After insertion, user.id contains the SQLite row ID.

Calling save() again updates the existing row:

user.name = "Alice Smith"
user.save()

Getting a model by ID

Use get() to retrieve a row by primary key.

user = User.get(42)

if user is not None:
    print(user.name)

get() returns None when the row does not exist.


Getting all models

Use all() to retrieve every row.

users = User.all()

for user in users:
    print(user.name)

Filtering models

Use where() with model fields to build a query.

admins = User.where(
    User.is_admin == True
).all()

Comparison operators can be used directly:

User.where(User.id == 10)
User.where(User.id != 10)
User.where(User.id < 10)
User.where(User.id <= 10)
User.where(User.id > 10)
User.where(User.id >= 10)

where() returns a query object, so the database is not read until a result method such as all(), first(), or count() is called.


Combining conditions

Use & for AND:

users = User.where(
    (User.is_admin == True) &
    (User.name == "Alice")
).all()

Use | for OR:

users = User.where(
    (User.name == "Alice") |
    (User.name == "Bob")
).all()

Multiple arguments to where() are also combined with AND:

users = User.where(
    User.is_admin == True,
    User.name == "Alice",
).all()

Getting the first result

Use first() when only one matching row is needed.

user = User.where(
    User.email == "alice@example.com"
).first()

if user is not None:
    print(user.name)

first() returns None when no row matches.


Counting rows

Use count() to count matching records without loading them.

admin_count = User.where(
    User.is_admin == True
).count()

To count every row:

user_count = User.where().count()

Limiting queries

Use limit() to restrict the number of results.

users = User.where(
    User.is_admin == True
).limit(10).all()

Query methods can be chained.


Ordering queries

Pass a model field to order_by() for ascending order.

users = User.where().order_by(
    User.name
).all()

Use asc() or desc() explicitly when needed:

users = User.where().order_by(
    User.name.asc()
).all()
users = User.where().order_by(
    User.name.desc()
).all()

Ordering and limiting can be combined:

users = (
    User.where(User.is_admin == True)
    .order_by(User.name)
    .limit(10)
    .all()
)

Deleting models

Call delete() on a saved model.

user = User.get(42)

if user is not None:
    user.delete()

After deletion, the object's id is reset to None.


Nullable fields

Use Optional for nullable columns.

from dataclasses import dataclass
from typing import Optional

from relix import Model


@dataclass
class User(Model):
    name: str
    nickname: Optional[str] = None

Nullable values can be queried:

users = User.where(
    User.nickname == None
).all()

and:

users = User.where(
    User.nickname != None
).all()

These are translated into the appropriate SQLite NULL comparisons.


Basic field types

relix maps common Python types to SQLite automatically.

from dataclasses import dataclass

from relix import Model


@dataclass
class Example(Model):
    count: int
    enabled: bool
    score: float
    name: str
    contents: bytes

The corresponding SQLite storage types are selected automatically.


Datetime fields

datetime values are stored as Unix timestamps.

from dataclasses import dataclass
from datetime import datetime

from relix import Model


@dataclass
class Event(Model):
    name: str
    created_at: datetime


event = Event(
    name="Example",
    created_at=datetime.now(),
)

event.save()

When the row is loaded, the timestamp is converted back into a datetime.

Datetime values can also be used in queries:

events = Event.where(
    Event.created_at >= datetime(2025, 1, 1)
).all()

SQLite stores datetime values as REAL, preserving fractional seconds.


Dictionary fields

Fields typed as dict are automatically serialized as JSON.

from dataclasses import dataclass

from relix import Model


@dataclass
class Document(Model):
    metadata: dict

Use them as ordinary dictionaries:

document = Document(
    metadata={
        "author": "Alice",
        "published": True,
    },
)

document.save()

When the model is loaded, the JSON is converted back into a Python dictionary.


List fields

Fields typed as list are automatically serialized as JSON too.

from dataclasses import dataclass

from relix import Model


@dataclass
class Document(Model):
    tags: list

For example:

document = Document(
    tags=[
        "python",
        "sqlite",
        "orm",
    ],
)

document.save()

Dictionary and list subclasses

Subclasses of dict and list can be used as model fields.

from dataclasses import dataclass

from relix import Model


class Settings(dict):
    pass


class Tags(list):
    pass


@dataclass
class Document(Model):
    settings: Settings
    tags: Tags

When a row is loaded, the values are reconstructed using the declared subclass:

document = Document(
    settings=Settings({
        "theme": "dark",
    }),
    tags=Tags([
        "python",
        "sqlite",
    ]),
)

document.save()

loaded = Document.get(document.id)

print(type(loaded.settings))
print(type(loaded.tags))

Foreign keys

Use ForeignKey to reference another model.

from dataclasses import dataclass

from relix import ForeignKey, Model


@dataclass
class User(Model):
    name: str


@dataclass
class Post(Model):
    title: str
    author: ForeignKey[User]

Save the referenced object first:

user = User(
    name="Alice",
)

user.save()

post = Post(
    title="Hello",
    author=user,
)

post.save()

SQLite stores the relationship using an integer foreign-key column.

For this example, the column is named:

author_id

Loading foreign keys

Foreign-key fields return the related model object.

post = Post.get(1)

if post is not None:
    print(post.author.name)

The related object is loaded lazily when the relationship is first accessed.

The raw ID is also available:

print(post.author_id)

This does not require loading the related User.


Querying foreign keys

A foreign key can be queried using the related object.

posts = Post.where(
    Post.author == user
).all()

The underlying ID column can also be queried directly:

posts = Post.where(
    Post.author_id == user.id
).all()

Nullable foreign keys

Use Optional for nullable relationships.

from dataclasses import dataclass
from typing import Optional

from relix import ForeignKey, Model


@dataclass
class Employee(Model):
    name: str
    manager: Optional[ForeignKey["Employee"]] = None

A foreign-key value of None is stored as SQL NULL.


Circular foreign keys

String foreign-key targets allow models to refer to classes that have not yet been defined.

A model can refer to itself:

from dataclasses import dataclass
from typing import Optional

from relix import ForeignKey, Model


@dataclass
class Employee(Model):
    name: str
    manager: Optional[ForeignKey["Employee"]] = None

Two different models can also reference each other:

@dataclass
class User(Model):
    name: str
    team: Optional[ForeignKey["Team"]] = None


@dataclass
class Team(Model):
    name: str
    owner: ForeignKey[User]

The target name is resolved against the models registered with Model.


Transactions

Use transaction() when several operations must succeed or fail together.

from relix import Database

database = Database.init("app.db")

with database.transaction():
    alice = User(
        name="Alice",
        email="alice@example.com",
    )
    alice.save()

    bob = User(
        name="Bob",
        email="bob@example.com",
    )
    bob.save()

If the block completes normally, the transaction is committed.

If an exception leaves the block, the transaction is rolled back.


Accessing the database directly

Database.init() returns the configured database instance.

from relix import Database

database = Database.init("app.db")

This gives application code access to lower-level database functionality such as transactions and explicit connection management when necessary.


Model registration

Every Model subclass is registered automatically.

from dataclasses import dataclass

from relix import Model


@dataclass
class User(Model):
    name: str


@dataclass
class Post(Model):
    title: str

The registered model classes are available through:

Model.subclasses

relix uses this registry when creating tables and resolving string foreign-key references.


DotDict

relix also provides DotDict, a dictionary with attribute access and mutation tracking.

from relix import DotDict

settings = DotDict({
    "theme": "dark",
})

print(settings.theme)

settings.theme = "light"

print(settings.changed)

Dictionary-style access still works:

settings["theme"] = "dark"

Call mark_clean() after persisting the value:

settings.mark_clean()

print(settings.changed)

Nested dictionaries are wrapped so nested changes can also be tracked:

settings.profile = {
    "name": "Alice",
}

settings.mark_clean()

settings.profile.name = "Alice Smith"

print(settings.changed)

DotDict is useful when an application needs to know whether mutable dictionary state actually needs to be persisted.


A small application

A complete relix application can stay very small.

from dataclasses import dataclass
from datetime import datetime
from typing import Optional

from relilx import Database, ForeignKey, Model


@dataclass
class User(Model):
    username: str
    display_name: str


@dataclass
class Post(Model):
    author: ForeignKey[User]
    title: str
    body: str
    created_at: datetime


database = Database.init("app.db")


alice = User(
    username="alice",
    display_name="Alice",
)

alice.save()


post = Post(
    author=alice,
    title="Hello",
    body="My first post.",
    created_at=datetime.now(),
)

post.save()


posts = (
    Post.where(Post.author == alice)
    .order_by(Post.created_at.desc())
    .all()
)

for post in posts:
    print(post.title)

relix deliberately keeps its scope small. It provides straightforward dataclass persistence and querying without attempting to reproduce the feature set or abstraction level of a larger ORM.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

relix-0.1.0.tar.gz (15.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

relix-0.1.0-py3-none-any.whl (13.7 kB view details)

Uploaded Python 3

File details

Details for the file relix-0.1.0.tar.gz.

File metadata

  • Download URL: relix-0.1.0.tar.gz
  • Upload date:
  • Size: 15.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for relix-0.1.0.tar.gz
Algorithm Hash digest
SHA256 1898db59f67f7d037f304dadbf69304b0ac52001bfc90cc44e12a8bb96f0d809
MD5 69c05a6b82b3681eccb62e06de31d541
BLAKE2b-256 3bc235252ddcc0515dde15b3d44719dbed88ee73e3d2a6f7db636356fab3139f

See more details on using hashes here.

Provenance

The following attestation bundles were made for relix-0.1.0.tar.gz:

Publisher: publish.yml on mizuki-hikaru/relix

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file relix-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: relix-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 13.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for relix-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6679c6ca26351a17268dfb3680229f5e3f44e5ad0b2ef69b5a7e6fbaf928aa51
MD5 bdc55ab8e20287d36c0e706b660ec8d2
BLAKE2b-256 5480578f1d1c16010e650199e9c31d1b3c177c4f5810a5da8421d5d00ee30541

See more details on using hashes here.

Provenance

The following attestation bundles were made for relix-0.1.0-py3-none-any.whl:

Publisher: publish.yml on mizuki-hikaru/relix

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page