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:

  1. Route handler body completes and returns the ORM object, e.g. return user.
  2. FastAPI prepares to send the HTTP response. Before serializing, it must exit the dependency context managers in reverse order.
  3. The get_db_session dependency's async with session.begin() block exits — await session.commit() fires.
  4. SQLAlchemy marks every attribute on every tracked instance (including user) as expired.
  5. FastAPI calls the response_model validator, e.g. Pydantic's UserRead.model_validate(user).
  6. 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 / symptomRoot causeFix
MissingGreenlet: greenlet_spawn has not been called raised during Pydantic serializationexpire_on_commit=True expired attributes after commit; lazy SELECT attempted with no async context activeasync_sessionmaker(..., expire_on_commit=False)
MissingGreenlet on obj.relationship_name after commitRelationship collection also expired by default; lazy load attempted outside async contextexpire_on_commit=False + eager-load with selectinload on the original query
DetachedInstanceError: Instance <User> is not bound to a Session after session closesExpired instance accessed after the session context exited entirelyexpire_on_commit=False; or await session.refresh(obj) before session closes
Stale updated_at timestamp returned in JSON responseDB trigger updated the column; expire_on_commit=False returns the pre-commit Python value, not the trigger-set valueawait session.refresh(obj, attribute_names=["updated_at"]) before returning from route
None returned for auto-generated PK (id) in responseflush() not called before commit; DB sequence value was never populated in Pythonawait session.flush() immediately after session.add(obj), before returning the object
ValidationError from Pydantic on a field that is visibly populated in the constructorSame as MissingGreenlet but Pydantic catches the exception internally and re-raises as ValidationErrorexpire_on_commit=False
StaleDataError on session.commit()Optimistic locking version counter mismatch between Python-side value and DB-side value — unrelated to expire_on_commitReload the instance with await session.refresh(obj) and retry the update
Background task reads stale dataRequest session committed; background task reuses same session which now has expired state with expire_on_commit=TrueBackground 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.