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) # changedRemember
- 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.