Skip to content

sillo.record — an Eloquent-style convenience layer over Tortoise ORM, covering setup, connection lifecycle, configuration, and what each submodule is actually for.

sillo.record is a convenience layer on top of Tortoise ORM. It does not fork Tortoise, wrap its query compiler, or replace its connection handling. Every Tortoise feature — fields, querysets, Q objects, relations, prefetching, aggregation, raw SQL, schema generation — behaves exactly as the Tortoise documentation describes, because it is Tortoise underneath.

What sillo.record adds is the layer Django and Laravel developers miss when they first meet Tortoise: a base model with timestamps and soft-deletes already wired up, attribute casting, reusable query scopes, chainable result collections, model factories for tests, a transaction context manager, and glue between Tortoise and the rest of sillo (pagination strategies, exception handlers, Pydantic schemas).

This page covers what the package contains, how to wire it into an application, and — importantly — which parts are production-ready and which are not. Several components in this package are thinner than their docstrings suggest, and you are better served knowing that up front than discovering it during an incident.

Use sillo.record when you are already using Tortoise and want the ergonomics. Model.active(), user.to_dict(exclude=["password"]), and Collection(rows).group_by("plan") are genuinely nicer than writing the same three lines by hand for the fortieth time.

Do not use sillo.record as a reason to choose Tortoise. If you have not picked an ORM yet, evaluate Tortoise on its own merits — async-native querysets, Django-like syntax, a small dependency footprint, and a migration tool (aerich) that is functional but noticeably less mature than Alembic. sillo.record does not change that calculus.

Do not use it if you need a synchronous ORM, multi-database routing with per-model connection selection, or a mature migration story with branching and merge resolution. Those are Tortoise-level constraints and no convenience layer fixes them.

sillo.record exports around forty names. They fall into eight groups.

GroupNamesGuide
Base modelModelModels & Mixins
MixinsSoftDeletesMixin, TimestampsMixin, HasUlidMixin, SerializesToDictMixin, ValidatesBeforeSaveMixin, CascadesDeletesMixinModels & Mixins
FieldsCreatedAtField, UpdatedAtField, SoftDeleteField, SlugField, ULIDFieldModels & Mixins
Scopes & eventsHasScopes, RecordManager, RecordQuerySet, ScopeRegistry, HasEvents, ModelObserverScopes & Events
Casting & collectionsHasCasts, CollectionCasting & Collections
Queries & paginationpaginate, iter_all, explain, find_by_ids, count_by, PaginatedResult, TortoiseDataHandler, SyncTortoiseDataHandlerPagination
Transactions & testingtransaction, TransactionContext, begin, commit, rollback, Factory, FactoryBuilderTransactions & Factories
IntegrationDatabaseConfig, DatabaseBackend, DatabaseManager, setup_record, register_db_exception_handlers, pydantic_model_from_tortoise, QueryLogger, QueryLogEntry, Seeder, FixtureLoader, MigrationHelper, RecordCLIExceptions & Pydantic, Migrations & Seeding

Importing sillo.record pulls in aerich as a side effect — sillo/record/helpers.py imports from aerich import Command at module level, and __init__.py imports from helpers. If aerich is not installed, import sillo.record fails outright even if you never touch migrations.

Three things have to happen before a query runs: Tortoise must be initialised with a connection config, your model modules must be registered so Tortoise can discover the classes, and the connection must be closed cleanly on shutdown. setup_record does all three against a sillo application.

app.py
from sillo import silloApp
from sillo.record import DatabaseConfig, setup_record
app = silloApp()
db = setup_record(
app,
DatabaseConfig.sqlite("data/app.db"),
model_modules=["myapp.models"],
)

setup_record is idempotent. Calling it twice on the same app returns the manager already stored in app.state["record"] and ignores the second config — useful when a library and your own startup code both try to configure the database, and a silent trap if you expected the second call to reconfigure anything.

Under the hood it does four things:

what setup_record wires up
app.state["record"] = manager # retrievable anywhere via app.state
app.use(manager.ensure_context) # per-request middleware
app.on_startup(manager.init) # Tortoise.init + generate_schemas
app.on_shutdown(manager.shutdown) # connections.close_all()

