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, which covers the full request-scoped dependency lifecycle this one setting slots into.

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. This flag belongs alongside the rest of your async engine and connection pool configuration, since it is a property of the async_sessionmaker you build there.

What COMMIT does to the objects you already loaded Two behaviours at commit time. With the default expire_on_commit True, every attribute of every instance in the session is marked expired, so the next read of any of them triggers a refresh query — which under async raises MissingGreenlet if the session is gone, and is a wasted round trip if it is not. With expire_on_commit False the loaded values stay in memory exactly as they were read, so the objects can be serialised after the session closes and no refresh is issued. The trade recorded below is that those values are now a snapshot: a concurrent writer's change is not reflected until you re-query. expire_on_commit=True (the default) every attribute is expired at commit the next read issues a refresh query raises MissingGreenlet once the session is gone and costs a round trip even when it works expire_on_commit=False loaded values stay in memory safe to serialise after the session closes no refresh, no extra round trip the values are a snapshot as of the read The snapshot caveat is real but rarely matters in a request handler: the response is being built from data read moments ago. It matters in a long-lived worker that loops over objects.

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?

Post-commit expiry is only one of several ways to hit this error — the full set of MissingGreenlet root causes (wrong session type, lazy relationship access) is covered in the sibling page; here we focus on the FastAPI serialization timeline specifically.

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:

FastAPI commit-to-serialize timeline: expire_on_commit True vs False A FastAPI route returns an ORM instance, then the dependency context exits and await session.commit() fires. With expire_on_commit=True (left, broken) SQLAlchemy marks every attribute expired, so when Pydantic reads user.id it triggers a lazy SELECT that raises MissingGreenlet because no async greenlet is active. With expire_on_commit=False (right, working) the expiry step is skipped, Pydantic reads the retained in-memory values with no database I/O, and the response serializes successfully. Route returns user (ORM instance) return user FastAPI exits dependency context await session.commit() fires expire_on_commit=True — default, breaks Every attribute marked EXPIRED Pydantic reads user.id Expired → lazy SELECT attempted MissingGreenlet ✗ no async greenlet to run the I/O raised inside the serializer expire_on_commit=False — the fix Expiry step skipped — values kept Pydantic reads user.id from memory No DB I/O, no greenlet needed Response serialized ✓ 201 Created returned to client pre-commit values reused safely
  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. The timing of step 3 is dictated by where your transaction and commit boundary sits — moving the commit outside the request-serving path is another way to sidestep the expiry, but expire_on_commit=False is the simpler fix.

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

The way this dependency scopes one AsyncSession per request — and returns its connection to the pool when the async with block exits — is governed by the AsyncSession lifecycle and scope management rules; expire_on_commit=False only changes what happens to attribute state at the commit inside that scope, not the scope itself.

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 plus 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.

Where a stale snapshot actually causes a bug Three situations. A long-running worker that holds instances across many commits will keep serving values read at load time, so anything another process changed is invisible; call session.expire(obj) or re-query at the top of each iteration. A read-after-write that must observe a change made by a different transaction will not see it from cached attributes, so refresh explicitly with await session.refresh(obj). And a retry loop that reuses objects from the failed attempt is reasoning about state the database rolled back, so a retry should always start from a fresh session and a fresh read. The closing note is that none of these are arguments against the setting in a request handler, where the object lives for milliseconds. a worker looping across many commits keeps serving values read at load time — other processes are invisible fix: session.expire(obj) or re-query at the top of each iteration a read-after-write across transactions cached attributes cannot show another transaction’s commit fix: await session.refresh(obj) at the point you need it fresh a retry loop reusing objects from the failed attempt that state was rolled back — the objects describe a database that never existed fix: a retry starts from a fresh session and a fresh read None of these argue against expire_on_commit=False in a request handler, where the objects live for milliseconds. They argue for scoping the session to the work, not to the process.

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. The same split matters when you run async SQLAlchemy inside Celery task workers, where each task should re-read committed state rather than trust pre-commit memory.

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:

from decimal import Decimal

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=Decimal("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.