Fixing DetachedInstanceError After Commit in SQLAlchemy

Set expire_on_commit=False on your sessionmaker or async_sessionmaker factory — this is the fastest fix for DetachedInstanceError on attribute access after commit(), and it is covered in depth in the Session Lifecycle and Scope Management guide alongside all other object state transitions.

Quick Answer

DetachedInstanceError after commit() occurs because SQLAlchemy expires every attribute on every tracked object at commit time (expire_on_commit=True is the default). If the session is then closed before you access those attributes, the ORM has nowhere to issue the SELECT that would reload them, and it raises DetachedInstanceError.

The three production fixes, in order of preference:

# Fix 1: Disable expiry on commit (preferred for APIs)
# Before (causes DetachedInstanceError if session closes before attribute access):
session_factory = sessionmaker(engine, expire_on_commit=True)  # default

# After (attributes remain loaded after commit):
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db", pool_size=10)
AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,  # attributes survive commit without re-SELECT
)
# Fix 2: Eager-load attributes you need before the session closes
from sqlalchemy import select
from sqlalchemy.orm import selectinload

async def get_order_with_items(session: AsyncSession, order_id: int):
    stmt = (
        select(Order)
        .where(Order.id == order_id)
        .options(selectinload(Order.items))  # hydrates before commit
    )
    result = await session.execute(stmt)
    order = result.scalar_one()
    await session.commit()
    # order.items is safe — already loaded into memory
    return order
# Fix 3: Refresh the specific object before session closes
async def create_user(session: AsyncSession, name: str) -> User:
    user = User(name=name)
    session.add(user)
    await session.commit()
    await session.refresh(user)  # re-SELECTs all columns from DB
    # Now user.id, user.created_at, and all columns are re-loaded
    return user

Execution Context & Async Workflow Integration

The error appears most frequently in async FastAPI applications because the typical dependency pattern yields the session to the route handler, commits on exit, and then closes the session — all before the response serializer accesses the returned ORM object.

from __future__ import annotations

from typing import AsyncGenerator
from fastapi import Depends, FastAPI
from sqlalchemy import Integer, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

app = FastAPI()


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    email: Mapped[str] = mapped_column(String(255))


engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db", pool_size=10)

# BROKEN: expire_on_commit=True (default)
BrokenSessionLocal = async_sessionmaker(engine, class_=AsyncSession)

# FIXED: expire_on_commit=False
FixedSessionLocal = async_sessionmaker(
    engine, class_=AsyncSession, expire_on_commit=False
)


async def get_broken_session() -> AsyncGenerator[AsyncSession, None]:
    async with BrokenSessionLocal() as session:
        yield session
        await session.commit()
        # session closes here — all attributes expired


async def get_fixed_session() -> AsyncGenerator[AsyncSession, None]:
    async with FixedSessionLocal() as session:
        yield session
        await session.commit()
        # attributes remain in-memory after commit


@app.post("/users-broken")
async def create_user_broken(
    name: str, session: AsyncSession = Depends(get_broken_session)
) -> dict:
    user = User(name=name, email=f"{name}@example.com")
    session.add(user)
    # commit happens in dependency teardown, AFTER this function returns
    # Pydantic tries to access user.id AFTER the session is closed
    return {"id": user.id, "name": user.name}  # DetachedInstanceError here


@app.post("/users-fixed")
async def create_user_fixed(
    name: str, session: AsyncSession = Depends(get_fixed_session)
) -> dict:
    user = User(name=name, email=f"{name}@example.com")
    session.add(user)
    # expire_on_commit=False keeps attributes loaded after dependency teardown
    return {"id": user.id, "name": user.name}  # Safe

The root cause sequence is:

  1. session.commit() is called — SQLAlchemy expires all attributes on tracked objects.
  2. session.close() is called (or the async with block exits) — the session is no longer usable.
  3. Application code accesses user.id or another attribute — SQLAlchemy tries to lazy-load it by issuing a SELECT, but there is no open session to execute through.
  4. DetachedInstanceError is raised.

This exact sequence also occurs when objects are passed across task boundaries in Celery or asyncio task queues — the session that created the object closes in one task, and the consuming task attempts attribute access in another execution context.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
sqlalchemy.orm.exc.DetachedInstanceError: Instance <User at 0x...> is not bound to a Session; attribute refresh operation cannot proceedexpire_on_commit=True (default) expires all attributes on commit(). Attribute accessed after session closes.Set expire_on_commit=False on sessionmaker/async_sessionmaker, or call session.refresh(obj) before session closes.
DetachedInstanceError on relationship access after commit()Relationship was not loaded before commit; lazy loading attempted on detached object.Use selectinload() or joinedload() in the query to eagerly load required relationships before committing.
DetachedInstanceError when passing ORM objects to Celery tasksSession closed in the producing context; Celery worker accesses attributes in a different process/thread.Use expire_on_commit=False and serialize to dict/Pydantic schema before enqueuing. Never pass live ORM instances across process boundaries.
DetachedInstanceError on obj.id immediately after session.add() then session.commit()id is a server-generated primary key (autoincrement / sequence). After commit() with expire_on_commit=True, id is expired — even though it was loaded during flush.Call await session.refresh(user) after commit, or set expire_on_commit=False.
sqlalchemy.exc.InvalidRequestError: Instance <User> is not persistent within this SessionAttempting session.refresh(obj) on an object that is already detached from this session (different session instance).Use session.merge(obj) to re-integrate the object, then refresh if needed.
MissingGreenlet: greenlet_spawn has not been calledAccessing a lazy-loaded relationship inside an async context without await, causing SQLAlchemy to attempt sync I/O.Eager-load with selectinload() or use AsyncSession.run_sync() for explicit sync bridging. See the GreenletSpawnError guide.

