Trigger SQLAlchemy onupdate for Bulk Updates & setattr
Learn why SQLAlchemy mapped_column onupdate=func.now() doesn't trigger with setattr or bulk updates. Fix automatic timestamp updates in ORM, bulk_update_mappings, and Query.update with code examples and best practices.
How to trigger SQLAlchemy mapped_column onupdate=func.now() for automatic timestamp updates when using setattr() or bulk updates?
I have a SQLAlchemy ORM model inheriting from AsyncAttrs and DeclarativeBase with a timestamp attribute:
timestamp_last_modified: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now())
I want this timestamp to update automatically when any other attribute changes. However:
- Using
setattr(obj, key, value)(e.g.,setattr(person1, 'name', 'newName')) does not trigger theonupdate. - SQLAlchemy’s ORM bulk update by primary key also fails to update the timestamp.
The object appears in session.dirty, and other attributes update correctly after flush, but the timestamp remains unchanged.
Example output:
now: 2026-01-09 13:29:20.675518
session.dirty IdentitySet([<database.models.MyObject object at 0x78e0ff231b50>])
flush erfolgreich
Test failure:
AssertionError: '2026-01-09 13:29:18' == '2026-01-09 13:29:18'
What type of update operation triggers the onupdate=func.now() function automatically?
A normal ORM-managed instance UPDATE — that is, changing a mapped object (via setattr) and letting the Session flush/commit the unit-of-work — is what automatically triggers SQLAlchemy’s Column.onupdate (so your mapped_column(..., onupdate=func.now()) will be applied). Bulk/batched DML (e.g., Query.update(), session.execute(update(...)), bulk_update_mappings, or many upsert paths) bypass the ORM unit-of-work and do not automatically call onupdate; for those you must set the timestamp explicitly (e.g. .values(timestamp_last_modified=func.now())), use a before_update event, or enforce it in the database.
Contents
- How Column.onupdate works (sqlalchemy onupdate)
- Why setattr might not update the timestamp
- Bulk updates and Query.update behavior (sqlalchemy bulk update)
- Practical fixes and code examples
- Best practices & debugging checklist
- Sources
- Conclusion
How Column.onupdate works (sqlalchemy onupdate)
-
Column-level
onupdateaccepts either a Python callable or a SQL expression. The docs explain thatColumn.defaultandColumn.onupdate“also accept Python functions. These functions are invoked at the time of insert or update if no other value for that column is supplied, and the value returned is used for the column’s value” (see the SQLAlchemy defaults page). See https://docs.sqlalchemy.org/en/20/core/defaults.html. -
If you supply a SQL expression like
func.now(), SQLAlchemy will render that expression into the SQL UPDATE so the database evaluates the timestamp at update time (e.g.,SET timestamp_last_modified = NOW()). If you supply a Python callable (e.g.datetime.utcnow), SQLAlchemy will call it client-side and use the returned value in the UPDATE. -
A nuance: Python callables used for defaults/onupdate are invoked once prior to statement execution — they are not re‑invoked per row when a single multi-row SQL statement runs. See the notes on DML/RETURNING behavior here: https://docs.sqlalchemy.org/en/20/core/dml.html.
-
There’s also
server_onupdatewhich is intended to describe server-side behavior in DDL, but it doesn’t always emit DB-specificON UPDATEDDL (for example, it currently doesn’t produce MySQL’sON UPDATE CURRENT_TIMESTAMP()clause in SQLAlchemy’s DDL generation). See https://docs.sqlalchemy.org/en/20/core/defaults.html and related issues like https://github.com/sqlalchemy/sqlalchemy/issues/5427.
Why setattr might not update your timestamp
You’d expect setattr(obj, 'name', 'newName') to mark the instance dirty and for onupdate to run at flush — and in the ordinary case it does. If you don’t see the timestamp change, common causes are:
-
No real attribute change: if the new value equals the old value SQLAlchemy won’t emit an UPDATE for that attribute (inspect with
sqlalchemy.inspect(obj).attrs.name.history). -
You actually executed a bulk/batched update instead of a per-instance flush:
Query.update(),session.execute(update(...)),bulk_update_mappings, and similar functions run a direct SQL UPDATE and bypass per-instance attribute instrumentation and Python callables — soonupdatewon’t be invoked for those. See ORM DML docs: https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html. -
You never flushed/committed: make sure
await session.flush()orawait session.commit()ran; otherwise changes may not reach the database. -
The generated SQL didn’t include the timestamp column: enable SQL logging (see below) to confirm whether
timestamp_last_modifiedis present in theUPDATEstatement. If it’s not present, SQLAlchemy didn’t include the onupdate expression in that UPDATE. -
Subtle mapper/bulk features:
bulk_*APIs are explicitly optimized and skip events/defaults; they won’t triggeronupdate(you must include the timestamp in the mapping data). See the ORM bulk behavior docs and discussions such as https://github.com/sqlalchemy/sqlalchemy/discussions/5903.
Debugging tip: turn on SQL logging to inspect what was actually executed:
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
# or create engine with echo=True
If the UPDATE statement does not include timestamp_last_modified or NOW() then the onupdate did not participate in that operation.
Bulk updates and Query.update behavior (sqlalchemy bulk update)
-
Bulk/batched updates issue a single SQL UPDATE. That single statement does not run mapper-level per-instance logic (no attribute events, no Python callables invoked per-row), so mapper
onupdatecallables are not run. -
For SQL expression
onupdate=func.now(): the mapper will not automatically inject that expression into a bulk UPDATE unless you explicitly include it in thevalues()you send with the statement. The ORM may consultColumn.onupdatewhen constructing return defaults in some contexts (seeUpdateBase.return_defaults()), but you should not rely on implicit inclusion in bulk updates — be explicit. See https://docs.sqlalchemy.org/en/20/core/dml.html and https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html. -
Example of what does NOT call
onupdateautomatically: -
session.query(Model).filter(...).update({...}) -
await session.execute(update(Model).where(...).values(...))if you don’t include the timestamp field explicitly -
session.bulk_update_mappings(Model, mappings) -
To make a bulk update update the timestamp, include the timestamp change in the
values(...)yourself (example in the next section). For upserts oron_conflict_do_updateyou must also include the timestamp expression explicitly — not all upsert code paths consultColumn.onupdate. See https://github.com/sqlalchemy/sqlalchemy/discussions/5903.
Practical fixes and code examples
Below are actionable options depending on whether you operate per-instance (preferred for onupdate) or bulk.
- Per-instance updates (using setattr + flush) — rely on
onupdateor use abefore_updatelistener
- If you want the ORM to always compute the timestamp client-side:
from datetime import datetime
from sqlalchemy.orm import mapped_column, DeclarativeBase, Mapped
class MyObject(DeclarativeBase):
id: Mapped[int] = mapped_column(primary_key=True)
timestamp_last_modified: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
- Or use an event to guarantee a timestamp before update (works even if
onupdatewasn’t taken):
from datetime import datetime
from sqlalchemy import event
@event.listens_for(MyObject, "before_update", propagate=True)
def _set_updated_timestamp(mapper, connection, target):
# this writes a Python datetime into the instance so the UPDATE includes it
target.timestamp_last_modified = datetime.utcnow()
Note: using Python datetime.utcnow() sets the value client-side; using SQL func.now() renders DB expression instead.
- Bulk updates — include the timestamp expression explicitly
- Core-style bulk update (recommended in SQLAlchemy 2.x):
from sqlalchemy import update, func
stmt = (
update(MyObject)
.where(MyObject.some_flag == True)
.values({
"name": "newName",
MyObject.timestamp_last_modified: func.now()
})
)
await session.execute(stmt)
await session.commit()
- ORM-style
.update()(older style) — include timestamp in values:
# depending on your SQLAlchemy API, but the same idea:
await session.execute(
update(MyObject)
.where(MyObject.id == some_id)
.values({"name": "newName", MyObject.timestamp_last_modified: func.now()})
)
- If you use
bulk_update_mappingsorbulk_save_objects, you must put the timestamp value into the mapping list (they bypass mapper defaults/events):
await session.bulk_update_mappings(
MyObject,
[{"id": 1, "name": "newName", "timestamp_last_modified": datetime.utcnow()}]
)
- Upserts / ON CONFLICT / on_conflict_do_update
- Supply the timestamp explicitly in the
do_updateset_clause:
stmt = insert(MyObject).values(...).on_conflict_do_update(
index_elements=[MyObject.id],
set_={
"name": excluded.c.name,
MyObject.timestamp_last_modified: func.now()
}
)
await session.execute(stmt)
- Database-side enforcement (triggers or DB default)
- If you want the DB to always maintain the column (regardless of application code), create a DB trigger or use database column options (e.g., MySQL
DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPor Postgres triggers). Note SQLAlchemy’sserver_onupdatedocumentation/behavior and DDL generation caveats: see https://github.com/sqlalchemy/sqlalchemy/issues/5427.
- Return the updated timestamp (optional)
- If you need the new timestamp back into your in-memory objects after a bulk UPDATE, use
RETURNING(if the DB supports it) or setsynchronize_session='fetch'/ useeager_defaultswhere appropriate. See https://docs.sqlalchemy.org/en/20/core/dml.html and https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html.
Best practices & debugging checklist
- Prefer per-instance updates for critical auditing fields (unit-of-work will manage
onupdate). If you must bulk-update, be explicit about timestamps. - If you want DB-authoritative timestamps, use DB triggers or server-side defaults; otherwise accept that mapping-level
onupdatecan be bypassed by bulk paths. - Enable SQL logging to verify the UPDATE includes
NOW()/timestamp:logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)orcreate_engine(..., echo=True). - If
setattrdidn’t update the timestamp: - confirm
inspect(obj).attrs.<attr>.historyshows a change, - confirm
await session.flush()ran, - inspect the emitted UPDATE SQL,
- make sure you’re not accidentally using one of the bulk APIs.
- If you need per-row server-side evaluation for a multi-row UPDATE (different timestamp per row), use a DB trigger or ensure your SQL expression is evaluated per row (server-side), e.g.,
SET timestamp_last_modified = NOW()in the UPDATE you execute.
Sources
- Column INSERT/UPDATE Defaults — SQLAlchemy 2.0 Documentation
- Insert, Updates, Deletes — SQLAlchemy 2.0 Documentation
- Table Configuration with Declarative — SQLAlchemy 2.0 Documentation
- Mapping Table Columns — SQLAlchemy 1.4 Documentation
- SQLAlchemy ignores server_onupdate clause on update · Issue #5427 · sqlalchemy/sqlalchemy
- sqlalchemy onupdate inconsistent behavior for Query.update() - Stack Overflow
- Upserts not honoring Column onupdate values · sqlalchemy/sqlalchemy · Discussion #5903
- Can I “touch” a SQLAlchemy record to trigger “onupdate”? - Stack Overflow
- Client-side “default” or “onupdate” using a SQL expression should participate in eager defaults · Issue #7438 · sqlalchemy/sqlalchemy
- onupdate not overridinig current datetime value - Stack Overflow
- ORM-Enabled INSERT, UPDATE, and DELETE statements — SQLAlchemy 2.0 Documentation
Conclusion
In short: an ORM-managed, per-instance UPDATE (change attributes via setattr and let the Session flush/commit) is the operation that will automatically trigger mapped_column(..., onupdate=func.now()). Bulk/batched DML operations bypass the ORM unit-of-work and won’t fire onupdate automatically — include the timestamp expression explicitly in those updates, use a before_update listener for consistent per-instance behavior, or enforce timestamps at the database level if you need absolute consistency across every update path.