Using expire_on_commit=False in FastAPI Dependencies
Set expire_on_commit=False in async_sessionmaker and your FastAPI responses will serialize ORM instances correctly after commit — without it, every attribute read after await session.commit() raises MissingGreenlet because SQLAlchemy tries a synchronous lazy reload that has nowhere to run. This page is part of the guide to integrating SQLAlchemy async with FastAPI and Starlette.
Quick Answer
# WRONG — default expire_on_commit=True causes MissingGreenlet after commit
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
# Omitting expire_on_commit means it defaults to True — dangerous in async FastAPI
bad_factory = async_sessionmaker(engine, class_=AsyncSession)
# CORRECT — expire_on_commit=False keeps in-memory attribute values after commit
good_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # This one setting prevents the post-commit MissingGreenlet
)
In a FastAPI route that returns an ORM model (or one that Pydantic validates via model_validate), the response serializer reads instance attributes after the dependency's commit fires. With the default expire_on_commit=True, those attributes are expired, triggering a lazy SELECT that raises MissingGreenlet. With expire_on_commit=False, the pre-commit in-memory values remain available for as long as the Python object is referenced.
Execution Context & Async Workflow Integration
Why SQLAlchemy expires attributes by default
SQLAlchemy's identity map tracks every attribute on every ORM instance. After a commit(), the authoritative state of the row is in the database, not in Python memory. A DB trigger might have updated a timestamp. A DEFAULT expression might have set a column the Python code never touched. An advisory lock release might have allowed a concurrent UPDATE to change a value. To prevent stale reads on the next access, SQLAlchemy marks all attributes as expired after commit. The next attribute read then fetches a fresh value from the database.
In a synchronous session, expiry is harmless: user.email triggers a SELECT users WHERE id=? LIMIT 1, the value is returned, and execution continues. In an AsyncSession, however, the lazy SELECT cannot run synchronously. The greenlet frame that managed the async DBAPI bridge was torn down when the session's async with session.begin() context exited. Attempting a lazy SELECT at that point raises:
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
can't call await_only() here. Was IO attempted in an unexpected place?
The exact FastAPI commit timeline
Understanding precisely when commit fires relative to Pydantic serialization clarifies why this error appears in places that look unrelated to the database:
- Route handler body completes and returns the ORM object, e.g.
return user. - FastAPI prepares to send the HTTP response. Before serializing, it must exit the dependency context managers in reverse order.
- The
get_db_sessiondependency'sasync with session.begin()block exits —await session.commit()fires. - SQLAlchemy marks every attribute on every tracked instance (including
user) as expired. - FastAPI calls the
response_modelvalidator, e.g. Pydantic'sUserRead.model_validate(user). - Pydantic reads
user.id→ expired → lazy SELECT attempt →MissingGreenlet.
Step 6 is where the stack trace appears — inside Pydantic's validator, pointing at seemingly innocent attribute access. Developers are frequently confused because the traceback shows no obvious database code; the error appears to originate in the serialization layer.
expire_on_commit=False eliminates step 4
With expire_on_commit=False configured in async_sessionmaker, step 4 is skipped entirely. Attributes retain their pre-commit Python-side values. When Pydantic reads user.id, user.email, user.created_at in step 6, it reads from the Python dictionary that has been in memory since the instance was loaded — no database I/O, no greenlet bridge required.
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost:5432/appdb"
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=5,
pool_pre_ping=True,
pool_recycle=1800,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # retain attribute values after commit
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
async with session.begin():
yield session # commit fires when this context exits normally
Full route example showing safe post-commit attribute access
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models import User
from myapp.schemas import UserCreate, UserRead
router = APIRouter()
@router.post("/users/", response_model=UserRead, status_code=201)
async def create_user(
payload: UserCreate,
session: AsyncSession = Depends(get_db_session),
) -> User:
user = User(email=payload.email, display_name=payload.display_name)
session.add(user)
await session.flush() # assigns user.id from the DB sequence, within the txn
# The dependency's context manager commits here when this function returns.
# expire_on_commit=False means user.id and user.email remain readable.
return user
# Pydantic reads user.id, user.email, user.created_at after commit — all safe.
Without await session.flush() before returning, user.id would be None (the sequence value has not been assigned yet). Flush assigns the PK within the current transaction, so it is available when the function returns and Pydantic serializes the response.
Resolving Warnings, Errors & Common Mistakes
| Exact error / symptom | Root cause | Fix |
|---|---|---|
MissingGreenlet: greenlet_spawn has not been called raised during Pydantic serialization | expire_on_commit=True expired attributes after commit; lazy SELECT attempted with no async context active | async_sessionmaker(..., expire_on_commit=False) |
MissingGreenlet on obj.relationship_name after commit | Relationship collection also expired by default; lazy load attempted outside async context | expire_on_commit=False + eager-load with selectinload on the original query |
DetachedInstanceError: Instance <User> is not bound to a Session after session closes | Expired instance accessed after the session context exited entirely | expire_on_commit=False; or await session.refresh(obj) before session closes |
Stale updated_at timestamp returned in JSON response | DB trigger updated the column; expire_on_commit=False returns the pre-commit Python value, not the trigger-set value | await session.refresh(obj, attribute_names=["updated_at"]) before returning from route |
None returned for auto-generated PK (id) in response | flush() not called before commit; DB sequence value was never populated in Python | await session.flush() immediately after session.add(obj), before returning the object |
ValidationError from Pydantic on a field that is visibly populated in the constructor | Same as MissingGreenlet but Pydantic catches the exception internally and re-raises as ValidationError | expire_on_commit=False |
StaleDataError on session.commit() | Optimistic locking version counter mismatch between Python-side value and DB-side value — unrelated to expire_on_commit | Reload the instance with await session.refresh(obj) and retry the update |
| Background task reads stale data | Request session committed; background task reuses same session which now has expired state with expire_on_commit=True | Background tasks must open their own async_session_factory() context — never share the request session |
Advanced expire_on_commit=False Optimization
Refreshing only DB-generated columns
expire_on_commit=False keeps the pre-commit Python values. If the database populates a column that your Python code did not set — a DEFAULT NOW() timestamp, a trigger-computed field, a GENERATED ALWAYS AS column — that column's in-memory value will be stale after commit until you explicitly refresh it.
The pattern is to refresh only the specific column(s) you need, within the transaction, before the dependency commits:
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models import Invoice
async def create_invoice(session: AsyncSession, data: dict) -> Invoice:
invoice = Invoice(**data)
session.add(invoice)
await session.flush() # invoice.id assigned by sequence
# The DB has a trigger that populates invoice.invoice_number.
# Refresh only that column — still within the transaction.
await session.refresh(invoice, attribute_names=["invoice_number"])
# Dependency commits here on function return.
# expire_on_commit=False keeps invoice.id, invoice.invoice_number, all other
# attributes readable when Pydantic serializes the response.
return invoice
session.refresh(obj, attribute_names=[...]) re-selects only the named columns from the database, within the current transaction, and populates them in the Python object. It is more efficient than session.refresh(obj) with no arguments, which re-fetches all columns.
Two session factories for different use cases
If your application mixes FastAPI request handlers (where expire_on_commit=False is correct) and background data pipelines (where you want fresh DB reads after every commit), declare two factories:
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession, create_async_engine
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db", pool_size=20)
# FastAPI request handlers: safe post-commit attribute access for serialization
request_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Background pipelines and scheduled jobs: always re-read from DB after commit
pipeline_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=True, # explicit; reload from DB on next access after commit
)
Using two named factories makes the intent explicit in code review and prevents a background pipeline developer from inadvertently relying on stale cached values.
Testing with expire_on_commit=False
When writing pytest tests that use expire_on_commit=False sessions, test assertions after commit may not catch bugs where a DB constraint changes a value (e.g., a CHECK rounds a price, a trigger normalises a status string). The Python-side value looks correct because it was never refreshed. Two strategies:
Strategy 1 — Use expire_on_commit=True in test sessions. Tests re-read from the DB, catching constraint-driven changes. Accept that you must eager-load relationships explicitly in test queries.
Strategy 2 — Explicit refresh in assertion blocks. Keep expire_on_commit=False in the test session factory (to match production) but add await session.refresh(obj) in the assertion section of tests that check DB-computed values:
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models import Product
@pytest.mark.asyncio
async def test_price_is_floored_by_constraint(session: AsyncSession) -> None:
product = Product(name="Widget", price=0.001)
session.add(product)
await session.flush()
# Force a re-read to catch any DB-side CHECK constraint rounding
await session.refresh(product)
assert product.price >= Decimal("0.01"), "Price floor constraint not applied"
Per-session override without changing the factory
If you need expire_on_commit=False on a single session without changing the factory default, set it on the instance:
async with async_session_factory() as session:
session.expire_on_commit = False # override for this session only
async with session.begin():
...
Setting it before any objects are loaded ensures all instances in this session are governed by the new setting. This technique is useful in test fixtures that need to override the application's pipeline factory setting for a specific test case.
Frequently Asked Questions
Does expire_on_commit=False serve stale data across requests?
No. Each request receives a fresh AsyncSession from the Depends dependency. The expire_on_commit=False setting only affects instances within the same session's lifetime. Every new session loads fresh data from the database on its first query.
What happens to relationship collections after commit with expire_on_commit=False?
Collections loaded before commit remain populated in memory with their pre-commit content. If a concurrent process deleted a child row, that deletion is not reflected until a new session loads the parent, or until await session.refresh(obj, attribute_names=["items"]) is called explicitly. For request-scoped sessions this is almost never an issue — the session loads data, commits a write, and immediately returns in the same request cycle. No concurrent mutation from another process is expected to interfere within a single request's lifespan.
Should I use expire_on_commit=False in Celery workers or long-running background sessions?
Generally no. Background workers often process items in loops, committing after each item. With expire_on_commit=True, the next loop iteration re-reads the item from the DB, reflecting any changes made by concurrent workers or DB triggers. With expire_on_commit=False, the worker would continue using pre-commit values, potentially acting on stale state. Use expire_on_commit=True (the default) in background and pipeline sessions, and expire_on_commit=False only in request-scoped FastAPI dependencies.
Can I set expire_on_commit on a per-commit basis rather than per session?
Not directly through the commit call. The expire_on_commit flag is a session-level property. To prevent expiry for a specific commit only, set session.expire_on_commit = False before calling await session.commit() and reset it to True afterwards — though this is fragile in concurrent async code. The cleaner approach is separate session factories as described in the two-factory pattern above.
Related
- Integrating SQLAlchemy Async with FastAPI and Starlette — parent guide covering the full dependency injection lifecycle that this setting is part of.
- Fixing GreenletSpawnError in Async SQLAlchemy Workflows — all MissingGreenlet root causes, not only the post-commit expiry case covered here.
- Configuring Async Engines and Connection Pools — the full engine and
async_sessionmakerconfiguration reference these settings live in.