Skip to content

The sillo.record base model — auto timestamps, soft deletes, serialization, accessors and mutators, bulk writes, and the six composable mixins, with the failure modes of each.

sillo.record.Model is a Tortoise Model subclass that also inherits HasCasts and HasScopes. Everything Tortoise gives you — fields, querysets, Q objects, relations, prefetch_related, aggregation, raw SQL — is unchanged. What the subclass adds is a set of behaviours most applications end up writing by hand.

a minimal model
from tortoise import fields
from sillo.record import Model
class User(Model):
id = fields.IntField(pk=True)
email = fields.CharField(max_length=255, unique=True)
name = fields.CharField(max_length=100)
class Meta:
table = "users"

That class already has created_at, updated_at, and deleted_at columns, to_dict(), soft_delete(), active(), get_or_none(), upsert(), and support for attribute casts, accessors, mutators, and query scopes.

Subclass sillo.record.Model for models in an application that already uses sillo. The added surface is opt-in in practice: if you never call to_dict() or soft_delete(), they cost you three extra columns.

Use plain tortoise.Model when those three columns are unwanted — a join table, a materialised view, an append-only event log where updated_at is meaningless, or a table whose schema is owned by another team. There is no way to disable the auto-fields on a per-model basis; the fields are declared on the abstract base.

Every subclass gets three datetime columns without declaring them.

FieldClassTortoise configBehaviour
created_atCreatedAtFieldauto_now_add=TrueSet once, on the first save
updated_atUpdatedAtFieldauto_now=TrueRewritten on every save
deleted_atSoftDeleteFieldnull=True, default=NoneNone means active

updated_at is rewritten on every save(), including save(update_fields=["deleted_at"]) — so soft-deleting a row also bumps its modification time. If updated_at drives cache invalidation or a sync cursor, be aware that soft deletes and restores both move it.

Walks self._meta.fields, converting datetime values to ISO 8601 strings and nested Model instances to their own dicts.

to_dict
user = await User.get(id=1)
user.to_dict()
# {'id': 1, 'email': 'a@b.com', 'name': 'Alice',
# 'created_at': '2026-01-01T00:00:00+00:00', 'updated_at': '...',
# 'deleted_at': None}
user.to_dict(exclude=["password_hash"])
user.to_dict(include=["id", "email"])

include is applied per field and wins over nothing — if you pass both, a field must be in include and absent from exclude to survive.

to_dict() also runs accessors. If the model defines get_title_attribute, the dict contains the accessor’s output, not the stored column value. That is usually what you want for display, and occasionally not what you want for an export.

json.dumps(self.to_dict(**kwargs), indent=indent, default=str). The default=str is what silently stringifies unserializable values instead of raising, which is why the ReverseRelation problem above is invisible until a client complains.

to_json
print(user.to_json(indent=2, exclude=["password_hash"]))

Applies a dict of field values and saves. Keys not matching a model field are ignored silently, which makes it safe to feed a request body directly — but only for fields you are willing to let a client set.

partial update from a Pydantic model
from pydantic import BaseModel
class UserUpdate(BaseModel):
name: str | None = None
email: str | None = None
@app.patch("/users/{user_id}", request_model=UserUpdate)
async def update_user(request, response, user_id: str):
user = await User.get_or_none(id=user_id)
if user is None:
return response.json({"error": "Not found"}, status_code=404)
await user.update_from_dict(
request.validated_data.model_dump(exclude_unset=True)
)
return response.json(user.to_dict())

exclude_unset=True is the important half. Without it, a Pydantic model with None defaults sends {"name": None, "email": None} for a request that set neither, and update_from_dict faithfully nulls both columns.

Model.__getattribute__ and __setattr__ are overridden to support Laravel-style computed attributes.

accessors and mutators
class Product(Model):
name = fields.CharField(max_length=200)
price_cents = fields.IntField()
def get_name_attribute(self, value):
"""Runs on every read of ``product.name``."""
return value.title() if value else value
def set_name_attribute(self, value):
"""Runs on every write to ``product.name``. Returns the stored value."""
return value.strip()

