SQLAlchemy expire vs refresh: detection and resolution

Stale object state in SQLAlchemy sessions often surfaces in production services that cache query results, where an expired instance returns outdated column values. This is caused by the session’s expire mechanism and can silently corrupt downstream calculations.

# Example showing the issue
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, sessionmaker

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    name = Column(String)

engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)

# initial population
s1 = Session()
s1.add(User(id=1, name='Alice'))
s1.commit()

# load in first session
u = s1.query(User).first()
print(f'Initial name: {u.name}')  # Alice

# modify directly in DB using a second session
s2 = Session()
s2.query(User).filter_by(id=1).update({'name': 'Bob'})
s2.commit()
s2.close()

# expire only marks as stale; value is reloaded on next access
s1.expire(u)
print(f'After expire access: {u.name}')  # Bob

# refresh forces immediate reload (overwrites local changes)
u.name = 'Charlie'  # local dirty change
s1.refresh(u)
print(f'After refresh: {u.name}')  # Bob

expire only flags an instance’s attributes as stale; the next attribute access triggers a lazy SELECT that pulls the current value from the database. refresh, on the other hand, forces an immediate SELECT and overwrites any local changes. This behavior follows SQLAlchemy’s unit-of-work and identity map design, ensuring session consistency. Related factors:

  • Implicit lazy loads after expire
  • Local dirty state can be lost on refresh
  • Session-wide expiration policies affect all loaded objects

To diagnose this in your code:

# Detect if an instance has expired attributes
if u.__dict__ == {} or u.__class__.__name__ == 'User' and u.__sa_instance_state.expired:
    print('Instance is expired; next access will hit the DB')
else:
    print('Instance is fresh')

# Show which columns are expired
print('Expired attributes:', u.__sa_instance_state.expired_attributes)

Fixing the Issue

Quick Fix (1-Liner Solution)

session.refresh(obj)  # forces immediate reload from the DB

When to use: Debugging or when you need the latest value instantly. Tradeoff: Adds an extra round‑trip query.

Best Practice Solution (Production‑Ready)

import logging
from sqlalchemy.orm import Session

def ensure_fresh(session: Session, instance):
    """Refresh only if the instance is expired or stale."""
    state = instance.__sa_instance_state
    if state.expired or state.modified:
        logging.info(f'Refreshing {instance}')
        session.refresh(instance)
    else:
        logging.debug(f'Instance {instance} is already fresh')
    return instance

# usage
u = s1.query(User).first()
# after possible external DB changes
ensure_fresh(s1, u)
print(f'Current name: {u.name}')

When to use: Production code where you want explicit control over when a SELECT occurs, logging of stale reads, and avoidance of accidental overwrites of local changes. Why better: Centralizes refresh logic, logs unexpected staleness, and respects the session’s dirty state.

What Doesn’t Work

❌ Calling session.commit() after expire expecting it to refresh data: commit only persists local changes, it does not reload.

❌ Using session.expunge_all() to ‘fix’ stale objects: this detaches everything and forces new queries, but loses identity map benefits.

❌ Setting expire_on_commit=True and then never accessing attributes: the session will raise errors on lazy load if the connection is closed.

  • Calling expire() and assuming attributes are instantly updated.
  • Using refresh() on a detached instance, causing DetachedInstanceError.
  • Relying on session-wide expire_on_commit=False and never checking for staleness.

When NOT to optimize

  • Read‑only scripts: If the session never modifies data, occasional staleness is harmless.
  • One‑off migrations: You can afford a full table scan without per‑object refresh.
  • Small in‑memory caches: When the dataset fits in RAM and no external updates occur.
  • Testing fixtures: Tests that control the entire transaction lifecycle usually don’t need explicit refreshes.

Frequently Asked Questions

Q: Does expire delete data from the session?

No, it only marks attributes as stale; they are reloaded on next access.

Q: Can refresh overwrite unsaved changes?

Yes, refresh discards pending attribute changes and loads the current DB state.


Understanding the distinction between expire and refresh lets you keep SQLAlchemy sessions deterministic, especially in services that share a database with external writers. Use explicit refresh only when you need the freshest state; otherwise rely on SQLAlchemy’s lazy expiration to avoid unnecessary round‑trips. Proper logging of stale reads makes debugging production data anomalies much easier.

Why SQLAlchemy session identity map causes stale objectsWhy SQLAlchemy backref creates duplicate rowsWhy SQLAlchemy engine echo hurts performanceWhy SQLAlchemy session rollback raises exception