Diagnosing Which Attributes Are Expired

Use inspect() to see exactly which attributes are expired on a given object before you hit the error in production:

from __future__ import annotations

from typing import Any
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import InstanceState


def list_expired_attrs(obj: Any) -> list[str]:
    """Return attribute names that will trigger a lazy SELECT on next access."""
    state: InstanceState = sa_inspect(obj)
    return list(state.expired_attributes)


# Usage: call before session.close() to audit what will be unavailable
async def debug_expiry(session: AsyncSession, user_id: int) -> None:
    result = await session.execute(select(User).where(User.id == user_id))
    user = result.scalar_one()
    await session.commit()  # expire_on_commit=True causes expiry here

    expired = list_expired_attrs(user)
    print(f"Expired attrs after commit: {expired}")
    # Typical output: ['name', 'email', 'created_at', ...]
    # All will raise DetachedInstanceError if session is now closed

Advanced Session Expiry Optimization

Selective Per-Object expiry with session.expire(obj, attrs)

When expire_on_commit=False is set globally but you know a specific object might be stale (for example, it was modified by a background job between your last read and the current request), you can expire only the attributes that matter:

from __future__ import annotations

from sqlalchemy import Integer, String, Numeric
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession
from decimal import Decimal


class Base(DeclarativeBase):
    pass


class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(200))
    price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
    stock_count: Mapped[int] = mapped_column(Integer)


async def get_fresh_price(session: AsyncSession, product_id: int) -> Decimal:
    product = await session.get(Product, product_id)
    if product is None:
        raise ValueError(f"Product {product_id} not found")

    # Only expire the price column — force re-read from DB without full refresh
    session.expire(product, ["price"])
    # Next access to product.price triggers a targeted SELECT
    return product.price  # Safe — triggers a single-column reload

This technique avoids the overhead of session.refresh(obj) (which re-fetches all columns) when only one or two columns are expected to change externally.

Using session.merge() for Reintegration After Session Close

When an object was loaded in one request context and must be used in another (e.g., cached in-memory between API calls), session.merge() is safer than session.add():

from __future__ import annotations

from sqlalchemy import Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession


class Base(DeclarativeBase):
    pass


class Tenant(Base):
    __tablename__ = "tenants"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(200))
    plan: Mapped[str] = mapped_column(String(50))


# Simulating a detached object retrieved from application cache
cached_tenant = Tenant(id=7, name="Acme Corp", plan="enterprise")

async def update_tenant_plan(session: AsyncSession, cached_obj: Tenant, new_plan: str) -> Tenant:
    # merge() loads current DB state, overlays cached_obj's attributes, returns persistent copy
    merged = await session.merge(cached_obj)
    merged.plan = new_plan
    await session.commit()
    # merged is persistent; with expire_on_commit=False its attributes are still valid
    return merged

session.merge() issues a SELECT by primary key if the object is not already in the identity map, then merges the provided attribute values onto the loaded instance. The original cached_obj remains detached and unchanged — only the returned merged object is managed by the session.

Frequently Asked Questions

Is it safe to set expire_on_commit=False globally? Yes, for the vast majority of web API workloads where each request gets its own session. Attributes loaded during the request remain valid for the duration of response serialization. The risk is staleness: if another process modifies the same row between your commit() and your next read in a long-lived session, you may serve outdated data. For long-lived sessions or background workers processing the same rows repeatedly, use session.refresh(obj) at the start of each processing cycle rather than relying on post-commit expiry.

Why does user.id raise DetachedInstanceError after commit() when the id was clearly set during insert? SQLAlchemy's expire_on_commit=True (the default) expires every attribute on every tracked object after commit() — including id. Even though the database assigned the id during the flush, SQLAlchemy marks it as expired so the next access re-reads from the database. If the session closes before that access, the re-read is impossible and DetachedInstanceError is raised. Fix: expire_on_commit=False, or await session.refresh(user) immediately after await session.commit().

What is the difference between session.refresh(obj) and session.expire(obj)?session.expire(obj) marks the object's attributes as expired without issuing any SQL. The next attribute access triggers a lazy SELECT to reload them from the database. session.refresh(obj) immediately issues a SELECT and reloads all columns (or a specified subset) right now, synchronously within the current async context. Use expire() when you want deferred reloading on demand; use refresh() when you need the fresh value immediately.

Can DetachedInstanceError occur even with expire_on_commit=False? Yes — expire_on_commit=False only prevents expiry at commit time. The error can still occur if you explicitly call session.expunge(obj) or session.close() and then access unloaded attributes. It also occurs if you access lazy-loaded relationships that were never fetched, regardless of expiry settings. The fix for those cases is eager loading with selectinload() before detachment.