Detaching SQLAlchemy ORM Objects Safely with session.expunge()
Use session.expunge() to detach SQLAlchemy ORM objects before returning them from a closed session, preventing DetachedInstanceError while releasing database connections.
10 Jan 2026, 02:46 UTC

The problem: ORM objects fail after the session closes
SQLAlchemy's ORM (Object Relational Mapper) uses a Session to implement the unit-of-work pattern. The session tracks every loaded object and persists changes when session.commit() is called. If you return an ORM instance to a template, serializer, or another service after the Session block ends, accessing a relationship that was not already loaded raises DetachedInstanceError because the parent session is gone and lazy loading can no longer run.
Useful takeaway
Load every relationship the caller needs while the session is active, then call session.expunge(instance) before returning the object. Expunging removes the instance from the session's tracker so it can be used safely outside the unit of work, and the context manager closes the session and releases the database connection.
Worked example: load, expunge, return
This example assumes SQLAlchemy 2.0 style (Session, session.get).
from sqlalchemy import Column, Integer, String, ForeignKey, create_engine
from sqlalchemy.orm import declarative_base, Session, relationship
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
addresses = relationship("Address", back_populates="user")
class Address(Base):
__tablename__ = "addresses"
id = Column(Integer, primary_key=True)
email = Column(String)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User", back_populates="addresses")
engine = create_engine("sqlite:///example.db")
Base.metadata.create_all(engine)
def get_user_with_addresses(user_id: int):
# Where to run: application service or repository layer
# Permissions: SELECT on users and addresses for the database user
with Session(engine) as session:
user = session.get(User, user_id)
if user is None:
return None
# Load the relationship while the session is active
_ = user.addresses
# Remove tracking so the object survives session closure
session.expunge(user)
return user
Expected check: After the function returns, code such as user.addresses[0].email should execute without DetachedInstanceError because the collection was populated before expunging.
Risk: Modifying the returned object and later calling session.commit() on a new session will not persist the change, because the object is no longer tracked by any session. Unloaded relationships accessed after expunging will still fail.
Comparison: attached, closed, and expunged
| State | Can access loaded attributes after session closes? | Tracked by a session? |
|---|---|---|
| Attached inside active session | Yes, including lazy loads | Yes |
| Attached after session closes | No; raises DetachedInstanceError on lazy access | No longer valid |
| Expunged before session closes | Yes, for attributes already loaded | No |
Limits and common mistakes
- Unloaded relationships still break:
session.expunge()does not populate data. Loaduser.addressesor useselectinload()before expunging. - Changes are lost: An expunged object is detached from the unit of work. Modifications made after expunging are not persisted by a later
session.commit()unless the object is merged or re-attached. - Do not expunge pending writes: If the object has uncommitted changes you intend to save, expunging before
session.commit()discards tracking for those changes. - Related objects are not automatically expunged: Accessing a related object after expunging the parent may return an instance still associated with the closed session, depending on how it was loaded.
Practical verification
- Call
get_user_with_addresses(1)from outside the session block. - Access
user.addressesand print an email address. - Confirm no
DetachedInstanceErroris raised and the database connection is returned to the pool. - Change
user.name, open a new session, and callsession.commit(); confirm the database row is unchanged.