Skip to content

Every field type available on a Sillo model (numbers, text, dates, binary, JSON, UUID and enums) with the arguments each accepts and what they map to per backend.

from tortoise import fields
from sillo.record import Model
class Post(Model):
id = fields.IntField(primary_key=True)
title = fields.CharField(max_length=200)
body = fields.TextField()
views = fields.IntField(default=0)
published_at = fields.DatetimeField(null=True)

Plus the six Record adds: PasswordField, SlugField, ULIDField and the three timestamp fields.

ArgumentDefaultMeaning
primary_keyFalseThis is the primary key
nullFalseThe column accepts NULL
defaultValue when none is given. A callable is called per row.
uniqueFalseAdds a unique constraint
db_indexFalseAdds an index
descriptionBecomes the column comment
source_fieldThe column name, when it differs from the attribute
validators[]Validators run before write
generatedFalseThe database supplies it
slug = fields.CharField(max_length=200, unique=True, db_index=True)
uuid = fields.UUIDField(primary_key=True, default=uuid4)
legacy = fields.CharField(max_length=50, source_field="legacy_col")

They answer different questions. null=True says the column may hold NULL; default= says what to write when you do not supply a value. A field can have both, either, or neither.

For text, prefer an empty string over NULL unless “unset” and “empty” are genuinely different states. Otherwise every query needs to handle both.

FieldRangeNotes
IntField32-bitThe default choice
SmallIntField16-bit±32,767
BigIntField64-bitFor ids that will exceed 2 billion
FloatFielddoubleBinary floating point
DecimalFieldexactRequires max_digits and decimal_places
quantity = fields.IntField(default=0)
weight = fields.FloatField()
price = fields.DecimalField(max_digits=10, decimal_places=2)

Use DecimalField for money. FloatField is binary floating point: 0.1 + 0.2 is not 0.3, and a ledger built on it will not balance. max_digits=10, decimal_places=2 gives you up to 99,999,999.99.

max_digits counts all digits, not the ones before the point.

An auto-incrementing primary key is IntField(primary_key=True). The generated flag follows from being an integer primary key.

FieldNotes
CharFieldmax_length is required
TextFieldUnbounded. Cannot be indexed on MySQL without a prefix length.
title = fields.CharField(max_length=200)
body = fields.TextField()

Pick CharField when there is a real bound: an email, a slug, a status. The length is a constraint the database enforces, and it is what lets the column be indexed everywhere.

Pick TextField for prose. Do not reach for CharField(max_length=65535) to avoid choosing.

is_active = fields.BooleanField(default=True)

Stored as a real boolean on PostgreSQL, as TINYINT(1) on MySQL, and as an integer on SQLite. All three round-trip as Python bool.

Give it a default. A nullable boolean has three states, and almost no domain actually wants that.

FieldPython type
DatetimeFielddatetime
DateFielddate
TimeFieldtime
TimeDeltaFieldtimedelta
published_at = fields.DatetimeField(null=True)
birth_date = fields.DateField()
duration = fields.TimeDeltaField()

Two automatic modes:

created = fields.DatetimeField(auto_now_add=True) # set on insert only
updated = fields.DatetimeField(auto_now=True) # set on every save

Which is exactly what CreatedAtField and UpdatedAtField wrap, and why you rarely write these yourself. The base model already has both.

from uuid import uuid4
id = fields.UUIDField(primary_key=True, default=uuid4)

Native uuid on PostgreSQL, CHAR(36) elsewhere.

A UUID primary key does not leak a row count and can be generated before the insert, useful when a client needs the id up front. The cost is index locality: random UUIDs scatter writes across the index. ULIDField is the middle ground, sorting by creation time while staying opaque.

thumbnail = fields.BinaryField()

BYTEA on PostgreSQL, BLOB elsewhere. For small binary values: a hash, a signature, an icon.

Not for uploads. Files belong on disk or in object storage, with the path in a CharField: a row with a 5MB column in it makes every query that touches the table slower, including the ones that do not select the column.

metadata = fields.JSONField(default=dict)

Native JSONB on PostgreSQL, JSON on MySQL, TEXT on SQLite.

It has its own lookups (contains, contained_by, filter) rather than the usual set. See Lookups.

await Post.filter(metadata__filter={"theme": "dark"})

JSON is right for genuinely open-ended data: a webhook payload, per-tenant settings, an audit snapshot. It is wrong as a way to avoid deciding on columns: you lose type checking, defaults, constraints and most index options, and every consumer has to handle a shape that is not enforced anywhere.

If you find yourself querying inside the same key repeatedly, that key wants to be a column.

Related: the json cast does the same conversion over a TextField, for when you cannot change the column type.

Two, depending on how you want the value stored:

from enum import Enum, IntEnum
class Status(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class Priority(IntEnum):
LOW = 1
NORMAL = 2
HIGH = 3
class Post(Model):
status = fields.CharEnumField(Status, default=Status.DRAFT)
priority = fields.IntEnumField(Priority, default=Priority.NORMAL)

CharEnumField stores the string; IntEnumField stores the integer. Both return real enum members, so post.status is Status.DRAFT works.

CharEnumField sizes the column from the longest member unless you pass max_length. Adding a longer member later is therefore a migration, worth setting max_length up front with room to spare.

Prefer CharEnumField. A status column reading published is self-describing in a database console, a CSV export and a log line; priority = 2 is not. Take IntEnumField when the values are genuinely ordinal and you want to compare them with <.

Adding a member is not a schema change for either. The constraint is in Python, not the database. Which is also the caveat: nothing stops another writer putting an unknown value in the column.

from tortoise.fields import Now, SqlDefault
created_at = fields.DatetimeField(db_default=Now())
count = fields.IntField(db_default=SqlDefault("0"))

A default= is applied by Python, so a row inserted by anything else (a migration, another service, a psql session) does not get it. db_default puts it in the schema, where it applies to every writer.

Choosing between the two column-level options

Section titled “Choosing between the two column-level options”
You wantUse
A value the application decidesdefault=
A value every writer should getdb_default=
A value derived from other fieldsA model event
A value that must always holdA check constraint

Subclass and override the two conversion hooks:

class UpperCharField(fields.CharField):
def to_db_value(self, value, instance, *args, **kwargs):
return value.upper() if isinstance(value, str) else value
def to_python_value(self, value, *args, **kwargs):
return value

For a conversion that needs no new column type, casting is lighter. It is configured per model rather than declared as a type.