Databases

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.

1 answer 1 view

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:

python
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 the onupdate.
  • 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)

  • Column-level onupdate accepts either a Python callable or a SQL expression. The docs explain that Column.default and Column.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_onupdate which is intended to describe server-side behavior in DDL, but it doesn’t always emit DB-specific ON UPDATE DDL (for example, it currently doesn’t produce MySQL’s ON 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 — so onupdate won’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() or await 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_modified is present in the UPDATE statement. 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 trigger onupdate (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:

python
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 onupdate callables 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 the values() you send with the statement. The ORM may consult Column.onupdate when constructing return defaults in some contexts (see UpdateBase.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 onupdate automatically:

  • 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 or on_conflict_do_update you must also include the timestamp expression explicitly — not all upsert code paths consult Column.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.

  1. Per-instance updates (using setattr + flush) — rely on onupdate or use a before_update listener
  • If you want the ORM to always compute the timestamp client-side:
python
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 onupdate wasn’t taken):
python
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.

  1. Bulk updates — include the timestamp expression explicitly
  • Core-style bulk update (recommended in SQLAlchemy 2.x):
python
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:
python
# 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_mappings or bulk_save_objects, you must put the timestamp value into the mapping list (they bypass mapper defaults/events):
python
await session.bulk_update_mappings(
 MyObject,
 [{"id": 1, "name": "newName", "timestamp_last_modified": datetime.utcnow()}]
)
  1. Upserts / ON CONFLICT / on_conflict_do_update
  • Supply the timestamp explicitly in the do_update set_ clause:
python
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)
  1. 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_TIMESTAMP or Postgres triggers). Note SQLAlchemy’s server_onupdate documentation/behavior and DDL generation caveats: see https://github.com/sqlalchemy/sqlalchemy/issues/5427.
  1. Return the updated timestamp (optional)

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 onupdate can be bypassed by bulk paths.
  • Enable SQL logging to verify the UPDATE includes NOW()/timestamp: logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) or create_engine(..., echo=True).
  • If setattr didn’t update the timestamp:
  • confirm inspect(obj).attrs.<attr>.history shows 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


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.

Authors
Verified by moderation
Moderation
Trigger SQLAlchemy onupdate for Bulk Updates & setattr