Understanding Session.expunge vs Session.clear in Python
Session.expunge(obj) detaches a single instance from the identity map while Session.clear() no longer exists in modern SQLAlchemy — it was removed before the 1.0 release, and its job is now done by Session.expunge_all() (detach every tracked object) or Session.close() (detach everything and release the connection). If you are here from a 0.x-era tutorial, that is the substitution you need. This page sits under the Session Lifecycle and Scope Management guide, which covers all five object states and the identity map semantics that make these methods behave the way they do. Neither expunge() nor expunge_all() triggers flush() or commit(): they operate purely on the in-memory registry.
Direct Syntax Replacement
If your code still calls session.clear(), it will raise AttributeError on any SQLAlchemy 1.4 or 2.0 install. The mechanical fix depends on intent — do you want to keep the session open (expunge_all()) or tear it down (close())?
# Legacy (pre-1.0) — session.clear() no longer exists
session.clear() # AttributeError: 'Session' object has no attribute 'clear'
# Modern 2.0 — detach every persistent object, keep the session usable
session.expunge_all() # identity map emptied; session stays open
# Modern 2.0 — detach everything AND return the connection to the pool
session.close() # expunge_all() + end transaction + release connection
For detaching just one object, expunge() is the surgical tool and has been stable across every release:
# Before: object is persistent (tracked by the session)
from sqlalchemy import inspect
# expunge() — surgical removal of one object
session.expunge(user)
assert inspect(user).detached is True # user is now detached
assert inspect(order).persistent is True # other objects unchanged
# expunge_all() — full identity-map reset (the real "clear")
session.expunge_all()
assert inspect(order).detached is True # every persistent object is now detached
assert len(session.identity_map) == 0
All three methods run synchronously against the identity map. In AsyncSession, expunge() and expunge_all() need no await — they delegate to the underlying synchronous Session registry without touching the database or the event loop. Only close() on an AsyncSession is a coroutine, because releasing the connection may issue a ROLLBACK.
Execution Context & Async Workflow Integration
In high-concurrency async endpoints, unbounded identity-map growth causes memory pressure and can starve the connection pool when sessions outlive a single request. The identity map holds one Python object per database row loaded, and without explicit removal those objects stay resident for the session's lifetime — which is exactly why the request-scoped session boundaries described in the parent guide matter.
AsyncSession wraps a regular Session. Calls to expunge() and expunge_all() dispatch to the synchronous layer immediately, so they are safe to call inside coroutines without await:
from __future__ import annotations
from typing import Any
from sqlalchemy import Integer, String, select
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
relationship,
selectinload,
)
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
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))
orders: Mapped[list["Order"]] = relationship("Order", lazy="select")
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer)
total: Mapped[float] = mapped_column()
engine = create_async_engine(
"postgresql+asyncpg://user:pass@localhost/db", pool_size=10
)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def process_and_cache_user(user_id: int) -> dict[str, Any]:
"""Load a user, pre-fetch relationships, expunge for external caching."""
async with AsyncSessionLocal() as session:
stmt = (
select(User)
.where(User.id == user_id)
.options(selectinload(User.orders))
)
result = await session.execute(stmt)
user = result.scalar_one()
# Pre-load complete — safe to detach (no await needed)
session.expunge(user)
# user.orders is accessible because selectinload fetched it eagerly
return {
"id": user.id,
"name": user.name,
"order_count": len(user.orders),
}
async def stateless_worker_tick(session: AsyncSession) -> None:
"""Background worker that processes a batch then reclaims memory."""
result = await session.execute(select(Order).limit(500))
orders = result.scalars().all()
for order in orders:
# process each order ...
pass
# Discard all tracked state before the next batch iteration.
# Prevents the identity map from growing without bound across ticks.
session.expunge_all()
The key distinction for async workflows: expunge() is appropriate when you need to pass a fully hydrated object to a serializer, background task queue, or external cache that lives outside the session scope. expunge_all() is appropriate at batch boundaries in long-running workers where you want a clean slate but are reusing the same session object to avoid re-acquiring a connection. The expire_on_commit=False setting above prevents attributes from being invalidated on commit — the same pattern documented in using expire_on_commit=False in FastAPI dependencies, which avoids the post-commit lazy-load traps that otherwise force manual expunging.
Resolving Warnings, Errors & Common Mistakes
| Error / Warning | Root Cause | Production Fix |
|---|---|---|
AttributeError: 'Session' object has no attribute 'clear' | Calling session.clear(), a method removed before SQLAlchemy 1.0. | Replace with session.expunge_all() (keep the session) or session.close() (release the connection). |
sqlalchemy.orm.exc.DetachedInstanceError: Instance <User> is not bound to a Session; attribute refresh operation cannot proceed | Accessing an unloaded attribute or lazy relationship after expunge() / expunge_all(). The instance lost its session reference. | Pre-load required attributes with selectinload() / joinedload() before detaching, or re-attach via session.add(obj). See the sibling guide below. |
sqlalchemy.exc.InvalidRequestError: This session's transaction has been rolled back due to a previous exception during flush | Re-using a session after a failed flush without rolling back first. | Call await session.rollback() before any subsequent operation. |
sqlalchemy.exc.InvalidRequestError: Object '<Order>' is already attached to session '...' (this is '...') | Calling session.add(obj) on an expunged object in a different session than the one that originally tracked it. | Use session.merge(obj) to integrate an object from a foreign or closed session. |
Deletions silently abandoned after expunge_all() | expunge_all() was called while the session held deleted objects whose DELETE had not yet been flushed. | Call session.flush() (or commit()) before expunge_all() if any deletions must reach the database. |
Interaction with Pending and Deleted Objects
The behavior of expunge shifts depending on whether the object is pending (never flushed) or deleted (marked for deletion):
from __future__ import annotations
from sqlalchemy import inspect
from sqlalchemy.orm import Session
def demonstrate_state_edge_cases(session: Session) -> None:
# Pending object — not yet in the database
new_user = User(name="Bob", id=99)
session.add(new_user)
assert inspect(new_user).pending
# expunge a pending object — it becomes transient, not detached
session.expunge(new_user)
assert inspect(new_user).transient # No DB identity, so transient not detached
# Deleted object
existing = session.get(User, 1)
session.delete(existing)
assert inspect(existing).deleted
# expunge_all() while a delete is pending re-raises it to detached
session.expunge_all()
assert inspect(existing).detached # DELETE never issued; row still exists in DB
This edge case bites batch jobs: if you expunge_all() while the session has deleted objects that were never flushed, those deletions are silently dropped and the rows remain in the database. Always flush() before expunge_all() when deletions must be committed. The transaction itself is unaffected — how commit() and rollback() interact with these state transitions is covered in Transaction Isolation and Commit Strategies.
Advanced Identity-Map Optimization
Selective Expunge in Cache-Population Pipelines
In read-heavy APIs that maintain an application-level cache (Redis, Memcached), the pattern of load → expunge → serialize → store trims ORM memory overhead while keeping serialization safe outside the session boundary:
from __future__ import annotations
import json
from decimal import Decimal
from sqlalchemy import Integer, Numeric, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
sku: Mapped[str] = mapped_column(String(50))
name: Mapped[str] = mapped_column(String(200))
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
async def warm_product_cache(
session: AsyncSession,
redis_client,
product_ids: list[int],
) -> None:
"""Bulk-load products, expunge each after caching to reclaim ORM memory."""
stmt = select(Product).where(Product.id.in_(product_ids))
result = await session.execute(stmt)
products = result.scalars().all()
for product in products:
payload = json.dumps({
"id": product.id,
"sku": product.sku,
"name": product.name,
"price": str(product.price),
})
await redis_client.set(f"product:{product.id}", payload, ex=300)
session.expunge(product)
# product is now detached; the ORM state is eligible for GC
Expunging per item avoids holding a large identity map for the whole cache-warming job. Note the caveat: the products list still references each object, so garbage collection only reclaims memory once those references drop — for very large batches, stream with yield_per() and process in chunks rather than materializing every scalar into one list.
expunge_all() vs close(): which reset do you actually want?
Both empty the identity map, but they differ in what happens to the connection and the transaction:
# expunge_all() — detach all objects; the session keeps its connection and
# any open transaction. Reuse the same session for the next unit of work.
session.expunge_all()
# close() — expunge_all() PLUS end the transaction and return the connection
# to the pool. The session is reset to a clean, connection-less state but can
# be used again (it lazily re-acquires a connection on the next query).
await session.close() # coroutine on AsyncSession
Reach for expunge_all() inside a long-lived worker loop where re-acquiring a connection every tick would add latency, and you want to keep the current transaction open across iterations. Reach for close() at a true request or job boundary where the connection should go back to the pool — this is the interaction that keeps async engine and connection pooling healthy under load, because a connection pinned to an idle session is a connection no other request can use.
Frequently Asked Questions
Does Session.clear() still work in SQLAlchemy 2.0?
No. Session.clear() was removed before the 1.0 release and raises AttributeError on every current version. Use session.expunge_all() to empty the identity map while keeping the session open, or session.close() to also release the connection and end the transaction.
Can I call Session.expunge() on an AsyncSession without await?
Yes. AsyncSession delegates expunge() and expunge_all() to the underlying synchronous Session registry. They operate on the in-memory identity map without blocking the event loop and do not require await. Only AsyncSession.close() is a coroutine, because it may issue a ROLLBACK when releasing the connection.
What is the difference between expunge_all() and close()?
expunge_all() detaches every object but leaves the session bound to its connection and any open transaction. close() does everything expunge_all() does and additionally ends the transaction and returns the connection to the pool. Use expunge_all() to reset object tracking between batches on the same connection; use close() at request or job boundaries.
How do I fix DetachedInstanceError after expunging?
The error means code accessed an attribute that was not loaded before detachment. Pre-load required attributes and relationships with selectinload() or joinedload() in the hydrating query, or re-attach the object to a live session with session.add(obj) before touching unloaded attributes. The DetachedInstanceError-after-commit guide walks the most common variant.
Related
- Session Lifecycle and Scope Management — Parent guide covering all five object states, identity-map semantics, and request-scoped session patterns.
- Fixing DetachedInstanceError After Commit in SQLAlchemy — Sibling guide on the most common detachment scenario: attribute access after
commit()withexpire_on_commit=True. - Transaction Isolation and Commit Strategies — How
commit(),rollback(), and savepoints interact with session state. - Using expire_on_commit=False in FastAPI Dependencies — Framework pattern that avoids post-commit lazy-load issues without manual expunge.