Loading Strategies¶
This page uses the SQLAlchemy materia and the Author / Book / Category models from the
setup. Loading strategies are what the backend-free
relationships gain once a database is behind them.
Arcanus never reads a relationship during validation — a relationship field validates into a deferred association that records how to load, not the loaded data. The query fires only when you access the field, and it honors whatever loading strategy you configured on the ORM model or attached to the query. This is what keeps a transmuter from triggering the accidental N+1 that "just validate the ORM object" approaches fall into (see the validation-triggers-lazy-load trap).
Because loading is the backend's job, the strategy is configured with SQLAlchemy's own mechanisms.
Arcanus only makes the typed Column references usable wherever SQLAlchemy expects an attribute.
Strategy on the ORM model¶
Set the default strategy with lazy= on the relationship(). Arcanus respects it as-is.
from sqlalchemy.orm import Mapped, relationship
class BookModel(Base):
# "select" (default): emit a SELECT on first access
author: Mapped[AuthorModel] = relationship(lazy="select", back_populates="books")
# "selectin": batch-load related rows in a second SELECT ... IN (...)
# author: Mapped[AuthorModel] = relationship(lazy="selectin", back_populates="books")
# "joined": load via an outer JOIN in the same query
# author: Mapped[AuthorModel] = relationship(lazy="joined", back_populates="books")
# "raise": forbid loading — any access raises instead of querying
# author: Mapped[AuthorModel] = relationship(lazy="raise", back_populates="books")
# "raise_on_sql": like "raise", but only when a load would emit SQL
# (already-loaded access is allowed)
# author: Mapped[AuthorModel] = relationship(lazy="raise_on_sql", back_populates="books")
lazy= |
When related rows load | Good for |
|---|---|---|
"select" (default) |
on first access, one query each | small graphs, uncertain access |
"selectin" |
up front, one batched IN query per level |
collections you'll iterate |
"joined" |
up front, via outer join in the same query | many-to-one you always need |
"subquery" |
up front, via a correlated subquery | legacy alternative to selectin |
"raise" |
never — raises on any access | catching unintended lazy loads |
"raise_on_sql" |
never — raises only if a load would emit SQL | allow already-loaded access, forbid new queries |
The strategy chosen on the model is the default; any query can override it per-statement.
Strategy per query¶
Recall the example relationships
The setup models declare their relationships without an explicit lazy=, so
Author.books, Book.author, Book.categories, and Category.books all use SQLAlchemy's
default "select" — each loads on first access. The per-query options below override that default
for a single query, without changing the models.
Arcanus re-exports SQLAlchemy's loader options from arcanus.materia.sqlalchemy, wrapped so they
accept Arcanus's typed Column references (e.g. Author["books"]) in addition to native attributes.
Pass them via the options= argument of the session helpers, or .options(...) on a select.
from sqlalchemy import select
from arcanus.materia.sqlalchemy import (
selectinload, joinedload, raiseload, load_only, defer, contains_eager,
)
with Session(engine) as session:
# Eager-load a collection for the whole result set (avoids N+1 on iteration)
authors = session.execute(
select(Author).options(selectinload(Author["books"]))
).scalars().all()
for author in authors:
for book in author.books: # already loaded — no extra query
...
# Eager-load a many-to-one via JOIN (native get accepts options=)
book = session.get_one(Book, 1, options=[joinedload(Book["author"])])
# Forbid a lazy load on this query — access raises instead of querying
book = session.get_one(Book, 1, options=[raiseload(Book["categories"])])
# Column-level: only fetch some columns, or defer an expensive one
authors = session.execute(
select(Author).options(load_only(Author["name"]))
).scalars().all()
books = session.execute(
select(Book).options(defer(Book["title"]))
).scalars().all()
Loader options chain for nested paths, exactly as in SQLAlchemy:
# Load each author's books, and each book's categories, in batched queries
authors = session.execute(
select(Author).options(selectinload(Author["books"]).selectinload(Book["categories"]))
).scalars().all()
(The same options= also works on the session helpers, e.g.
session.list(Author, options=[selectinload(Author["books"])]).)
Other re-exported options: lazyload, subqueryload, noload, undefer, defaultload,
contains_eager, plus selectin_polymorphic and with_polymorphic for polymorphic hierarchies.
See the arcanus.materia.sqlalchemy reference.
Async and awaiting relationships¶
Under AsyncSession, how you access a relationship depends on its strategy — because a lazy load
is I/O, and I/O must be awaited.
await on an association triggers its load and returns the resolved value:
await relation→ the related object (same asrelation.value)await relation_collection→ alistof the related objects
from arcanus.materia.sqlalchemy import AsyncSession
async with AsyncSession(async_engine) as session:
author = await session.get_one(Author, 1)
books = await author.books # awaits the lazy load → list[Book]
for book in author.books: # already resolved — plain iteration
...
book = await session.get_one(Book, 1)
parent = await book.author # → Author
assert parent is book.author.value # second access needs no await
Lazy access without await is a hard error under async
With the default lazy="select", touching a relationship without awaiting it would emit SQL
outside the greenlet — Arcanus catches that and raises a MissingGreenlet with a pointed hint
rather than letting it fail obscurely:
sqlalchemy.exc.MissingGreenlet: Failed to load relation 'books' of Author for a greenlet
is expected. Are you trying to get the relation in a sync context ? Await the Author.books
instance to trigger the sqlalchemy async IO first.
For eager strategies (selectin, joined) the data is already loaded so no I/O happens, but
keeping the await is recommended for consistency across strategies.
The fix is in the message: await the access, or eager-load it on the query with selectinload /
joinedload. A relationship pinned to lazy="raise" raises a similar guided error instead — an
InvalidRequestError telling you to add selectinload or switch the strategy to select /
selectin.