The mutator’s return value is what gets stored, so it can normalise input before it ever reaches the database. The accessor’s return value is what every read sees — including to_dict(), to_json(), and any code that does product.name.

Two rules keep this from becoming confusing:

An accessor must not be expensive. It runs on every attribute read, and attribute reads happen in loops you did not write. Do formatting, not lookups.

An accessor must not be async. __getattribute__ is synchronous; returning a coroutine from an accessor gives every reader an unawaited coroutine object.

Mutators are skipped while a row is being loaded from the database (_record_loading is set during _init_from_db), so a normalising mutator does not corrupt data read back out.

the shortcuts
user = await User.get_or_none(id=42)
user, created = await User.get_or_create({"name": "New"}, email="a@b.com")
rows = await User.bulk_create([{"email": "a@b.com"}, {"email": "c@d.com"}])
n = await User.count_active()

get_or_none catches every exception, not just DoesNotExist:

sillo/record/models.py
try:
return await cls.get(**kwargs)
except Exception:
return None

A malformed filter, a type error in a lookup, or a dropped connection all return None — indistinguishable from “no such row”. If a get_or_none is returning None when you are certain the row exists, replace it with get() temporarily and read the real exception.

get_or_create(defaults, **kwargs) takes the lookup as keyword arguments and the creation-only values as the first positional argument or as defaults=. On create it merges {**kwargs, **defaults}, so a key in both wins from defaults. It is not atomic — it does a get, then a create. Two concurrent requests can both miss and both insert; rely on a unique constraint, and be ready to catch IntegrityError.

Inserts in batches of batch_size (default 100).

bulk_create
rows = await Article.bulk_create(
[{"title": f"post-{i}"} for i in range(5_000)],
batch_size=500,
)

These use the database’s native conflict handling (ON CONFLICT and equivalents) rather than a read-then-write loop.

upsert
user = await User.upsert(
{"email": "a@b.com", "name": "Alice"},
conflict_fields=["email"],
update_fields=["name"],
)

conflict_fields must match a real unique constraint or primary key. If a named conflict field is missing from the payload, upsert raises a bare KeyError from its final lookup rather than a helpful message.

soft delete lifecycle
await user.soft_delete() # UPDATE ... SET deleted_at = <now>
await user.restore() # UPDATE ... SET deleted_at = NULL
await User.active() # WHERE deleted_at IS NULL
await User.deleted() # WHERE deleted_at IS NOT NULL
await User.count_active()

active() and deleted() are ordinary classmethods returning querysets, so they chain: await User.active().filter(plan="pro").order_by("-id").

The base Model provides soft_delete, restore, active, deleted, and count_active. It does not provide force_delete() or is_trashed — those live on SoftDeletesMixin. Use await user.delete() for a hard delete on the base model.

The important limitation: soft delete is not automatic. Nothing filters deleted_at for you. await User.all() returns soft-deleted rows, await User.get(id=...) finds them, and a foreign key to a soft-deleted row still resolves. If you want soft-deleted rows excluded everywhere, register a global scope — see Scopes & Events.

Mixins are opt-in behaviours you compose onto a model.

Adds force_delete(), only_trashed(), with_trashed(), and the is_trashed property on top of what the base model already has. Worth adding when you want is_trashed in templates or with_trashed() for readability. Note that with_trashed() is simply cls.all() — it is meaningful only if you have registered a global scope that filters deleted rows.

touch() sets updated_at and saves; set_created_at() fills created_at if empty. Useful when you need to bump a modification time without changing any other column.

await session.touch() # UPDATE sessions SET updated_at = <now>

An alternative to_dict() with a max_depth parameter (default 3) that bounds recursion into related models. The base Model.to_dict() has no max_depth and recurses one level into loaded relations. Use this mixin — listed before Model — if you serialize nested object graphs.

Calls await self.validate() before every save. Raise anything to block the write.

validation on save
class Account(ValidatesBeforeSaveMixin, Model):
email = fields.CharField(max_length=255)
async def validate(self) -> None:
if "@" not in self.email:
raise ValueError("email must contain @")

