Python
sqlalchemy.orm.exc.DetachedInstanceError: instance is not bound to a Session
Written and reviewed by Sahil Srivastav
sqlalchemy.orm.exc.DetachedInstanceError: Instance <Order at 0x7f3a41c2b8e0> is not bound to a Session; attribute refresh operation cannot proceed (Background on this error at: https://sqlalche.me/e/20/bhk3)What this error actually means
An ORM instance is not a plain data object. It is a proxy with a live link to the `Session` that loaded it, and unloaded attributes — lazy relationships, deferred columns, anything expired — are fetched on first access through that link. This error means the link is gone and something just asked for a value that only the database has.
The detail that makes this feel arbitrary is `expire_on_commit`, which defaults to `True`. On commit, every loaded attribute on every instance in the session is marked expired, so the *next* attribute access triggers a refresh query. Inside the session that is invisible — it just costs a query. Outside it, every attribute access on the object raises, including ones you already read successfully a line earlier. Same object, same field, different outcome, entirely determined by whether the session is still open.
So the error is never really about the attribute in the traceback. It is a boundary violation: an object crossed out of the unit of work that owns it while still depending on that unit of work. The fix is to decide what crosses the boundary — data, or a live proxy — and make that explicit.
Causes, most common first
- 1The object is serialised after the session closes. A handler loads an entity inside a session scope, returns it, and the framework serialises it afterwards. Every lazy relationship the serialiser touches needs a query that can no longer be issued. The most common shape by far, and it worsens when someone later adds a nested field to the response model.
- 2Attribute access after commit, inside a still-open session. A subtler variant with a different mechanism: `expire_on_commit` expired everything, so the access does a refresh. It works, silently costing a query per attribute, until the object also crosses the session boundary — then the same line raises.
- 3An ORM object passed to a thread, task queue or cache. Handing an instance to Celery, a thread pool, an in-process cache or a websocket handler moves it away from the session that owns it. Sessions are not thread-safe, so even keeping the session open is not a fix here.
- 4The instance was expunged or the session rolled back. A rollback returns instances to a state where they must be refreshed; `expunge`, `session.close()` in a nested helper, or a scoped-session `remove()` in a middleware detaches them while your reference stays alive.
- 5Objects stored on a long-lived structure. Caching entities on a module-level dict, an application config object, or a user session keeps proxies alive far beyond any unit of work, so the first cache hit after a session closes raises rather than returning data.
When you see it
- The failure is in a serialiser, a template render, or a response model — after the request handler finished its database work
- Accessing a relationship raises while accessing the primary key works, because the identity key is kept on the detached instance
- It appears immediately after wrapping a function in a `with Session()` block or adding a commit
- A background task fails on an object handed to it by the request that queued it
- Setting `expire_on_commit=False` makes it disappear for scalar columns but not for relationships
How to diagnose it
Step 1
Read which attribute was being loaded
The frame directly below the SQLAlchemy internals is the access that needed the database. If it is a relationship, the fix is eager loading; if it is a scalar column, expiry on commit is the mechanism.
Step 2
Check the instance state explicitly
`inspect()` tells you whether the object is transient, pending, persistent, detached, and which attributes are currently unloaded. This converts a guess about the boundary into a fact.
from sqlalchemy import inspect
st = inspect(order)
print(st.detached, st.expired, st.unloaded)Step 3
Turn on SQL echo and count the queries
If a single response emits one query per row, you are lazy loading per item — the same root cause as this error, presenting as an N+1 problem rather than a crash. Both are fixed by loading what you need up front.
create_engine(dsn, echo=True) # or logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)Step 4
Find where the boundary actually is
Log entry and exit of the session scope around the failing request. If the exception timestamp falls after the exit log, the object escaped — which tells you to change what you return, not how you configure the session.
The fix
Decide the boundary and return data across it, not proxies. Convert entities to plain objects — a dataclass, a Pydantic model built with `model_validate`, a dict — *inside* the session scope, and let only those leave. This is the fix that removes the whole class of bug rather than the current instance of it, because it makes the dependency on the session lexically obvious.
If entities must cross the boundary, load everything they will need eagerly and explicitly. `selectinload` for collections (one extra query, no row multiplication), `joinedload` for many-to-one (single query, but fans out rows on collections). Choosing the strategy per relationship at the query site is better than setting `lazy="joined"` on the model, which pulls the whole graph into every unrelated query.
Understand `expire_on_commit=False` before reaching for it. It stops the post-commit expiry, so already-loaded scalar attributes stay readable after commit and after detachment. It does not load relationships that were never loaded, and it means your objects may hold stale values that no longer match the database. It is a reasonable choice for short request-scoped sessions and a poor one for long-lived ones.
Never pass ORM instances to another thread, to Celery, or into a cache. Pass the primary key and reload inside that worker’s own session — that is also what makes the task retryable, since it then reads current state rather than a snapshot from whenever the task was queued.
For a genuine need to reattach, `session.merge(instance)` copies state into a new session and returns the attached copy. Treat it as a deliberate operation with a cost, not as a repair for a leaked object, and be aware it will emit a SELECT unless you pass `load=False` under conditions you fully control.
# Raises when FastAPI serialises order.items after the scope exits
@app.get("/orders/{order_id}")
def get_order(order_id: int) -> OrderOut:
with Session(engine) as session:
return session.get(Order, order_id)
# Load what the response needs, convert inside the scope, return data
@app.get("/orders/{order_id}")
def get_order(order_id: int) -> OrderOut:
with Session(engine) as session:
stmt = (
select(Order)
.options(selectinload(Order.items), joinedload(Order.customer))
.where(Order.id == order_id)
)
order = session.execute(stmt).unique().scalar_one()
return OrderOut.model_validate(order, from_attributes=True)How to stop it coming back
- Make the rule explicit in review: ORM entities never leave the session scope — response models and dataclasses do
- Pass identifiers, never instances, to Celery tasks and thread pools; this also makes tasks safely retryable
- Assert query counts in tests for hot endpoints; a lazy-loading regression shows up as a count change before it shows up as this exception
- Prefer explicit `selectinload`/`joinedload` at the call site over `lazy=` defaults on models, so each query loads exactly what it needs
- Treat caching an ORM object as a review blocker — cache the serialised form
FAQ
Why does reading the id work but reading a relationship fail?
The identity key stays on the detached instance, so primary-key access needs no database round trip. Anything unloaded or expired does, and that is what raises. It is a useful diagnostic: if only some attributes fail, the object is detached rather than broken.
Is expire_on_commit=False safe to set globally?
For short, request-scoped sessions it is a reasonable default and removes a class of surprise. Understand the trade: instances keep the values they had at commit time, so any code holding them can act on data another transaction has since changed. It also does nothing for relationships that were never loaded.
Why does it appear only in production?
Usually because development runs with a session open for longer — an interactive shell, an autocommit-per-statement pattern, or a middleware that closes the session later than the production configuration does. The lazy load still happens in development; it just succeeds, costing an invisible query.
Does this happen with async SQLAlchemy too?
Yes, and it bites harder: implicit IO in an async context raises `MissingGreenlet` instead, because a lazy load cannot suspend where it is being attempted. The cure is the same and less optional — load relationships explicitly with `selectinload` and convert to plain data inside the session.
Related
Other errors engineers hit next to this one
- CORS preflight: missing Access-Control-Allow-Origin
- 413 Payload Too Large
- 429 Too Many Requests and Retry-After
- nginx 499 client closed request
- upstream prematurely closed connection
- Intermittent 502 after an idle keep-alive connection
- SSL certificate problem: unable to get local issuer certificate
- ERR_INCOMPLETE_CHUNKED_ENCODING