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.
Models & Mixins
Section titled “Models & Mixins”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.
from tortoise import fieldsfrom 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.
When to reach for this
Section titled “When to reach for this”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.
The three automatic fields
Section titled “The three automatic fields”Every subclass gets three datetime columns without declaring them.
| Field | Class | Tortoise config | Behaviour |
|---|---|---|---|
created_at | CreatedAtField | auto_now_add=True | Set once, on the first save |
updated_at | UpdatedAtField | auto_now=True | Rewritten on every save |
deleted_at | SoftDeleteField | null=True, default=None | None 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.
Serialization
Section titled “Serialization”to_dict()
Section titled “to_dict()”Walks self._meta.fields, converting datetime values to ISO 8601
strings and nested Model instances to their own dicts.
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.
to_json()
Section titled “to_json()”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.
print(user.to_json(indent=2, exclude=["password_hash"]))update_from_dict()
Section titled “update_from_dict()”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.
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.
Accessors and mutators
Section titled “Accessors and mutators”Model.__getattribute__ and __setattr__ are overridden to support
Laravel-style computed attributes.
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.
Query shortcuts
Section titled “Query 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:
try: return await cls.get(**kwargs)except Exception: return NoneA 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.
Bulk writes
Section titled “Bulk writes”bulk_create
Section titled “bulk_create”Inserts in batches of batch_size (default 100).
rows = await Article.bulk_create( [{"title": f"post-{i}"} for i in range(5_000)], batch_size=500,)upsert and bulk_upsert
Section titled “upsert and bulk_upsert”These use the database’s native conflict handling (ON CONFLICT and
equivalents) rather than a read-then-write loop.
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 deletes
Section titled “Soft deletes”await user.soft_delete() # UPDATE ... SET deleted_at = <now>await user.restore() # UPDATE ... SET deleted_at = NULL
await User.active() # WHERE deleted_at IS NULLawait User.deleted() # WHERE deleted_at IS NOT NULLawait 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.
The six mixins
Section titled “The six mixins”Mixins are opt-in behaviours you compose onto a model.
SoftDeletesMixin
Section titled “SoftDeletesMixin”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.
TimestampsMixin
Section titled “TimestampsMixin”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>HasUlidMixin
Section titled “HasUlidMixin”SerializesToDictMixin
Section titled “SerializesToDictMixin”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.
ValidatesBeforeSaveMixin
Section titled “ValidatesBeforeSaveMixin”Calls await self.validate() before every save. Raise anything to block
the write.
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.
CascadesDeletesMixin
Section titled “CascadesDeletesMixin”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.
Custom fields
Section titled “Custom fields”| Field | Purpose | Status |
|---|---|---|
CreatedAtField | DatetimeField(auto_now_add=True) | Works |
UpdatedAtField | DatetimeField(auto_now=True) | Works |
SoftDeleteField | DatetimeField(null=True, default=None) | Works |
SlugField | CharField with a source_field argument | Stores the argument; never generates a slug |
ULIDField | CharField(max_length=26, pk=True) | Never generates a value |
PasswordField | CharField that bcrypt-hashes on write | Works; 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.
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 reloadedVerify 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 valueWhat not to do
Section titled “What not to do”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.
Performance notes
Section titled “Performance notes”__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.
API reference
Section titled “API reference”| Method | Signature | Notes |
|---|---|---|
to_dict | (*, exclude=None, include=None) -> dict | Applies accessors; includes reverse relations |
to_json | (*, indent=None, **kwargs) -> str | default=str hides serialization failures |
update_from_dict | (data: dict) -> None | Sets matching fields, then saves |
soft_delete / restore | () -> None | Writes deleted_at only |
active / deleted | () -> QuerySet | Classmethods; chainable |
count_active | () -> int | active().count() |
get_or_none | (**kwargs) -> T | None | Swallows 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 |
Related
Section titled “Related”- Record Overview — setup, configuration, and connection lifecycle
- Scopes & Events — global scopes for automatic soft-delete filtering
- Casting & Collections —
_castsand result manipulation - Exception Handlers & Pydantic — generating schemas from these models
- Hashing helpers — what
PasswordFielduses internally - Serialization — how sillo encodes handler return values