This fires for Account.create(...) as well as instance.save(), because create() calls save() internally. It does not fire for bulk_create, upsert, queryset.update(), or raw SQL — none of those go through save(). Treat it as a convenience, not as an integrity guarantee; the database constraint is the guarantee.

Deletes named relations before deleting the row.

class User(CascadesDeletesMixin, Model):
_cascade_deletes = ["profile"]

It calls getattr(self, name) and then .delete() on the result, so it works for already-loaded one-to-one and foreign-key objects. It is not a substitute for on_delete=fields.CASCADE on the field, which the database enforces atomically. Prefer the database-level cascade; reach for this mixin only for cleanup the schema cannot express, and wrap it in a transaction.

FieldPurposeStatus
CreatedAtFieldDatetimeField(auto_now_add=True)Works
UpdatedAtFieldDatetimeField(auto_now=True)Works
SoftDeleteFieldDatetimeField(null=True, default=None)Works
SlugFieldCharField with a source_field argumentStores the argument; never generates a slug
ULIDFieldCharField(max_length=26, pk=True)Never generates a value
PasswordFieldCharField that bcrypt-hashes on writeWorks; not exported from sillo.record

PasswordField is the useful one. It hashes plaintext in to_db_value and passes through values already starting with $2b$, $2a$, or $2y$, so re-saving a loaded row does not double-hash.

PasswordField
from sillo.record.fields import PasswordField # not exported from sillo.record
class Admin(Model):
email = fields.CharField(max_length=255, unique=True)
password = PasswordField()
admin = await Admin.create(email="a@b.com", password="hunter2")
# column contains '$2b$12$...'
# admin.password is still 'hunter2' in memory until reloaded

Verify with sillo.helpers.hashing.verify_password, never by comparing strings. And note the in-memory attribute keeps the plaintext for the lifetime of that instance — do not log the object.

SlugField(source_field="title") accepts and stores source_field but no code reads it. Generate the slug yourself in a mutator:

def set_title_attribute(self, value):
self.slug = slugify(value)
return value

Do not return to_dict() straight from a handler on a model with reverse relations. Exclude them or use include.

Do not pass an unvalidated body to update_from_dict(). It writes any matching field, including id and privilege flags.

Do not list a behaviour mixin after Model. The override is silently ignored.

Do not rely on get_or_create for uniqueness. It is two statements; use a unique constraint.

Do not call upsert without update_fields. The default set includes reverse relations and produces invalid SQL.

Do not expect bulk_create to give you primary keys. They come back as None.

Do not treat ValidatesBeforeSaveMixin as an integrity constraint. Bulk paths bypass it.

Do not put a query inside an accessor. It runs on every attribute read.

__getattribute__ is overridden on every model instance. Each non-underscore attribute read checks _meta.fields membership and performs two class-level getattr calls. This is fine per request and noticeable across hundreds of thousands of reads — use .values() or .values_list() for bulk numeric work, which skips model instantiation entirely.

save() opens the _encoded_cast_values() context manager, which short-circuits when _casts is empty. Models without casts pay one dict lookup per save.

to_dict() iterates all fields and does an isinstance check per value. Prefer include=[...] over exclude=[...] for wide tables — same cost in this implementation, but it keeps the payload from growing silently when someone adds a column.

MethodSignatureNotes
to_dict(*, exclude=None, include=None) -> dictApplies accessors; includes reverse relations
to_json(*, indent=None, **kwargs) -> strdefault=str hides serialization failures
update_from_dict(data: dict) -> NoneSets matching fields, then saves
soft_delete / restore() -> NoneWrites deleted_at only
active / deleted() -> QuerySetClassmethods; chainable
count_active() -> intactive().count()
get_or_none(**kwargs) -> T | NoneSwallows all exceptions
get_or_create(defaults=None, **kwargs) -> (T, bool)Not atomic
bulk_create(items, batch_size=100, *, ignore_conflicts=False, update_fields=None, on_conflict=None, using_db=None)PKs come back None
bulk_upsert(items, *, conflict_fields, update_fields=None, batch_size=100, using_db=None)Pass update_fields
upsert(values=None, *, conflict_fields, update_fields=None, using_db=None, **kwargs)Re-reads the row after writing