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:
session.commit()is called — SQLAlchemy expires all attributes on tracked objects.session.close()is called (or theasync withblock exits) — the session is no longer usable.- Application code accesses
user.idor another attribute — SQLAlchemy tries to lazy-load it by issuing aSELECT, but there is no open session to execute through. DetachedInstanceErroris 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 / Warning | Root Cause | Production Fix |
|---|---|---|
sqlalchemy.orm.exc.DetachedInstanceError: Instance <User at 0x...> is not bound to a Session; attribute refresh operation cannot proceed | expire_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 tasks | Session 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 Session | Attempting 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 called | Accessing 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.
Related
- Session Lifecycle and Scope Management — Parent guide covering all five object states, identity map semantics, and
expire_on_commitconfiguration in depth. - Understanding Session.expunge vs Session.clear in Python — Sibling guide on deliberate detachment via
expunge()andclear(), including safe pre-load patterns. - Using expire_on_commit=False in FastAPI Dependencies — Framework-specific implementation of the recommended fix with full FastAPI dependency code.
- Fixing GreenletSpawnError in Async SQLAlchemy Workflows — Related error that occurs when lazy loading is attempted inside async contexts without proper await handling.