Repository navigation
Description
Discussed in #13674
Originally posted by AJamesB October 13, 2026
Potential bug report.
Describe the bug
Since 2.0.36, an ORM-enabled update() with WHERE criteria expires server_onupdate columns on the matched objects in the session. That was the fix for #11917. With more than one matched object, though, only one of them is expired. The rest keep the value they had before (case 1 below). The same happens to a SET column given a SQL expression that wasn't loaded yet: one object is expired, and the others read None without loading (case 2). Neither case raises, so the values are silently wrong.
The fix for #11917 (bd1c17f) builds to_expire from postfetch_cols before the loop over matched objects in BulkUDCompileState._apply_update_set_values_to_objects. The loop already used to_expire for the "formerly modified" attributes, so it reassigns it after the first object:
to_expire = {...postfetch_cols...}.difference(evaluated_keys) # added in 2.0.36
for obj, state, dict_ in matched_objects:
...
state._expire_attributes(state.dict, to_expire)
...
to_expire = attrib.intersection(dict_).difference(to_evaluate) # pre-existing
if to_expire:
state._expire_attributes(dict_, to_expire)From the second object on, the first _expire_attributes call receives the previous object's "formerly modified" set. Giving the inner variable its own name looks like the whole fix. Which object gets the right treatment depends on identity-map order.
Optional link from https://docs.sqlalchemy.org which documents the behavior that is expected
No response
SQLAlchemy Version in Use
2.0.36 through 2.0.54, and 2.1.4. The code is the same on main.
DBAPI (i.e. the database driver)
pysqlite for the reproduction. First seen with asyncpg and psycopg2.
Database Vendor and Major Version
SQLite for the reproduction. First seen on PostgreSQL 16.
Python Version
3.12
Operating system
macOS (also Linux)
To Reproduce
import sqlalchemy as sa
from sqlalchemy import FetchedValue, func, inspect, update
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
class Base(DeclarativeBase):
pass
class Thing(Base):
__tablename__ = "thing"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
stamped: Mapped[str | None]
touched: Mapped[str] = mapped_column(server_default="x", server_onupdate=FetchedValue())
engine = sa.create_engine("sqlite://")
Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql(
"CREATE TRIGGER t AFTER UPDATE ON thing BEGIN "
"UPDATE thing SET touched = 'y' WHERE id = NEW.id AND touched = 'x'; END"
)
print("SQLAlchemy", sa.__version__)
# 1. A server_onupdate column is expired on only one of the matched objects.
with Session(engine) as session:
things = [Thing(id=n, name=f"thing {n}") for n in (1, 2, 3)]
session.add_all(things)
session.commit()
for thing in things:
thing.touched # load it: 'x'
session.execute(update(Thing).values(name="renamed"))
for thing in things:
print("case 1", thing.id, "touched expired:", "touched" in inspect(thing).expired_attributes)
print("case 1 values:", [thing.touched for thing in things], "(the database has 'y' for all)")
# 2. A SET column given a SQL expression, not yet loaded, is expired on only one object;
# the others then read None without loading.
with Session(engine) as session:
session.execute(sa.delete(Thing))
things = [Thing(id=n, name=f"thing {n}") for n in (11, 12, 13)]
session.add_all(things)
session.flush()
session.execute(update(Thing).values(stamped=func.upper("z")))
for thing in things:
print("case 2", thing.id, "stamped expired:", "stamped" in inspect(thing).expired_attributes)
print("case 2 values:", [thing.stamped for thing in things], "(the database has 'Z' for all)")Error
No exception. Output on 2.1.4:
SQLAlchemy 2.1.4
case 1 1 touched expired: False
case 1 2 touched expired: False
case 1 3 touched expired: True
case 1 values: ['x', 'x', 'y'] (the database has 'y' for all)
case 2 11 stamped expired: True
case 2 12 stamped expired: False
case 2 13 stamped expired: False
case 2 values: ['Z', None, None] (the database has 'Z' for all)
Expected: touched expired on all three objects in case 1, so each reads 'y', and stamped expired on all three in case 2, so each reads 'Z'.
Output on 2.0.35, before #11917, for comparison: no object is refreshed in either case.
SQLAlchemy 2.0.35
case 1 values: ['x', 'x', 'x'] (the database has 'y' for all)
case 2 values: [None, None, None] (the database has 'Z' for all)
Additional context
First hit this with a trigger-maintained updated_at column mapped with server_onupdate=FetchedValue(). After a bulk update() matching several loaded rows, only one row's updated_at was refreshed. In an AsyncSession, the rows that weren't expired keep stale values silently, instead of raising MissingGreenlet as an expired attribute would.
Activity
sqla-tester commentedon Oct 13, 2026
Mike Bayer has proposed a fix for this issue in the main branch:
expire postfetch cols for all objects matched by ORM UPDATE https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/7055
sqla-tester commentedon Oct 13, 2026
Mike Bayer has proposed a fix for this issue in the rel_2_0 branch:
expire postfetch cols for all objects matched by ORM UPDATE https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/7056
expire postfetch cols for all objects matched by ORM UPDATE