sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called is the error that greets almost every team migrating a FastAPI app from sync SQLAlchemy to async, and it’s rarely obvious what it’s actually complaining about. The traceback usually points at a line that looks completely innocent — accessing order.customer.name in a Jinja template, or in a response serializer, nowhere near any database call you wrote yourself. The instinct is to treat it as some obscure async compatibility bug. It isn’t. It’s SQLAlchemy telling you, as precisely as it can, that you tried to run a lazy-loaded query outside of the async context that makes lazy loading possible at all.
Getting async SQLAlchemy right in FastAPI isn’t about memorizing one error message — it’s about understanding that a session’s lifecycle, an engine’s lifecycle, and a relationship’s loading strategy all have to agree with each other, and async makes the places where they can silently disagree much less forgiving than sync SQLAlchemy ever was. This post covers session-per-request scoping done correctly, what actually triggers MissingGreenlet, why relationships you never explicitly queried can crash a response, and where the engine itself should live relative to your app’s lifespan.
You’ll learn:
- How to scope an async SQLAlchemy session correctly per request using FastAPI’s
Dependsandyield - What
MissingGreenletactually means, and the three places it most commonly shows up - Why accessing an unloaded relationship attribute after the session context ends causes a lazy-load explosion
- The difference between a lifespan-managed engine and a request-scoped session, and why they’re not the same lifecycle
- Eager loading strategies (
selectinload,joinedload) that prevent lazy-load errors before they happen - A complete, working async session setup you can copy directly into a project
Table of Contents
- The Basics
- Session-Per-Request Scoping
- The MissingGreenlet Error, Explained
- Lazy-Load Explosions
- Lifespan-Managed Engine vs Request-Scoped Session
- Eager Loading Strategies
- A Complete Working Setup
- Testing Async SQLAlchemy Code
- Connection Pool Sizing Under Concurrent Load
- Common Pitfalls
- Production Best Practices
The Basics
Why Async SQLAlchemy Behaves Differently From Sync
Sync SQLAlchemy’s lazy loading is forgiving almost by accident: when you access order.customer and it hasn’t been loaded yet, SQLAlchemy just issues a new synchronous query right there, on the spot, and returns the result. It works because a blocking database call inside a Python attribute access is invisible — Python doesn’t distinguish between “fast attribute access” and “attribute access that secretly does I/O.”
Async SQLAlchemy can’t do that silently, because issuing a query requires await, and you can’t await from inside a plain attribute access (__getattr__ can’t be a coroutine in a way Python will implicitly await for you). SQLAlchemy’s actual solution is a bridge called greenlet, which lets certain sync-looking calls run inside an async context by switching into a greenlet that can await on your behalf — but that bridge only exists within the boundaries SQLAlchemy sets up for it, specifically while a session’s async context is active. Step outside those boundaries, and MissingGreenlet is SQLAlchemy telling you the bridge isn’t there anymore.
The Engine, the Session, and the Relationship — Three Different Lifecycles
The recurring theme behind almost every async SQLAlchemy bug in a FastAPI app is that three things need compatible lifecycles, and it’s easy to mismatch them: the engine (long-lived, created once at app startup), the session (short-lived, one per request), and relationship loading (needs to happen while the session that will do the loading is still open). Getting any one of these wrong produces a different flavor of the same underlying problem.
Session-Per-Request Scoping
The correct pattern is a yield-based dependency, exactly the pattern discussed generally in FastAPI Dependency Injection: Patterns and Anti-Patterns — a session is opened when a request starts, and guaranteed to close when it ends, regardless of whether the handler succeeded or raised:
# app/core/database.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
AsyncSessionLocal = async_sessionmaker(
bind=engine,
expire_on_commit=False,
class_=AsyncSession,
)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
@router.get("/orders/{id}")
async def get_order(id: int, db: AsyncSession = Depends(get_db)):
order = await db.get(Order, id)
return order
Two things here are easy to get wrong. First, expire_on_commit=False matters specifically for FastAPI: by default, SQLAlchemy expires all loaded attributes after a commit, meaning the next attribute access re-triggers a lazy load — which, as covered next, is exactly the situation that produces MissingGreenlet if that access happens after your response serialization has started. Setting it to False keeps already-loaded attributes usable after commit without triggering a fresh query. Second, the session must be created inside the dependency function, not at module import time — a session created once and reused across requests silently shares state (and, worse, connections) across unrelated requests, which breaks the entire “one session per request” model this pattern depends on.
The MissingGreenlet Error, Explained
MissingGreenlet fires when SQLAlchemy needs to run I/O — an implicit lazy load, a flush, a refresh — outside the greenlet-bridged async context, which in practice means the session (or its underlying connection) has already been closed or the code path isn’t actually running inside an awaited async call at all. Three places this shows up constantly:
1. Accessing a relationship after the request has responded. If a Pydantic response model or a background task touches order.customer after get_db’s async with block has exited, the session is closed and there’s no greenlet bridge left to do the implicit query.
2. Calling a sync serialization library on an ORM object with unloaded relationships. Some serializers (or older Pydantic configurations using .from_orm() on deeply nested models) touch every attribute during serialization, including ones you never explicitly loaded — if any of those trigger a lazy load outside the session’s active context, MissingGreenlet is the result.
3. Using a sync-style call directly on an async session or engine. Code copy-pasted from a sync SQLAlchemy codebase — db.query(...) instead of await db.execute(select(...)), or accessing .scalars() without awaiting the execute call first — bypasses the async driver’s expectations entirely and produces the same error, because the sync API path was never wired through the greenlet bridge to begin with.
The fix in every case is the same shape: make sure whatever needs data from the database happens while the session is still open and inside an awaited call, not after.
Lazy-Load Explosions
Even when MissingGreenlet doesn’t fire — because, say, lazy="select" relationships happen to get accessed while the session is still technically open — a different problem shows up: an N+1 query explosion, now paid for with the added overhead of a full async round-trip per lazy load instead of a cheap sync one.
@router.get("/orders")
async def list_orders(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Order))
orders = result.scalars().all()
return [
{"id": o.id, "customer": o.customer.name} # implicit lazy load, per order
for o in orders
]
For 100 orders, this issues 1 query to fetch the orders and then, inside the list comprehension, up to 100 additional implicit queries — one per o.customer access — each one a full async round trip to the database. In sync SQLAlchemy this is already a performance problem; in async SQLAlchemy it’s frequently also a correctness problem, because whether the lazy load succeeds at all depends on subtle timing around whether the session’s greenlet context is still considered active at the point of access, which is exactly the kind of thing that works in a quick local test and fails intermittently under real request patterns.
Lifespan-Managed Engine vs Request-Scoped Session
The engine and the session are not the same object with different names — they’re different lifecycles serving different purposes, and conflating them is a common source of connection pool exhaustion.
The engine owns the actual connection pool and should be created exactly once, at application startup, and disposed exactly once, at shutdown — via FastAPI’s lifespan. This is also exactly the kind of cross-cutting, domain-agnostic code that belongs in a shared core/database.py, as laid out in FastAPI Project Structure That Survives Growth — every domain’s router imports get_db from there, and none of them need to know how the engine itself was constructed:
# app/main.py
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(
"postgresql+asyncpg://user:pass@localhost/db",
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
The session is cheap, short-lived, and scoped to a single request via the get_db dependency shown earlier — it borrows a connection from the engine’s pool for the duration of the request and returns it when the session closes. Creating a new engine per request (instead of per app) is the mistake that actually exhausts a database’s connection limit under load, because each engine brings its own pool, and pools that are never disposed keep their connections open indefinitely. pool_pre_ping=True is worth defaulting to in production — it validates a pooled connection isn’t stale (closed by the database side, or by a load balancer’s idle timeout) before handing it to a request, trading a small latency cost for avoiding a much more confusing “connection already closed” error surfacing mid-request.
Eager Loading Strategies
The durable fix for both MissingGreenlet and N+1 explosions is the same: load what you need while the session is open, explicitly, instead of relying on implicit lazy loading to happen later.
selectinload issues a second, separate query for the related rows, batched by primary key — the right default for one-to-many relationships:
from sqlalchemy.orm import selectinload
result = await db.execute(
select(Order).options(selectinload(Order.items))
)
orders = result.scalars().all()
# order.items is already loaded — no further query needed
joinedload pulls the related data in via a SQL JOIN in the same query — better for many-to-one or one-to-one relationships where the extra columns per row are cheap:
from sqlalchemy.orm import joinedload
result = await db.execute(
select(Order).options(joinedload(Order.customer))
)
For a relationship you need in most read paths, consider setting lazy="raise" on the relationship definition itself — it turns an accidental implicit lazy load into an immediate, loud exception at the exact access point, instead of a MissingGreenlet several stack frames away from the actual mistake:
customer = relationship("Customer", lazy="raise")
This trades a confusing runtime error for a much clearer one, right where the missing eager-load option actually needs to be added.
A Complete Working Setup
Putting the pieces together — engine, session factory, dependency, model, and endpoint:
# app/core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# app/orders/models.py
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
customer: Mapped["Customer"] = relationship(lazy="raise")
items: Mapped[list["OrderItem"]] = relationship()
# app/orders/router.py
@router.get("/orders/{id}", response_model=OrderOut)
async def get_order(id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Order)
.options(selectinload(Order.items), joinedload(Order.customer))
.where(Order.id == id)
)
order = result.unique().scalar_one_or_none()
if order is None:
raise HTTPException(404)
return order
Note .unique() on the result when using joinedload on a collection-producing query — without it, a join that fans out rows can return duplicate parent objects, a detail that’s easy to miss until it shows up as unexplained duplicate entries in a response.
Testing Async SQLAlchemy Code
Testing async database code correctly means giving each test the same request-scoped session guarantee production gets, without spinning up a full HTTP client for every unit test. The cleanest approach wraps each test in its own transaction and rolls it back afterward, so tests never leak state into each other regardless of execution order:
# tests/conftest.py
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def db_session():
async with engine.connect() as conn:
await conn.begin()
async with AsyncSession(bind=conn, expire_on_commit=False) as session:
yield session
await conn.rollback()
Every test using db_session gets a fully isolated view of the database — inserts made in one test are invisible to the next, because the outer transaction is rolled back after each test regardless of whether the test itself called commit(). This is meaningfully faster than truncating tables between tests, and it sidesteps a whole class of test-order-dependent flakiness that shows up once a test suite grows past a handful of files.
For endpoint-level tests, pair this with the dependency_overrides pattern from FastAPI Dependency Injection: Patterns and Anti-Patterns — override get_db to yield the same transaction-wrapped db_session, so an HTTP-level test through httpx.AsyncClient and a direct service-function test share identical isolation guarantees.
Connection Pool Sizing Under Concurrent Load
pool_size and max_overflow aren’t cosmetic settings — they directly determine how many concurrent requests can be doing database work at once before later ones start queuing for a connection. A pool sized too small under real concurrency doesn’t fail loudly; it just makes every request slightly slower as requests wait their turn for a free connection, which is easy to misattribute to the database itself being slow rather than the pool being the bottleneck.
engine = create_async_engine(
DATABASE_URL,
pool_size=20, # connections kept open and ready
max_overflow=10, # additional connections allowed under burst load
pool_timeout=30, # seconds to wait for a connection before raising
pool_recycle=1800, # recycle connections older than 30 minutes
)
A reasonable starting point is sizing the pool to comfortably cover your typical concurrent request volume per worker process, remembering that if you run multiple Uvicorn/Gunicorn workers, each one gets its own engine and therefore its own pool — the database’s actual connection ceiling is pool_size + max_overflow, multiplied by the number of worker processes, not just the number configured in one engine. pool_recycle matters specifically for managed databases (RDS, Cloud SQL) that silently close connections held open past a certain idle threshold on their end — without it, those stale connections surface as confusing mid-request failures that pool_pre_ping catches but recycling avoids paying the cost of hitting in the first place.
Common Pitfalls
Mistake: creating the session at module scope instead of per-request. A shared session across requests breaks isolation and leaks state between unrelated users. Solution: always create the session inside the get_db dependency, scoped to one request.
Mistake: mixing a sync engine into an otherwise async app. A sync SQLAlchemy call inside an async def route blocks the event loop exactly as described in Why Your FastAPI Endpoint Blocks the Event Loop, on top of the async/sync API mismatch producing its own errors. Solution: use create_async_engine and the asyncpg driver consistently — don’t mix sync and async SQLAlchemy in the same codebase.
Mistake: accessing relationships in a Pydantic response model without eager loading them. This is the most common source of production MissingGreenlet errors, because it happens during serialization, after the handler’s own code has already finished. Solution: eager-load every relationship your response model will touch, explicitly, in the query itself.
Mistake: creating a new engine per request. Each engine owns its own pool, so this exhausts the database’s max connections quickly under any real concurrency. Solution: create the engine once, in lifespan, and reuse it for every session.
Mistake: not setting pool_pre_ping. A connection pool can hold references to connections the database has already closed (idle timeouts, restarts), producing confusing mid-request failures. Solution: enable pool_pre_ping=True in production, especially behind a load balancer or managed database with its own idle timeout.
Production Best Practices
- One engine per app, one session per request. Never conflate the two lifecycles.
- Set
expire_on_commit=Falseso committed objects stay usable through response serialization without triggering fresh lazy loads. - Eager-load explicitly for every response path, using
selectinloadfor collections andjoinedloadfor single related objects. - Consider
lazy="raise"on relationships used in read-heavy endpoints to convert silent lazy-load bugs into loud, immediately actionable errors. - Always commit or rollback explicitly in the session dependency, never rely on an implicit commit — an uncommitted write that “worked” locally due to autoflush behavior is a common surprise in production.
Wrapping Up
MissingGreenlet isn’t a mysterious async incompatibility — it’s SQLAlchemy accurately reporting that something needed the database after the session’s async context had already ended, which is almost always a session lifecycle or eager-loading gap rather than a framework bug. Get the engine’s lifespan, the session’s per-request scope, and your relationships’ loading strategy aligned, and the error class disappears entirely rather than needing to be debugged case by case.
Are your response models touching relationships you never explicitly eager-loaded? If you’re not sure, setting lazy="raise" on your busiest models for a day in a staging environment is a fast, low-risk way to find out — every silent lazy load turns into a stack trace pointing exactly at the missing selectinload or joinedload call.