The middleware is not optional decoration. Tortoise 0.25 and later store connections in a TortoiseContext held in a contextvar. Tortoise.init() runs in the ASGI startup task; request handlers run in different tasks that do not inherit that contextvar. ensure_context re-enters the captured root context for the duration of each request. Remove it and every query in a request handler fails to find a connection.

model_modules=["myapp.models"] is a dotted module path that Tortoise imports and scans for Model subclasses. Two consequences catch people:

A model class defined anywhere other than a listed module is invisible. Defining a model inside a function body, a test fixture, or a conditionally imported file means Tortoise never sees it, and the first query against it raises ConfigurationError: default_connection for the model <...> cannot be None.

If you pass no model_modules at all, the default is ["__main__"]. That works for single-file scripts and breaks the moment you run under a real ASGI server, where __main__ is uvicorn rather than your app.

DatabaseConfig is a dataclass whose every field has an environment variable behind it, so a deployed app can be configured without code changes.

configuring from the environment
from sillo.record import DatabaseConfig
config = DatabaseConfig.from_env() # reads DATABASE_URL
config = DatabaseConfig.sqlite("data/app.db") # explicit sqlite
config = DatabaseConfig(url="postgres://user:pw@host:5432/db")
FieldEnv varDefault
urlDATABASE_URLsqlite://:memory:
pool_sizeDB_POOL_SIZE5
max_overflowDB_MAX_OVERFLOW10
pool_recycleDB_POOL_RECYCLE3600
echoDB_ECHOfalse
sslDB_SSLfalse
timezoneDB_TIMEZONEUTC
ssl_ca / ssl_cert / ssl_keyDB_SSL_CA / DB_SSL_CERT / DB_SSL_KEYNone

__post_init__ infers backend from the URL scheme, so DatabaseConfig(url="postgres://…") reports DatabaseBackend.POSTGRES without being told.

The URL builders do not escape credentials

Section titled “The URL builders do not escape credentials”

DatabaseConfig.postgres() and .mysql() interpolate arguments straight into a URL string:

what the builder produces
DatabaseConfig.postgres("app", "p@ss:w/ord", host="db").url
# 'postgres://postgres:p@ss:w/ord@db:5432/app'

That URL is unparseable. The @ in the password terminates the userinfo section early and the rest is garbage. Any password containing @, :, /, ?, or # breaks it, and those are exactly the characters a password generator likes.

Percent-encode before you pass it, or skip the builder entirely:

safe
import os
from urllib.parse import quote
password = quote(os.environ["DB_PASSWORD"], safe="")
config = DatabaseConfig(url=f"postgres://app:{password}@db:5432/appdb")

Non-SQLite backends need a Tortoise-native config

Section titled “Non-SQLite backends need a Tortoise-native config”

Use Tortoise’s own URL expansion, which produces the correct shape, and initialise Tortoise directly:

postgres/mysql setup that works
import os
from tortoise import Tortoise, connections
from tortoise.backends.base.config_generator import generate_config
TORTOISE_CONFIG = generate_config(
os.environ["DATABASE_URL"], # postgres://user:pw@host:5432/db
{"models": ["myapp.models"]},
)
@app.on_startup
async def connect_db():
await Tortoise.init(config=TORTOISE_CONFIG)
await Tortoise.generate_schemas(safe=True)
@app.on_shutdown
async def disconnect_db():
await connections.close_all()

generate_config yields engine: "tortoise.backends.asyncpg" with host, port, user, password, and database split out correctly. Note that this path skips ensure_context; if you are on Tortoise 0.25+ and see connection-not-found errors inside handlers, add the middleware back by constructing a DatabaseManager purely for that purpose or by re-entering the context yourself.

Everything else in sillo.record — models, scopes, casting, collections, transactions, factories — is backend-agnostic and works fine on Postgres once the connection is established by any means.

generate_schemas is not a migration system

Section titled “generate_schemas is not a migration system”

DatabaseManager.init() calls Tortoise.generate_schemas(safe=True) on every startup. safe=True means “create tables that do not exist” — it never issues ALTER TABLE. Adding a column to a model and restarting gets you a running app with a stale table and confusing errors at query time.

That behaviour is fine for tests, prototypes, and throwaway SQLite files. For anything with real data, use migrations, and read Migrations & Seeding first — including its warnings about the built-in MigrationHelper.

