Using raiseload to catch unexpected lazy loads
Set lazy="raise_on_sql" on relationships and any access that would emit a query raises immediately, naming the attribute and the class — which turns an N+1 into a failure at the line that caused it rather than a slow endpoint nobody can attribute. This is the enforcement tool under complex joins and relationship loading strategies.
Quick Answer
Before — a lazy load nobody intended:
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
items: Mapped[list[OrderItem]] = relationship(back_populates="order")
orders = (await session.execute(select(Order).limit(20))).scalars().all()
total = sum(len(order.items) for order in orders) # 20 silent queries
After — the access raises and names itself:
from __future__ import annotations
from sqlalchemy.orm import Mapped, mapped_column, relationship
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
# Any access that would emit SQL now raises, at the access site.
items: Mapped[list[OrderItem]] = relationship(
back_populates="order", lazy="raise_on_sql"
)
# sqlalchemy.exc.InvalidRequestError:
# 'Order.items' is not available due to lazy='raise_on_sql'
# The fix is the loader option the query always needed:
stmt = select(Order).options(selectinload(Order.items)).limit(20)
orders = (await session.execute(stmt)).scalars().all()
total = sum(len(order.items) for order in orders) # 2 queries, total
raise_on_sql rather than plain raise is deliberate: an access that can be satisfied from the identity map — because the related object is already loaded in this session — is permitted, which avoids failing on something that costs nothing.
Execution Context & Async Workflow Integration
Under AsyncSession an unguarded lazy load already fails, so raiseload is not adding a failure — it is improving one. The default failure is MissingGreenlet, raised from inside the greenlet machinery with a traceback that passes through several frames of SQLAlchemy internals before reaching your code. The raiseload failure names the relationship and the class at the access site.
# Without raiseload — the error, several frames from the cause:
# sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
# can't call await_only() here.
# With raiseload — the error, at the line:
# sqlalchemy.exc.InvalidRequestError:
# 'Order.items' is not available due to lazy='raise_on_sql'
That difference is worth the configuration on its own. It is the same class of improvement as a typed annotation: the failure moves closer to the mistake.
The wildcard form is particularly useful at a serialisation boundary, where the code doing the reading is not the code that wrote the query:
# Async — nothing may load lazily while building this response
from sqlalchemy.orm import raiseload, selectinload
async def order_response(session: AsyncSession, order_id: int) -> OrderOut:
stmt = (
select(Order)
.where(Order.id == order_id)
.options(
selectinload(Order.items).selectinload(OrderItem.product),
raiseload("*"), # anything else must not load
)
)
order = (await session.execute(stmt)).scalar_one()
return OrderOut.model_validate(order) # a serialiser that reaches too far raises
raiseload("*") after the explicit options means: exactly these relationships are available, and any other access is a bug. A response model that later gains a field reaching into an unloaded relationship then fails in the test that covers that endpoint, rather than in production once the session has closed — which is the same failure described in using expire_on_commit=False in FastAPI dependencies, caught earlier.
Resolving Warnings, Errors & Common Mistakes
| Error / Warning | Root Cause | Production Fix |
|---|---|---|
InvalidRequestError: 'Order.items' is not available due to lazy='raise_on_sql' | The query did not load the relationship being accessed. | Add the loader option to the query — that is the whole intent of the error. |
The error appears inside a __repr__ | The generated or hand-written repr reads a relationship. | repr=False on the relationship field, or keep repr to columns only. |
| It fires in a background task | The task holds an instance loaded by a query that did not load this relationship. | Re-query in the task's own session with the options it needs. |
| It fires only in production | Test fixtures have one row per collection, so the fan-out never happens — but the loader was always missing. | Seed enough rows for the access path to be exercised. |
| Enabling it globally produced hundreds of failures | A mature codebase relies on lazy loading throughout. | Enable in tests first, fix incrementally, then promote per relationship. |
raiseload("*") broke an unrelated query | The wildcard applies to every relationship not explicitly named in that statement. | Name the ones you need, or use the targeted form instead of the wildcard. |
Advanced: Enabling It in Tests Before Production
On an existing codebase the useful move is to make raiseload a test configuration first. Production behaviour is unchanged, the failures surface where they are cheap, and the fixes ship one at a time.
# conftest.py — every test query refuses to load anything implicitly
import pytest
from sqlalchemy import event
from sqlalchemy.orm import raiseload
@pytest.fixture(autouse=True)
def forbid_lazy_loads(engine):
"""Apply raiseload('*') to every ORM SELECT issued during a test."""
@event.listens_for(engine.sync_engine, "do_orm_execute")
def _add_raiseload(orm_execute_state):
if orm_execute_state.is_select and not orm_execute_state.is_column_load:
orm_execute_state.update_execution_options(
_sa_orm_load_options=orm_execute_state.load_options
)
orm_execute_state.statement = orm_execute_state.statement.options(
raiseload("*", sql_only=True)
)
yield
event.remove(engine.sync_engine, "do_orm_execute", _add_raiseload)
The is_column_load check matters: an explicit session.refresh() is a column load rather than a relationship load, and blocking it would break legitimate code.
With that in place, run the suite. Every failure is a query missing a loader option — a genuine N+1 that has been there all along. Fix them one endpoint at a time, and each fix is a small, independently reviewable change that also makes the endpoint faster.
Once an endpoint's tests pass with the fixture enabled, promote the setting onto the relationships involved:
items: Mapped[list[OrderItem]] = relationship(
back_populates="order", lazy="raise_on_sql"
)
That makes the rule permanent for that relationship in production too. Doing this per relationship rather than globally means the migration is incremental and reversible, and the codebase converges on explicit loading without ever having a period where nothing works.
Pairing the fixture with the query-budget assertions from counting queries per request to catch N+1 regressions covers both directions: raiseload catches the relationship that should have been loaded, and the budget catches the query that should not have been issued at all.
When Lazy Loading Is Still the Right Answer
raiseload is a strong default and not a universal one. Three cases legitimately want lazy loading.
A relationship most requests never touch. A User.audit_log that one administrative endpoint reads should not be eagerly loaded by every query that returns a user. Lazy is correct here — but so is raise_on_sql plus an explicit selectinload on the one endpoint that needs it, which is strictly better because the endpoint documents its own requirement.
Interactive scripts and notebooks. Exploring data in a REPL is exactly the situation lazy loading was designed for, and raiseload makes it tedious. Scope the strictness to the application rather than to the models if this matters — a per-query option rather than a relationship setting.
A genuinely single-object path. Loading one order and reading its customer costs one extra query whether it is lazy or eager. The N+1 problem is about collections of parents; for a single object the difference is one round trip either way, and the clarity of lazy access can outweigh it.
What all three share is that the decision should be deliberate. The failure mode raiseload exists to prevent is not lazy loading — it is lazy loading nobody chose, in a loop, discovered months later as an endpoint that is inexplicably slow.
A pragmatic default that works well: lazy="raise_on_sql" on every collection relationship, and ordinary lazy loading on scalar many-to-one relationships. Collections are where the fan-out lives, scalars are where the convenience is worth most, and the split is easy to state in a code review.
Frequently Asked Questions
What is the difference between lazy="raise" and lazy="raise_on_sql"?
raise fails on any access to an unloaded relationship. raise_on_sql fails only when satisfying the access would require SQL, so an access that the identity map can already answer is permitted. raise_on_sql is almost always the one you want: it forbids the cost, not the convenience.
Does raiseload affect writes?
Only in that appending to an unloaded collection triggers a load. For a large collection where you only ever append, lazy="write_only" is the better tool — it gives an append-only interface with no load at all, and is designed for exactly that case.
Will this break my serialisers?
It will break the ones that reach into relationships the query did not load — which is the point, and those were already issuing hidden queries or raising under async. Naming what the response needs in the query is the fix, and it makes the endpoint's data requirements explicit.
Can I enable it for one query without changing the model?
Yes: .options(raiseload(Order.items)) for a single relationship, or .options(raiseload("*")) for everything not explicitly loaded in that statement. That is the right way to tighten a hot path before committing to the model-level setting.
Related
- Complex Joins and Relationship Loading Strategies — The parent guide: choosing the loader that raiseload forces you to state.
- Using selectinload vs joinedload for N+1 prevention — Which loader option to add once raiseload has found the access.
- Counting queries per request to catch N+1 regressions — The complementary check: queries that should not have been issued.
- Typing relationships with Mappedlist... — Where the lazy setting sits among the other relationship arguments.