sillo.record — an Eloquent-style convenience layer over Tortoise ORM, covering setup, connection lifecycle, configuration, and what each submodule is actually for.
Record (sillo.record)
Section titled “Record (sillo.record)”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.
When to reach for this
Section titled “When to reach for this”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.
What is in the package
Section titled “What is in the package”sillo.record exports around forty names. They fall into eight groups.
| Group | Names | Guide |
|---|---|---|
| Base model | Model | Models & Mixins |
| Mixins | SoftDeletesMixin, TimestampsMixin, HasUlidMixin, SerializesToDictMixin, ValidatesBeforeSaveMixin, CascadesDeletesMixin | Models & Mixins |
| Fields | CreatedAtField, UpdatedAtField, SoftDeleteField, SlugField, ULIDField | Models & Mixins |
| Scopes & events | HasScopes, RecordManager, RecordQuerySet, ScopeRegistry, HasEvents, ModelObserver | Scopes & Events |
| Casting & collections | HasCasts, Collection | Casting & Collections |
| Queries & pagination | paginate, iter_all, explain, find_by_ids, count_by, PaginatedResult, TortoiseDataHandler, SyncTortoiseDataHandler | Pagination |
| Transactions & testing | transaction, TransactionContext, begin, commit, rollback, Factory, FactoryBuilder | Transactions & Factories |
| Integration | DatabaseConfig, DatabaseBackend, DatabaseManager, setup_record, register_db_exception_handlers, pydantic_model_from_tortoise, QueryLogger, QueryLogEntry, Seeder, FixtureLoader, MigrationHelper, RecordCLI | Exceptions & 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.
The minimum viable setup
Section titled “The minimum viable setup”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.
from sillo import silloAppfrom 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:
app.state["record"] = manager # retrievable anywhere via app.stateapp.use(manager.ensure_context) # per-request middlewareapp.on_startup(manager.init) # Tortoise.init + generate_schemasapp.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 are strings, not imports
Section titled “Model modules are strings, not imports”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.
Configuration
Section titled “Configuration”DatabaseConfig is a dataclass whose every field has an environment
variable behind it, so a deployed app can be configured without code
changes.
from sillo.record import DatabaseConfig
config = DatabaseConfig.from_env() # reads DATABASE_URLconfig = DatabaseConfig.sqlite("data/app.db") # explicit sqliteconfig = DatabaseConfig(url="postgres://user:pw@host:5432/db")| Field | Env var | Default |
|---|---|---|
url | DATABASE_URL | sqlite://:memory: |
pool_size | DB_POOL_SIZE | 5 |
max_overflow | DB_MAX_OVERFLOW | 10 |
pool_recycle | DB_POOL_RECYCLE | 3600 |
echo | DB_ECHO | false |
ssl | DB_SSL | false |
timezone | DB_TIMEZONE | UTC |
ssl_ca / ssl_cert / ssl_key | DB_SSL_CA / DB_SSL_CERT / DB_SSL_KEY | None |
__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:
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:
import osfrom 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:
import osfrom tortoise import Tortoise, connectionsfrom 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_startupasync def connect_db(): await Tortoise.init(config=TORTOISE_CONFIG) await Tortoise.generate_schemas(safe=True)
@app.on_shutdownasync 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.
Checking connectivity
Section titled “Checking connectivity”DatabaseManager.health() runs SELECT 1 and returns a bool, swallowing
every exception. It is exactly what a readiness probe wants.
@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 worked example
Section titled “A worked example”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.
from tortoise import fieldsfrom 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)from sillo import silloAppfrom 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.
What not to do
Section titled “What not to do”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.
Performance notes
Section titled “Performance notes”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.
API reference
Section titled “API reference”| Name | Signature | Notes |
|---|---|---|
setup_record | (app, config, *, model_modules=None) -> DatabaseManager | Idempotent; 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+ |
DatabaseConfig | dataclass | .from_env(), .sqlite(), .postgres(), .mysql(), .to_dict() |
DatabaseBackend | enum | SQLITE, POSTGRES, MYSQL, MARIADB |
Related
Section titled “Related”- Models & Mixins — the base model, serialization, and composable behaviours
- Scopes & Events — reusable query constraints and lifecycle hooks
- Casting & Collections — attribute transformation and result manipulation
- Pagination — connecting querysets to sillo’s pagination strategies
- Transactions & Factories — atomicity and test data
- Exception Handlers & Pydantic — mapping database errors to HTTP, and schema generation
- Migrations & Seeding — aerich, seeders, and fixtures
- Startup & Shutdown — the lifecycle hooks
setup_recordregisters - Serialization — how sillo encodes what your handlers return