DatabaseManager.health() runs SELECT 1 and returns a bool, swallowing every exception. It is exactly what a readiness probe wants.

health endpoint
@app.get("/healthz")
async def healthz(request, response):
db = request.app.state["record"]
if not await db.health():
return response.json({"status": "degraded"}, status_code=503)
return response.json({"status": "ok"})

Because it catches everything, it cannot distinguish “database is down” from “credentials are wrong” from “Tortoise was never initialised”. When it returns False, check your logs rather than trusting the boolean to tell you why.

A small application that puts the pieces together — configuration from the environment, models in a dedicated module, exception handlers mapping database errors to HTTP status codes, and a soft-delete-aware listing endpoint.

myapp/models.py
from tortoise import fields
from sillo.record import Model
class Article(Model):
id = fields.IntField(pk=True)
slug = fields.CharField(max_length=200, unique=True)
title = fields.CharField(max_length=200)
body = fields.TextField()
published = fields.BooleanField(default=False)
class Meta:
table = "articles"
@classmethod
def scope_published(cls, queryset):
return queryset.filter(published=True)
myapp/app.py
from sillo import silloApp
from sillo.record import DatabaseConfig, register_db_exception_handlers, setup_record
app = silloApp()
setup_record(
app,
DatabaseConfig.from_env(),
model_modules=["myapp.models"],
)
register_db_exception_handlers(app)
@app.get("/articles")
async def list_articles(request, response):
from myapp.models import Article
rows = await Article.active().published().order_by("-created_at").limit(20)
return response.json([a.to_dict(exclude=["body"]) for a in rows])
@app.get("/articles/{slug}")
async def get_article(request, response):
from myapp.models import Article
# DoesNotExist is mapped to a 404 by register_db_exception_handlers
article = await Article.active().get(slug=request.path_params["slug"])
return response.json(article.to_dict())

Article.active() comes from the base model’s soft-delete support. .published() is the local scope declared as scope_published. The get() on a missing slug raises DoesNotExist, which the registered handler turns into a 404 JSON body rather than a 500.

Do not call setup_record and then Tortoise.init yourself. Two initialisations produce two connection registries and one of them wins unpredictably. Pick one path.

Do not rely on generate_schemas in production. It creates but never alters.

Do not pass a raw password to DatabaseConfig.postgres(). Percent-encode it or build the URL yourself.

Do not define models outside the registered modules. Tortoise discovers by module scan, not by subclass registry.

Do not remove manager.ensure_context from the middleware stack because it looks like a no-op. On Tortoise 0.25+ it is what makes queries work inside handlers at all.

Do not assume every exported name is finished. FixtureLoader parses files and inserts nothing. MigrationHelper builds a config that registers only aerich’s own models. TransactionContext.savepoint() raises TypeError on entry. Each is documented in its own guide with the verified failure mode.

The convenience layer costs almost nothing at query time, with two exceptions worth knowing.

Model.__getattribute__ is overridden to support casts and accessors. It runs on every attribute read on a model instance, checking _meta.fields membership and then performing two getattr lookups on the class. For ordinary request handling this is invisible. In a tight loop over a hundred thousand rows reading five attributes each, it is measurable — use .values() or .values_list() for bulk numeric work, which bypasses model instantiation entirely.

Model.save() wraps every save in _encoded_cast_values(), a context manager that only does work when the model declares _casts. Models without casts pay one dictionary lookup.

Connection pooling is Tortoise’s, not sillo’s. pool_size and max_overflow are forwarded into the credentials dict; for SQLite they are accepted and ignored, and for other backends they only reach the driver if you supply a correctly shaped config.

NameSignatureNotes
setup_record(app, config, *, model_modules=None) -> DatabaseManagerIdempotent; stores in app.state["record"]
DatabaseManager(config).init(), .shutdown(), .health(), .register_models(*paths)
DatabaseManager.ensure_context(request, response, call_next)Middleware; required on Tortoise 0.25+
DatabaseConfigdataclass.from_env(), .sqlite(), .postgres(), .mysql(), .to_dict()
DatabaseBackendenumSQLITE, POSTGRES, MYSQL, MARIADB