ORM: Getting Started¶
Talking to a database is where most apps spend their plumbing: fetching rows, mapping them to
objects, wiring up relationships, and writing the same CRUD over and over. arvel's ORM collapses
that into the model itself — it's an async Active-Record, so a Post both describes a row and
is how you query, save, and relate it. No separate repository layer, no hand-written SQL for the
common cases. (For raw connections and SQL, see Database: Getting Started.)
It's built on SQLAlchemy Core, so every query the builder emits is a real Core construct that compiles across PostgreSQL, MySQL, and SQLite — you write one expression and it runs on all three. This page covers defining a model and its lifecycle events; the rest of the ORM is linked below.
Needs a database extra
Pick the driver for your database: uv add 'arvel[postgres]' (asyncpg), 'arvel[mysql]'
(asyncmy), or 'arvel[sqlite]' (aiosqlite) — each pulls in SQLAlchemy + Alembic.
Defining a model¶
from arvel import Model
class Post(Model):
__fillable__ = ["title", "body", "published"]
__casts__ = {"published": "bool", "meta": "json"}
__hidden__ = ["secret"]
__timestamps__ = True
Reading a declared column that hasn't been set yet returns None — e.g.
post.body after Post.create(title="…") is None, not an error. Accessing a genuinely unknown
attribute still raises AttributeError.
Mass assignment¶
create() and fill() only set mass-assignable attributes, controlled by two class lists:
__fillable__— an allow-list. If set, only these keys are accepted.__guarded__— a deny-list (default["*"], i.e. everything is guarded). Set__guarded__ = []to allow every attribute.
A model with no __fillable__ and the default __guarded__ = ["*"] is totally guarded —
nothing is mass-assignable. Mass-assigning to it raises MassAssignmentException rather than silently
dropping the data into an empty row:
class Account(Model): # totally guarded — no __fillable__
__table_name__ = "accounts"
Account().fill({"name": "x"}) # MassAssignmentException: Add [name] to the __fillable__ property …
Declare __fillable__ to opt fields in. A model that does set __fillable__ stays lenient about
extras — an unlisted key is silently ignored (not raised), so passing a request body with
extra fields is safe. MassAssignmentException is a programmer error (a missing __fillable__), not
user input — it is not a ValidationException and renders as a 500, not a 422.
Model events (the full lifecycle)¶
Every write/read path fires a lifecycle event, resolved through the container's EventDispatcher
(no-op if nothing is bound — a model works standalone in tests without an app):
retrieved
creating -> created (insert)
updating -> updated (an existing, dirty save)
saving -> saved (wraps either of the above; saved always fires)
deleting -> deleted, trashed (a SoftDeletes model's delete())
deleting -> force_deleting -> force_deleted, deleted (force_delete(), or delete() on a
non-SoftDeletes model)
restoring -> restored
replicating
creating/updating/saving/deleting/restoring are cancelable: an observer returning
False aborts the operation and the calling method (save/delete/restore) returns False —
the row is left exactly as it was on disk. created/updated only fire when there was actually a
row to write (an insert, or an update on a genuinely dirty model); saved always fires — even on a
clean no-op save().
class PostObserver:
async def creating(self, post): # return False to cancel the create
post.slug = slugify(post.title)
async def deleting(self, post): # return False to cancel the delete
return post.is_protected is False
class Post(Model):
__observers__ = (PostObserver,) # declarative — wires itself on the first event
Only the hooks an observer defines are wired — see Events for the
registration mechanics (Model.observe, the halting/cancel semantics shared with Event.until).
replicate() stays synchronous
post.replicate() keeps its plain, non-async signature — the replicating
event it fires is dispatched best-effort on the running loop rather than awaited inline.
In this section¶
- Queries — reading, writing, and reusable scopes.
- Relationships — has-many / belongs-to / many-to-many, eager loading, aggregates.
- Migrations & Schema — the schema builder, column types, soft deletes, ids, pruning.
- Casts & Serialization — attribute casts, change tracking,
to_dict/to_json. - API Resources — shape a model (or a page of them) into JSON with
JsonResource. - Factories — generate model instances for tests and seeders.
- Transactions & Streaming — atomic units of work, locks, raw SQL, large-result iteration.
- CTEs & Recursive Queries —
WITH/WITH RECURSIVEand referential (self-referencing) trees. - SQL Views & Functions — views, materialized views, and stored functions.
- JSON, Full-Text & Vectors — query JSON/JSONB, Postgres full-text, and pgvector columns.
Common mistakes & gotchas¶
save()no-ops when clean. It only writes when an attribute actually changed (is_dirty). If a write seems to not happen, check the value really changed.- N+1 from lazy relations in a loop.
for u in users: await u.posts()fires a query per user — useUser.with_("posts")to batch oneWHERE IN. - Forgetting a global scope is there. It silently filters every query; use
without_global_scope(name)when you genuinely need the unfiltered set. - Mass-assigning an unlisted field. Only
__fillable__keys are accepted bycreate/fill; set anything else explicitly (post.x = ...). A model with no__fillable__is totally guarded and raisesMassAssignmentExceptioninstead of silently saving an empty row — declare__fillable__. - Writing to a
__view__model. Read-only models raiseReadOnlyModelErroron any write path (save/delete/touch/restore) — that's deliberate; write to the underlying table's model instead. - Morph column names must match.
morph_to("commentable")readscommentable_id/commentable_type; create them witht.morphs("commentable")so the names line up.
How it works¶
A model's Builder composes SQLAlchemy Core statement objects — select()/insert() over
Table/Column metadata, never raw SQL — so the same builder call compiles to PostgreSQL,
MySQL, and SQLite. Relationships resolve in two queries (a WHERE IN), avoiding SQL joins;
existence/aggregate helpers compile to correlated subqueries. Migrations run through Alembic on
the write connection; reads can route to a replica with sticky-after-write.
See also¶
- Validation —
unique/existsrules query the DB. - Console —
migrate/db:seed. Dates & Time — thedatetimecast. - Telemetry — with telemetry on, every query is auto-traced as a
db <OP>span (statement only — bind values are never captured) nested in the request trace.