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

Silent query, or an error at the line The same missing loader option, twice. Without raiseload, accessing order.items on a collection loaded without an eager option issues a query — once per order — and nothing reports it. The endpoint gets slower as data grows, and attributing the slowness later means reading a profiler trace back to a line of code. With raiseload configured, the first such access raises InvalidRequestError naming the relationship and the class, at the exact line, during development or in the test that exercises that path. The bug is the same in both cases; only the moment of discovery differs. without raiseload the access silently issues a query once per row, at every access site the endpoint just gets slower attribution needs a profiler with lazy="raise_on_sql" the access raises immediately naming the relationship and the class at the line that caused it found in development, not in production Under async the unguarded version raises anyway — as MissingGreenlet, from inside the greenlet machinery, several frames from the access. raiseload names the attribute.

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.

Three scopes for the same rule Three forms. Setting lazy raise_on_sql on the relationship makes the rule permanent and global: every query that does not explicitly load that relationship will raise on access, which is the strongest and the right default for a relationship that should never load implicitly. A raiseload option on a single query scopes the rule to that statement, which suits a hot endpoint being tightened without changing the model. And raiseload with a wildcard applies to every relationship not named by another option in that query, which is the aggressive form worth using on a serialisation boundary where nothing should load lazily. lazy="raise_on_sql" on the relationship permanent and global the strongest default .options(raiseload(Order.items)) one query tighten a hot path no model change .options(raiseload("*")) everything not explicitly loaded a serialisation boundary the aggressive form raise_on_sql rather than plain raise: the former still permits an access that can be satisfied from the identity map without touching the database.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction 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 taskThe 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 productionTest 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 failuresA mature codebase relies on lazy loading throughout.Enable in tests first, fix incrementally, then promote per relationship.
raiseload("*") broke an unrelated queryThe 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.

Adopting it without breaking everything Four steps. Enable raiseload in the test configuration only, so the existing production behaviour is untouched while the failures become visible. Fix what fails, one endpoint at a time, by adding the loader option the query always needed — each fix is small and independently shippable. Promote the setting onto the relationships whose access sites are now all explicit, which makes the rule permanent for those. And make raise_on_sql the default for every new relationship, so the problem does not regrow. Attempting to enable it everywhere at once produces a wall of failures nobody can triage, which is why the first step is scoped to tests. enable it in tests only production behaviour unchanged failures become visible fix what fails, endpoint by endpoint add the loader option each fix ships independently promote onto clean relationships the rule becomes permanent per relationship make it the default for new ones so it does not regrow Enabling it globally on day one on a mature codebase produces hundreds of failures at once, which is how the whole idea gets abandoned.

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.