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.

Object state transitions driven by expunge(), expunge_all() and close() A single ORM object flows transient to pending to persistent to detached; expunge(obj) and expunge_all() detach persistent objects, expunge() on a pending object reverts it to transient, and add()/merge() re-attaches. The lower strip contrasts expunge_all() (keeps the connection) with close() (returns the connection to the pool). The four session states — and what expunge / close move between them expunge() detaches one object · expunge_all() detaches every object · neither triggers flush or commit Transient no identity, not tracked Pending added, awaiting flush Persistent tracked in identity map Detached had identity, untracked add() flush() commit() expunge(obj) expunge_all() expunge() a pending object → transient add() / merge() re-attaches expunge_all() identity map emptied connection & transaction kept — session stays open close() expunge_all() + end transaction connection returned to the pool

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.

Both detach objects; neither touches the transaction A precise comparison. session.expunge(obj) removes a single instance from the identity map and from any pending change set, leaving every other instance attached and the transaction exactly as it was. session.expunge_all() does the same for every instance at once, which is the streaming and batch-processing idiom for releasing memory mid-transaction. The critical point they share is what neither does: the transaction stays open, the connection stays checked out, and any pending flush is still pending. Ending the transaction requires commit, rollback or close. session.expunge(obj) detaches exactly one instance other instances stay attached pending changes to obj are discarded transaction and connection untouched session.expunge_all() empties the identity map the memory-release idiom for streaming every instance becomes detached at once transaction and connection untouched Neither ends the unit of work. A loop that expunges each batch and never commits still holds one transaction — and every row lock it has taken — until the loop finishes.

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 / WarningRoot CauseProduction 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 proceedAccessing 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 flushRe-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:

Three identity-map behaviours that surprise people Three facts. The identity map holds weak references to clean instances and strong references to dirty ones, so an object you modified will not be collected while the session lives no matter what memory pressure exists — which is why a long batch loop that modifies rows grows even though nothing obviously retains them. Expunging a parent leaves its already-loaded children attached, because collections hold their own references, so releasing memory from an object graph means expunging the graph rather than its root. And once an instance has been expunged, re-querying the same primary key builds a brand-new instance: the two are equal by identity in the database and distinct in Python, so an identity comparison between them fails. clean instances are weakly referenced; dirty ones are not a modified object survives until flush or expunge, whatever the memory pressure this is why a long modifying loop grows steadily expunging a parent leaves its loaded children attached collections hold their own references to the child instances release the graph, not just its root re-querying an expunged row builds a new instance same database identity, different Python object an `is` comparison between the two is False The first explains most "the session leaks memory" reports: nothing is leaking. The session is holding modified state it is contractually required to keep until the unit of work ends.
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.