Skip to content

Casting & Collections

Attribute casting between database and Python representations, the round-trip and security traps in the built-in casters, and the chainable Collection wrapper with its sharp edges.

Two independent features, both about shaping data rather than fetching it. Casting sits between the database column and the Python attribute. Collections sit after the query, wrapping a list of rows in chainable transformations.

A cast is a pair of functions — an encoder that runs on the way into the database and a decoder that runs on the way out. Declaring one means you stop writing json.loads(user.metadata) at every read site.

declaring casts
from tortoise import fields
from sillo.record import Model
class User(Model):
email = fields.CharField(max_length=255)
metadata = fields.TextField(null=True)
login_count = fields.CharField(max_length=10, null=True)
_casts = {
"metadata": "json",
"login_count": "int",
}

sillo.record.Model already inherits HasCasts, so declaring _casts is all that is required. You do not need to list HasCasts in the bases.

reading and writing cast fields
user = await User.create(metadata={"theme": "dark"}, login_count=3)
# column contains the string '{"theme": "dark"}'
user = await User.get(id=user.id)
user.metadata # {'theme': 'dark'} — a real dict
user.metadata["theme"] # 'dark'

Decoding happens in Model.__getattribute__. Every read of a cast field runs the decoder. There is no caching — reading user.metadata five times parses the JSON five times.

Encoding happens in Model.save(), inside the _encoded_cast_values() context manager. It swaps the encoded value in, lets Tortoise write, then restores the original Python object on the instance. That restoration is why user.metadata is still a dict after saving rather than a JSON string.

Encoding covers save(), create(), bulk_create(), bulk_upsert(), and upsert(). It does not cover queryset.update(), which builds SQL directly:

await User.filter(id=1).update(metadata={"theme": "dark"}) # writes a Python repr

Load the row and save() it if the field is cast.

A subtlety in _set_kwargs: for a cast field, Tortoise’s own to_python_value is skipped during __init__, so the value you pass to create() reaches the encoder exactly as you wrote it.

NameEncoderDecoderSuitable column
"json"json.dumps(value, default=str)json.loads when the value is a strTEXT
"datetime".isoformat()datetime.fromisoformatTEXT
"bool"bool(value)bool(value)BOOLEAN / INTEGER
"int"int(value)int(value)INTEGER
"float"float(value)float(value)REAL
("encrypted", {"key": ...})XOR + base64base64 + XORTEXT

None bypasses both encoder and decoder — a null column reads back as None rather than crashing the decoder.

CastRegistry.register(name, encoder, decoder) adds a named caster usable from any model.

a registered caster
from sillo.record.casting import CastRegistry
CastRegistry.register(
"csv",
lambda value: ",".join(value), # list -> 'a,b,c'
lambda value: value.split(",") if value else [],
)
class Article(Model):
tags = fields.TextField(null=True)
_casts = {"tags": "csv"}

The registry is global and keyed by name, so registering the same name twice silently replaces the earlier caster. Register once, at import time, in a module that is imported before your models.

A cast entry may also be a callable returning an (encoder, decoder) pair, which is the way to build a caster that closes over per-model configuration:

a callable cast
def rounded(places: int):
def factory():
return (lambda v: str(round(v, places)), lambda v: float(v))
return factory
class Reading(Model):
celsius = fields.CharField(max_length=16)
_casts = {"celsius": rounded(2)}

Decoder failures surface at attribute access

Section titled “Decoder failures surface at attribute access”

If a column contains a value the decoder cannot handle, the exception is raised from the attribute read — not from the query.

row = await Flagged.get(id=1) # succeeds
row.payload # JSONDecodeError: Expecting value: line 1 column 1

That is a long way from the cause, and it means a single malformed legacy row can crash a listing endpoint when it is serialized. If a column may contain values written before the cast existed, make the decoder defensive:

CastRegistry.register(
"json_safe",
lambda v: json.dumps(v, default=str),
lambda v: json.loads(v) if isinstance(v, str) and v.strip() else None,
)

Both transform values on read, and they compose: the cast decoder runs first, then get_<field>_attribute receives the decoded value. Use a cast when the storage representation differs from the Python one, and an accessor when the presentation differs from the stored value. Accessors are covered in Models & Mixins.

Collection wraps a list and adds chainable transformations. Every method returns a new Collection; the original list is never mutated.

basic usage
from sillo.record import Collection
users = await User.active()
c = Collection(users)
emails = c.pluck("email").to_list()
vips = c.filter(lambda u: u.plan == "vip")
by_plan = c.group_by("plan")
total = c.sum("balance")

It is a plain in-memory wrapper: iterable, sized, and indexable, with no database awareness. len(c), for row in c, and c[0] all work.

