Unit of work and the identity map

SQLAlchemy · Expert · 15 min

Knowing what the Session does at flush time explains surprising queries, ordering errors, stale reads and memory growth in long sessions.

The Session implements the unit of work pattern. Mapped classes are instrumented: attribute access goes through descriptors that record changes in each instance's InstanceState (visible via inspect(obj)), including the original value of modified attributes. At flush, the session gathers new, dirty and deleted objects and computes the SQL needed.

The flush sorts operations topologically by mapper and relationship dependencies: parents are inserted before children so foreign keys can be filled in, and children deleted before parents. Within a table, rows are batched into executemany calls where possible. Cyclic dependencies (two rows pointing at each other) need post_update=True on one relationship, which issues a second UPDATE after the inserts.

The identity map keys persistent objects by (class, primary key), holding them weakly. Loading the same row twice in one session returns the same Python object, and session.get() returns it with no SQL at all. The flip side: a query that returns rows for objects already in the map does not overwrite their unflushed changes and, by default, does not refresh their loaded attributes. Use populate_existing(), session.refresh(obj) or expire() when you need fresh database values.

Long-running sessions accumulate state; the session holds strong references to dirty and new objects until flush. For batch jobs, commit and expunge_all() or use fresh sessions per chunk.

Example

from sqlalchemy import create_engine, select, inspect, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase): pass
class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

engine = create_engine("sqlite://"); Base.metadata.create_all(engine)
with Session(engine) as s:
    s.add(User(name="ada")); s.commit()
    a = s.get(User, 1)
    b = s.scalars(select(User).where(User.name == "ada")).one()
    print(a is b)                                   # True: identity map
    a.name = "Ada"
    print(inspect(a).attrs.name.history)           # History(added=['Ada'], unchanged=(), deleted=['ada'])
    s.commit(); print(a.name)                       # Ada (reloaded after commit)
    s.execute(text("UPDATE users SET name = 'changed'"))  # behind the ORM's back
    print(s.get(User, 1).name)                      # Ada: identity map, no refresh
    print(s.scalars(select(User).execution_options(populate_existing=True)).one().name)  # changed

Remember

  • Instrumented attributes record changes; flush turns them into SQL
  • Flush order follows dependencies: parents inserted first, deleted last
  • Identity map: one object per (class, pk) per session
  • Queries don't refresh already-loaded objects unless asked (populate_existing, refresh)

Watch out for

  • Expecting a re-query to show changes made by raw SQL or another process to objects already in the session.

Check yourself

  1. You load User 1, then another process changes its name, then you run select(User) in the same session. What does user.name show?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.sqlalchemy.org/en/20/orm/session_state_management.html