Articulate — Getting Started
Articulate is Almasix’s Active Record ORM. Each database table has a corresponding model used to query and persist rows, with first-class async/await.
Every persistence and read method is awaited — Almasix is async-first for ASGI.
from almasix.orm import Model, relation
class Flight(Model): fillable = ("name", "airline_id")
@relation def airline(self): from app.models.airline import Airline return self.belongs_to(Airline)
flights = await Flight.query().with_("airline").where("active", True).get()Generate a model (and optionally a migration) with Smith:
smith make:model Flightsmith make:model Flight -mArticulate model conventions
Section titled “Articulate model conventions”Table names
Section titled “Table names”By convention, the “snake case”, plural name of the class is used as the table name — unless another name is explicitly specified. So Flight stores records in flights, and BlogPost in blog_posts. Override with:
class Flight(Model): table = "my_flights"Primary keys
Section titled “Primary keys”Articulate assumes each model has an auto-incrementing id primary key. Customize with primary_key, incrementing, and key_type as needed.
UUID and ULID keys
Section titled “UUID and ULID keys”Mix in HasUuids for time-ordered (version 7) UUID keys, or HasUlids for 26-character ULIDs. Both turn off auto-increment, set key_type = "string", and fill the key on insert:
from almasix.orm import HasUuids, Model
class Article(HasUuids, Model): fillable = ("title",)
article = await Article.create(title="Traveling to Europe")article.id # "0199c6f1-...", ordered by creation timeMix them in before Model. Ordered UUIDs keep inserts local in the index, which is why they are the default rather than random v4. To generate ids yourself, or fill more than one column, override the hooks:
class Article(HasUuids, Model): def new_unique_id(self) -> str: return my_own_generator()
def unique_ids(self) -> tuple[str, ...]: return ("id", "public_id")ordered_uuid() and ulid() are also importable from almasix.orm if you need a value outside a model.
Timestamps
Section titled “Timestamps”By default, Articulate expects created_at and updated_at columns. Set timestamps = False to skip automatic management. Call await model.touch() to update updated_at only.
To save without touching the timestamps just once, use the without_timestamps block. It is scoped to that model class, and touch() becomes a no-op inside it:
with User.without_timestamps(): user.name = "Ada" await user.save()Retrieving models
Section titled “Retrieving models”await Flight.all()await Flight.query().where("active", True).order_by("name").get()await Flight.find(1)await Flight.find_or_fail(1) # raises ModelNotFoundErrorawait Flight.where("name", "Aurora").first()User.where(...) is sugar for User.query().where(...). Class-level with_ = ("airline",) eager-loads those relations on every query(); use new_query() to skip them.
Inserting and updating
Section titled “Inserting and updating”flight = await Flight.create(name="Aurora", airline_id=1)flight.name = "Northern Lights"await flight.save()await flight.update(name="Aurora")await flight.refresh()copy = flight.replicate() # unsaved clone without id / timestampsawait Flight.destroy(1, 2, 3)Mass assignment
Section titled “Mass assignment”Models are guarded by default (guarded = ("*",)). List attributes in fillable, or use force_fill / force_create. Filling a totally guarded model raises MassAssignmentError.
For a block where the rules should not apply — seeding, for instance — unguard them:
with Model.unguarded(): await User.create(**untrusted)Model.unguard() and Model.reguard() do the same thing without a block. Both are process-wide, so keep the window small.
Strictness
Section titled “Strictness”Two checks are off by default because they are stricter than most apps want in production. Turn them on in a service provider, typically for local development only:
from almasix.orm import Model
class AppServiceProvider(ServiceProvider): def boot(self) -> None: Model.should_be_strict(self.app.environment("local"))| Check | Effect |
|---|---|
prevent_silently_discarding_attributes() |
fill() raises DiscardedAttributeError instead of dropping a non-fillable key |
prevent_accessing_missing_attributes() |
Reading a column a persisted model never selected raises MissingAttributeError |
should_be_strict() |
Both of the above |
Lazy loading is already strict in Almasix: unloaded relation access raises unless you opt in, so there is no third switch.
Missing-attribute checks apply only to models that came from the database — a model you are still building reads as None. Pass a default to opt out for one read: user.get_attribute("bio", "").
class User(Model): fillable = ("email", "name", "votes") casts = {"votes": "int", "active": "bool", "meta": "json"} hidden = ("meta",)Known cast names: int, float, string, bool, decimal[:scale], json / array / dict, date, datetime, time, timestamp, encrypted[:array], hashed, an Enum class, or a custom cast class.
Accessors and mutators
Section titled “Accessors and mutators”from almasix.orm import Attribute
class User(Model): name = Attribute(get=lambda value: value.title(), set=lambda value: value.strip())The get_<name>_attribute / set_<name>_attribute methods work too, and appends = ("display",) includes computed attributes in to_dict().
See Mutators & Casts for attribute objects, custom casts, encrypted and hashed casts, date formats, and query-time casting.
Serialization visibility
Section titled “Serialization visibility”hidden is a denylist and visible an allowlist, both declared on the model. To adjust them for a single record, use the instance methods — they affect that model only, never the class:
user.make_hidden("email").to_dict() # hide more on this recorduser.make_visible("meta").to_dict() # reveal a normally hidden attributeuser.set_hidden(["email"]).to_dict() # replace the denylist outrightuser.set_visible(["id", "name"]).to_dict()Each returns the model, so they chain. Relation names may be hidden the same way.
Quiet writes
Section titled “Quiet writes”Every write fires model events. To save, delete, or restore without them:
await user.save_quietly()await user.delete_quietly()await user.force_delete_quietly()await user.restore_quietly() # SoftDeletes modelsQuiet writes are scoped to that one instance, so a concurrent request keeps its own events. To mute a whole block, use Model.without_events().
Pruning models
Section titled “Pruning models”Mix in Prunable and declare which rows are stale. smith model:prune deletes them:
from almasix.orm import Model, Prunable
class Flight(Prunable, Model): def prunable(self): return self.query().where("created_at", "<", month_ago())
async def pruning(self) -> None: """Called before each model is pruned — clean up related state here.""" await self.storage_path().unlink()MassPrunable deletes in bulk instead, which is far faster on large tables but skips pruning() since no models are loaded.
smith model:prunesmith model:prune --pretend # report counts, delete nothingsmith model:prune --model=Flightsmith model:prune --except=Flightsmith model:prune --chunk=500Models are discovered from app/models. Schedule it in routes/console.py to run daily — see Task Scheduling.
Chunking large results
Section titled “Chunking large results”Loading a million rows into memory is how a worker dies. Four ways to avoid it:
# One query per chunk, offset-paged.await Flight.query().chunk(200, handle_chunk)
# One query per chunk, keyset-paged by id — safe when the callback writes.await Flight.query().chunk_by_id(200, handle_chunk)await Flight.query().each_by_id(handle_one)
# Async iteration, one row at a time, still one query per chunk.async for flight in Flight.query().lazy(size=500): ...async for flight in Flight.query().lazy_by_id(size=500): ...
# A single streamed result set — nothing but the current row in memory.async for flight in Flight.query().cursor(): ...Prefer the *_by_id variants when the callback updates the column being ordered by: offset paging skips rows in that case, keyset paging does not. cursor() holds one result set open and therefore cannot apply eager loads.
Dirty tracking
Section titled “Dirty tracking”is_dirty("name")/is_clean()/get_dirty()/get_changes()/get_original("name")/was_changed("name")user.is_(other)— same class and primary keyexists— whether the model has been persistedto_dict()/to_json()honorhidden,visible,appends, and loaded relations — see Serialization
Async & loading defaults
Section titled “Async & loading defaults”| Topic | Articulate behavior |
|---|---|
| Async | Every read/write is awaited |
| Relations | Off by default on attribute access. Unloaded user.posts raises. Opt in with lazy_relations = True so await user.posts loads; or use with_ / await user.posts().get() |
where |
where("col", val) or where("col", ">", val) — two-arg form is only the = shortcut |
| Mass assignment | MassAssignmentError |
| Migrations | smith migrate |
Next steps
Section titled “Next steps”- Relationships
- Mutators & Casts
- Serialization
- Collections
- Soft Deletes & Events
- Documents (NoSQL) — the same model over MongoDB
- Task Scheduling — for
model:prune - Query Builder