MethodReturnsNotes
map(fn)Collection
filter(fn) / reject(fn)Collectionreject is the inverse
pluck(key)CollectionMissing attribute yields None
group_by(key)dict[Any, Collection]A dict, not a Collection
key_by(key)dict[Any, Any]Later duplicates overwrite earlier ones
sort_by(key, *, descending=False)CollectionSee the trap below
chunk(size)generator of CollectionLazy; not a list
first(default=None) / last(default=None)item
take(n) / skip(n)Collection
sum(key=None) / avg(key=None)numberEmpty gives 0 / 0.0
min(key=None) / max(key=None)itemEmpty raises ValueError
count()int
unique(key=None)CollectionSee the trap below
contains(fn)bool
is_empty() / is_not_empty()bool
to_list()list
to_dict()list[dict]Returns a list, despite the name
to_json(indent=None)strdefault=str

The sort key is getattr(item, key, None) or 0. Every falsy value — None, "", 0, False, [] — becomes the integer 0.

what breaks
Collection([Row("b"), Row(None), Row("a")]).sort_by("v")
# TypeError: '<' not supported between instances of 'int' and 'str'

A column that is nullable and holds strings cannot be sorted at all once a single row is NULL. Uniform types survive: all-strings sorts correctly, all-numbers sorts correctly, all-None is a no-op.

Sort in the database instead. order_by handles nulls, uses an index, and does not materialise the whole table:

rows = await User.all().order_by("-created_at").limit(20)

If you must sort in memory over a nullable column, use sorted() with an explicit key that handles None.

With a key, the default for a missing attribute is float("inf") for min and float("-inf") for max. Over a string field where any item lacks the attribute, the comparison raises TypeError: '<' not supported between instances of 'float' and 'str'.

They are also inconsistent with the other aggregates on an empty collection: sum() returns 0 and avg() returns 0.0, but min() raises ValueError: min() iterable argument is empty. Guard with is_empty() before calling either.

unique() without a key requires hashable items

Section titled “unique() without a key requires hashable items”

unique() with no argument does list(set(self._items)), which raises TypeError: unhashable type: 'dict' on dict rows and discards ordering even when it succeeds. unique(key="plan") uses a seen-set over the attribute values, preserves order, and keeps the first occurrence — that is the form you want almost always.

Despite the name it produces list[dict], calling .to_dict() on each item that has one and str(item) on those that do not. That fallback is worth watching: a collection of plain values silently becomes a list of their string representations.

Collection([1, 2]).to_dict() # ['1', '2'] — strings, not ints

For rows from a sillo.record.Model, remember that each item’s to_dict() includes reverse relations. Pass through map if you need to exclude them:

payload = Collection(articles).map(lambda a: a.to_dict(exclude=["tags"])).to_list()

Building a dashboard payload from one query, without a second round trip per grouping.

grouped summary endpoint
from sillo.record import Collection
@app.get("/admin/summary")
async def summary(request, response):
users = await User.active().limit(1000)
c = Collection(users)
by_plan = {
plan: {
"count": members.count(),
"revenue": members.sum("monthly_cents") / 100,
"newest": members.sort_by("id", descending=True).first().email,
}
for plan, members in c.group_by("plan").items()
}
return response.json({
"total": c.count(),
"by_plan": by_plan,
"sample": c.take(5).map(lambda u: u.to_dict(include=["id", "email"])).to_list(),
})

Note the limit(1000). A Collection holds every row in memory; the limit is what keeps this endpoint bounded. For an accurate count over a large table, use await User.active().count() — one COUNT(*) instead of a full fetch.

Do not use the "bool" cast. It inverts False over a text column and is redundant over a boolean one.

Do not use the "encrypted" cast for anything sensitive. It is repeating-key XOR. Register a Fernet caster.

Do not use queryset.update() on a cast field. It bypasses the encoder.

Do not read a cast field in a hot loop. The decoder runs on every access, uncached.

Do not let an unguarded decoder meet legacy data. One malformed row raises on attribute access, far from the query.

Do not sort_by a nullable string column. Sort in SQL.

Do not call min()/max() on a possibly-empty collection without an is_empty() guard.

Do not build a Collection from an unbounded queryset. It is a list; it holds everything.

Casting costs one function call per read and one per field per save. JSON decoding a large document on every attribute access is the case that actually shows up in profiles — assign it to a local variable rather than reading row.metadata repeatedly inside a loop.

Collections are ordinary Python list comprehensions. Every chained call allocates a new list, so a five-step chain over ten thousand rows allocates five ten-thousand-element lists. That is fine at page-sized volumes and wasteful at table-sized ones.

Aggregation is where the difference is largest. Collection(rows).sum("amount") fetches every row, instantiates every model, and adds in Python. await Order.all().annotate(total=Sum("amount")).values("total") sends one query and returns one number. Prefer the database for aggregates over anything you would not want to print.

NameSignatureNotes
_castsdict[str, str | tuple | Callable]Class attribute on the model
CastRegistry.register(name, encoder, decoder) -> NoneGlobal; last registration wins
CastRegistry.get(name) -> tuple | None
HasCasts.get_cast(field_name) -> (encoder, decoder)(None, None) when unset
HasCasts.cast_get / cast_set(field_name, value) -> AnyNone passes through
Collection(items: list | None = None)Iterable, sized, indexable