Skip to content

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

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 — use User.with_("posts") to batch one WHERE 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 by create/fill; set anything else explicitly (post.x = ...). A model with no __fillable__ is totally guarded and raises MassAssignmentException instead of silently saving an empty row — declare __fillable__.
  • Writing to a __view__ model. Read-only models raise ReadOnlyModelError on 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") reads commentable_id / commentable_type; create them with t.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

  • Validationunique/exists rules query the DB.
  • Consolemigrate / db:seed. Dates & Time — the datetime cast.
  • 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.