Turns SQLAlchemy's MissingGreenlet crash into a diagnostic that names the
model, the attribute, the instance, and the line that triggered it.
import missinggreenlet
handle = missinggreenlet.install(session_factory, mode="explain")
# ... run your app ...
# an implicit lazy load in an async-unsafe context now raises:
#
# missinggreenlet.LazyLoadError: Implicit relationship lazy load: Author.books
# instance: Author(pk=1)
# triggered at: app/views.py:42, in get_author
# fix: add selectinload(Author.books) (or joinedload(...)) to the original
# query, or `await session.refresh(instance, ['books'])` before touching
# it, or mix AsyncAttrs into Author and use
# `await instance.awaitable_attrs.books`.In SQLAlchemy's asyncio ORM, touching an unloaded relationship, a deferred
column, or an expired attribute outside an awaited call raises
sqlalchemy.exc.MissingGreenlet ("greenlet_spawn has not been called; can't
call await_() here"). The message doesn't say which model, which attribute,
which row, or which line of your code touched it - only that some internal
await_() call had no greenlet to run in. The traceback is dozens of frames
of asyncio/greenlet/SQLAlchemy internals with no application code in it
at all.
lazy="raise" / raiseload("*") catch the same class of bug, but they
require editing every model or every query, and they change behavior
everywhere, permanently - not something you can flip on for one request while
debugging a crash report.
Measured: python examples/demo.py (SQLAlchemy 2.1.1, aiosqlite), touching an
unloaded relationship the same way in both cases:
1. Raw SQLAlchemy crash (no missinggreenlet):
exception type : sqlalchemy.exc.StatementError
message : (sqlalchemy.exc.MissingGreenlet) greenlet_spawn has not been called; can't call await_() here. Was IO attempted in an unexpected place?
traceback depth: 45 lines
names the model/attribute/instance/call site? no
2. Same bug, missinggreenlet installed (mode='explain'):
exception type : missinggreenlet._detect.LazyLoadError
Implicit relationship lazy load: Author.books
instance: Author(pk=1)
triggered at: examples/demo.py:75, in with_missinggreenlet
fix: add selectinload(Author.books) (or joinedload(...)) to the original query, or `await session.refresh(instance, ['books'])` before touching it, or mix AsyncAttrs into Author and use `await instance.awaitable_attrs.books`.
3. Applying the suggested fix (selectinload) - no crash, no false alarm:
author.books preloaded: ['The Left Hand of Darkness']
The traceback depth varies a little across SQLAlchemy versions and DBAPI drivers (30-50 frames in practice); the point that it names nothing about your application code does not.
We checked whether SQLAlchemy already exposes this: the do_orm_execute
event fires for every lazy load (relationship, deferred column, and
expired-attribute refresh alike) before any I/O happens, and
ORMExecuteState.lazy_loaded_from names the instance behind a relationship
lazy load - but nothing public surfaces which specific attribute access
crashed, and column loads don't expose their instance publicly at all. This
package builds the diagnostic from that event (plus one small, isolated,
version-gated read of an internal field - see
How it works) instead of reinventing lazy-load detection.
pip install missinggreenletRequires SQLAlchemy >= 2.0. Tested against 2.0.54 and 2.1.1 (see Compatibility).
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import missinggreenlet
engine = create_async_engine("postgresql+asyncpg://...")
session_factory = async_sessionmaker(engine, class_=AsyncSession)
missinggreenlet.install(session_factory, mode="explain")Leave this on. It only ever intervenes on a load that was already about to
crash with MissingGreenlet - it replaces that crash with the same
exception chained under a LazyLoadError that names the model, attribute,
instance and call site. It never changes behavior for a load that would have
succeeded (eager loads, await session.refresh(...), AsyncAttrs).
missinggreenlet.install(session_factory, mode="raise")Raises LazyLoadError at the point of the implicit access, before any query
is sent to the database - no half-run statement, no wrapped DBAPI error.
missinggreenlet.install(session_factory, mode="warn")Emits a LazyLoadWarning with the same diagnostic and lets the original
crash happen afterwards (or lets the load succeed, if it's actually safe) -
useful for seeing how many call sites are affected before switching to
mode="raise".
from missinggreenlet import assert_no_lazy_loads
async def test_no_n_plus_one():
async with assert_no_lazy_loads():
result = await session.execute(select(Author).options(selectinload(Author.books)))
for author in result.scalars():
author.books # preloaded - fineassert_no_lazy_loads() is independent of install(): it fails on any
implicit lazy load inside the block, whether or not it would actually crash
- a lazy load that succeeds because it happens to run inside an already-safe
context is still an N+1 query pattern worth catching in a test. Works as
both
withandasync with.
handle = missinggreenlet.install(session_factory)
handle.uninstall() # or: with missinggreenlet.install(...) as handle: ...| Parameter | Type | Default | Description |
|---|---|---|---|
engine_or_sessionmaker |
Engine | AsyncEngine | sessionmaker | async_sessionmaker |
required | What to watch. See Limitations for how scoping works. |
mode |
"explain" | "raise" | "warn" |
"explain" |
See table below. |
Returns an InstallHandle with .uninstall(); also usable as a context
manager (with install(...) as handle:).
| Mode | When it acts | What it does |
|---|---|---|
explain |
A load is about to raise MissingGreenlet |
Raises LazyLoadError with the full diagnostic, chained from the original MissingGreenlet (exc.__cause__). No-op on a sync engine/session - MissingGreenlet can't happen there. |
raise |
Same condition as explain (async target); any implicit lazy load (sync target) |
Raises LazyLoadError immediately, before any I/O - no DBAPI round trip, no wrapped exception. |
warn |
Same condition as raise |
Emits LazyLoadWarning via warnings.warn, then lets the original outcome happen (crash or success). |
Context manager (with and async with). Raises LazyLoadDetected
(subclasses AssertionError) on any implicit relationship lazy load,
deferred-column load, or expired-attribute refresh inside the block, sync or
async, regardless of whether it would have crashed.
| Name | Base | Raised when |
|---|---|---|
LazyLoadError |
RuntimeError |
mode="explain" or mode="raise" |
LazyLoadWarning |
UserWarning |
mode="warn" |
LazyLoadDetected |
AssertionError |
inside assert_no_lazy_loads() |
LazyLoadDiagnostic |
dataclass |
not raised - .format() builds the message; available on all three exceptions' args[0] |
Every relationship lazy load, deferred-column load, and expired-attribute
refresh is issued as a Session.execute() call internally, so the public
do_orm_execute event (SessionEvents) fires for all three before any I/O
happens. From ORMExecuteState this package reads:
is_relationship_load/is_column_load- which family.lazy_loaded_from- theInstanceStatebehind a relationship lazy load (populated only for a true per-instance load, not an eager batch load likeselectinload, which is how eager loading is told apart from implicit loading with no false positives).loader_strategy_path- the mapper path, whose last element names the relationship property.
Whether a load is safe is answered by
sqlalchemy.util.concurrency.in_greenlet(): True inside the greenlet
SQLAlchemy's asyncio bridge spawned for the current awaited call (safe -
await session.refresh(...), AsyncAttrs.awaitable_attrs, and eager loads
all resolve inside it), False outside it (about to crash). This is not
public/documented API - it is a small, stable, one-purpose internal check,
isolated in missinggreenlet/_greenlet.py with a single try/except: if a
future SQLAlchemy release renames or removes it, the check degrades to "do
nothing" rather than guessing.
Column loads (deferred columns, expired-attribute refreshes) don't get a
public lazy_loaded_from. The InstanceState is still recoverable,
best-effort, from ORMExecuteState.load_options._refresh_state - a private
field of the (public) load_options attribute, set by
sqlalchemy/orm/loading.py. This is isolated in one function
(_refresh_state_of in _detect.py), documented there, and version-gated:
if it's absent, the diagnostic falls back to naming the model without the
specific instance. Verified against SQLAlchemy 2.0.54 and 2.1.1.
mode="explain" pairs this with the public
DialectEvents.handle_error/ConnectionEvents.handle_error event
(documented as supporting "exception re-writing"): the diagnostic built
in do_orm_execute is stashed in a contextvars.ContextVar, and if the
subsequent DBAPI call actually raises MissingGreenlet, handle_error
substitutes a LazyLoadError built from that diagnostic, chained from the
original via Python's normal raise ... from ....
- Scoping is best-effort, not per-instance. SQLAlchemy's
do_orm_executeevent can only be attached to theSessionclass (to reach asessionmaker/async_sessionmakerwith no staticbind=) or to a specific engine (if there is one to compare against). Passing an engine, or a sessionmaker created withbind=<engine>, scopes the check correctly. Passing a sessionmaker with no static bind falls back to matching by session class - if your app has multiple engines sharing oneSession/AsyncSessionsubclass, all of them are watched. mode="explain"needs a concrete engine. If given a sessionmaker/async_sessionmaker with no staticbind=,install()raisesValueError- there is no engine to attachhandle_errorto. Pass the engine directly, or use a sessionmaker created withbind=.- The instance-recovery for column loads relies on one private field
(
load_options._refresh_state, see How it works). If a future SQLAlchemy release removes it, deferred-column and expired-attribute diagnostics still fire and still name the model, but fall back to<instance unavailable>instead of the identity key. - The "first user frame" is a heuristic: the first stack frame outside
sqlalchemy/,asyncio/,greenlet/, and this package's own source. A framework that wraps SQLAlchemy calls deep in its own internals (e.g. a generic repository layer) will show that wrapper's frame, not your business-logic call site. - Concurrent unsafe loads across tasks:
mode="explain"'sdo_orm_execute->handle_errorcorrelation uses acontextvars.ContextVar, which is per-asyncio-task. Two unsafe loads from two different, unrelated sync threads sharing one process could theoretically cross wires; this wasn't exercised by the test suite (async tests only). - Triggering the unsafe path always logs a stray
RuntimeWarning: coroutine 'Connection.cursor' was never awaited. This comes from SQLAlchemy's own cleanup of the cancelled DBAPI coroutine whenMissingGreenletfires (see_safe_cancel_awaitableinsqlalchemy/util/concurrency.py) - it happens with or without this package installed and is not somethingmissinggreenletcan suppress. - Not a substitute for
assert_no_lazy_loads()as a runtime guard.assert_no_lazy_loads()raisesAssertionErroron any implicit load, which is right for a test but too strict for production code that sometimes safely lazy-loads inside an awaited context. - Doesn't cover bulk operations (
session.execute(update(...)),Session.bulk_*) or Core-only usage - both are already un-ORM'd, soMissingGreenletthere isn't about a "lazy load" in this package's sense.
Verified locally (this machine has Python 3.14.7 only - CI covers 3.11-3.14):
- SQLAlchemy 2.0.54 and 2.1.1, each in its own virtualenv, full test suite
(
pytest -q, 16 tests) passing against both. - Driver:
aiosqlite(async),sqlite3(sync, forassert_no_lazy_loadsand the sync half ofinstall()). Not tested againstasyncpgorasyncmy- the mechanism is driver-agnostic (it triggers on the ORM loader, not the DBAPI), but only sqlite was actually run. mypy --strictoversrc/andtests/: passing, on Python 3.14.7.- CI (
.github/workflows/test.yml) runs the suite on 3.11-3.14 - "run in CI", not independently verified here.
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest -q
.venv/bin/python -m mypy --strict src tests
.venv/bin/python examples/demo.pyMIT - see LICENSE.