Using ORM-enabled UPDATE and DELETE statements

Execute update(Account).where(...).values(...) or delete(Session).where(...) through session.execute() — one statement instead of loading every object — and let the default synchronize_session="auto" keep any objects the session already holds up to date. This guide belongs to Core vs ORM architecture decisions.

Quick Answer

Loading rows only to change a column and flush them back is the most common source of avoidable queries in ORM code.

Load and flush, or one statement Left: loading every trial account whose trial ended, setting status on each object and flushing issues one SELECT returning every row and one UPDATE per row, and holds every object in the identity map. Right: an ORM-enabled update of Account with a WHERE clause issues a single UPDATE; the session synchronises any matching objects it already holds. load, mutate, flush SELECT every expired trial N objects in the identity map N UPDATE statements at flush events and validators run per row session.execute(update(Account)) one UPDATE ... WHERE ... no objects loaded rows changed in the database in-session objects synchronised For 40,000 expired trials that is 40,001 statements against one.

Before — load every row, mutate, flush one UPDATE per object:

import datetime as dt

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import Account


async def expire_trials(session: AsyncSession, today: dt.date) -> int:
    accounts = (
        await session.scalars(
            select(Account).where(Account.plan == "trial", Account.trial_ends_on < today)
        )
    ).all()
    for account in accounts:
        account.plan = "expired"
    await session.commit()
    return len(accounts)

After — one ORM-enabled UPDATE:

import datetime as dt

from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import Account


async def expire_trials(session: AsyncSession, today: dt.date) -> int:
    result = await session.execute(
        update(Account)
        .where(Account.plan == "trial", Account.trial_ends_on < today)
        .values(plan="expired")
    )
    await session.commit()
    return result.rowcount

The legacy 1.x spelling was session.query(Account).filter(...).update({"plan": "expired"}, synchronize_session=False). The 2.0 form is a plain update() construct, the same one Core uses, executed by the session so it knows to treat the entity as mapped.

Execution Context & Async Workflow Integration

update(Account) names a mapped class rather than a table, and that is what makes it ORM-enabled. When the session executes it, SQLAlchemy resolves the class to its table, translates attribute expressions to columns, applies ORM-level options such as with_loader_criteria, and — the important part — synchronises the identity map afterwards.

synchronize_session: four choices Four tiles. auto, the 2.0 default, tries evaluate and falls back to fetch when the criteria cannot be evaluated in Python. evaluate applies the WHERE clause to objects in the identity map in Python, cheap but limited to simple expressions. fetch uses RETURNING to learn which primary keys matched and updates exactly those objects. False does nothing to in-session objects, which may then hold stale values. 'auto' (default) evaluate, else fetch right for most code 'evaluate' apply WHERE in Python simple criteria only 'fetch' RETURNING the matched keys exact; one round trip False leave session objects alone they may now be stale False is safe only when the session holds none of the affected objects, such as a fresh session.

The statement runs inside the session's current transaction, on the session's connection, like any other session.execute(). Nothing is committed until the session commits, and a failure rolls back with the rest of the transaction. Under async that is an ordinary await session.execute(...); there is no separate bulk API to learn.

Synchronisation is what synchronize_session controls. Suppose the session already holds Account 42, loaded earlier in the request, and the bulk update changes its plan in the database. Without synchronisation, account.plan in Python still says "trial". The 2.0 default, "auto", first tries "evaluate" — applying the WHERE criteria to in-memory objects in Python — and if the criteria are too complex to evaluate, falls back to "fetch", which on PostgreSQL adds RETURNING accounts.id to learn exactly which rows matched. Matching objects get the new values; nothing else is touched.

RETURNING can also hand back ORM objects directly, which replaces "update, then select what changed":

from sqlalchemy import update

from shop.models import Account

stmt = (
    update(Account)
    .where(Account.plan == "trial", Account.trial_ends_on < today)
    .values(plan="expired")
    .returning(Account)
)
expired = (await session.scalars(stmt)).all()   # Account objects, current values

Deletes work the same way — delete(CartItem).where(CartItem.updated_at < cutoff) — with one difference worth remembering: objects deleted in the database are marked deleted in the session when they are synchronised, so a later access in the same session behaves as it would after session.delete().

These statements sit squarely between the ORM and Core. They use the ORM's names and identity map, and Core's execution model, which is exactly the hybrid the parent guide on Core vs ORM architecture decisions recommends for write-heavy paths.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
Could not evaluate current criteria in Python ... Specify 'fetch' or False for the synchronize_session execution option.synchronize_session="evaluate" set explicitly with criteria Python cannot evaluate.Use the default "auto", or "fetch".
Objects in the session show old values after the updatesynchronize_session=False while the session held affected objects.Use "auto"/"fetch", or session.expire_all() afterwards.
No primary key value supplied for column(s) accounts.id; per-row ORM Bulk UPDATE by Primary Key requires that records contain primary key valuesA list of dictionaries passed to update(Account) without the primary key.Include the primary key in every dictionary.
Audit rows missing for bulk changesafter_update mapper events do not fire for bulk statements.Emit audit rows in the same statement with a CTE, or hook do_orm_execute.
Child rows left behind after a bulk deleteORM cascade="all, delete-orphan" is not applied to bulk deletes.ON DELETE CASCADE on the foreign key, or delete children first.
Optimistic locking silently bypassedversion_id_col is only checked by the unit of work.Add Account.version == expected to the WHERE and check rowcount.
rowcount is -1Some drivers do not report affected rows for certain statements.Use RETURNING and count the returned rows.
What a bulk statement does not do Four bands. Mapper events such as before_update and after_delete do not fire, only do_orm_execute does. Python-side validators and attribute events do not run. Relationship cascades configured on the ORM, such as delete-orphan, are not applied; only database ON DELETE rules act. Version counter checks from version_id_col are not performed. mapper events do not fire before_update / after_delete run at flush; a bulk statement never flushes those rows @validates and attribute events do not run the values go straight into SQL ORM cascades are not applied cascade='all, delete-orphan' does nothing; only ON DELETE CASCADE in the schema acts version_id_col is not checked optimistic locking needs the unit of work, or an explicit version predicate If behaviour lives in those hooks, move it into SQL, into do_orm_execute, or keep the per-object flush.

The skipped-behaviour rows share a cause: a bulk statement never passes the affected rows through the unit of work, so anything that is implemented as part of the unit of work does not happen. That includes @validates methods, attribute events, mapper events, relationship cascades and version counters. Database-side behaviour — triggers, ON DELETE rules, column server_onupdate — still applies, because the database executes the statement. Python-side onupdate column defaults also still apply, because Core renders them into the UPDATE.

Optimistic locking is the case most worth handling explicitly, because skipping it silently loses concurrent edits. Put the version predicate in the statement and treat zero affected rows as a conflict:

from sqlalchemy import update

from shop.errors import StaleEdit
from shop.models import Product


async def rename_product(session, product_id: int, name: str, expected_version: int) -> None:
    result = await session.execute(
        update(Product)
        .where(Product.id == product_id, Product.version == expected_version)
        .values(name=name, version=Product.version + 1)
    )
    if result.rowcount != 1:
        raise StaleEdit(product_id)

The full pattern is in implementing optimistic locking with version counters.

Advanced: Bulk UPDATE by Primary Key and UPDATE FROM

Two variants cover the cases where every matching row should not get the same value.

Which update form fits the job? Four steps of a decision. If per-object hooks such as validators, events or version checks must run, use the unit of work. Otherwise, if the same change applies to every matching row, use update with where. If each row gets different values keyed by primary key, pass a list of dictionaries to session.execute(update(Model)). If the statement needs values from other tables, use update with where and a correlated subquery or UPDATE FROM. must hooks, validators or versions run? yes → load and flush otherwise, go on same change for every matching row? yes → update().where().values() otherwise, go on different values per row, keyed by PK? yes → execute(update(M), [{'id':…}]) otherwise, go on values from other tables? update().where() with UPDATE FROM All three bulk forms run through session.execute(), so they share the session transaction.

When each row gets its own values — prices from a supplier feed, positions after a drag-and-drop reorder — pass a list of dictionaries, each containing the primary key, as the second argument to session.execute(). SQLAlchemy groups them and runs an executemany of UPDATE ... WHERE id = :id:

from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import Product


async def apply_price_feed(session: AsyncSession, feed: list[tuple[int, int]]) -> None:
    await session.execute(
        update(Product),
        [{"id": product_id, "price_cents": price} for product_id, price in feed],
    )
    await session.commit()

This form does not use RETURNING and does not support synchronize_session; objects already in the session are not refreshed, so run it in a session that does not hold those products, or expire them afterwards. For very large feeds, the fastest path is often to load the feed into a temporary table and run a single set-based update — the technique in loading rows with Postgres COPY through asyncpg.

When the new value comes from another table, a correlated WHERE across both tables renders as PostgreSQL's UPDATE ... FROM:

from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import Customer, Order


async def denormalise_customer_tier(session: AsyncSession) -> int:
    stmt = (
        update(Order)
        .where(Order.customer_id == Customer.id, Order.status == "open")
        .values(customer_tier=Customer.tier)
        .execution_options(synchronize_session="fetch")
    )
    result = await session.execute(stmt)
    await session.commit()
    return result.rowcount
# UPDATE orders SET customer_tier=customers.tier FROM customers
# WHERE orders.customer_id = customers.id AND orders.status = 'open'

"fetch" is set explicitly here because criteria spanning two tables cannot be evaluated in Python; "auto" would reach the same decision after trying.

All of these forms compose with the session's transaction, so a feed update, an audit insert and a cache-invalidation row can be written atomically in one commit(). That is the practical advantage over dropping to a raw AsyncConnection for bulk work.

Migrating Query.update() and Query.delete() Calls

Legacy code usually has many Query.update() and Query.delete() calls, and they translate mechanically. The details that change are the argument shapes and the synchronisation default.

Query.update() → update() Left, legacy: session.query(Account).filter(criteria).update(dictionary, synchronize_session=False) returns an integer row count. Right, 2.0: await session.execute(update(Account).where(criteria).values(plan=expired)) returns a result whose rowcount attribute holds the count, and synchronize_session defaults to auto, so the False argument can usually be removed. legacy 1.x session.query(Account) .filter(Account.plan == "trial") .update({...}, synchronize_session=False) returns an int 2.0 await session.execute(update(Account) .where(Account.plan == "trial") .values(plan="expired")) result.rowcount; sync defaults to auto The old False was often a workaround for 'evaluate' raising. 'auto' removes the reason for it.
# 1.x legacy
session.query(Account).filter(Account.plan == "trial").update(
    {Account.plan: "expired"}, synchronize_session=False
)
session.query(CartItem).filter(CartItem.cart_id == cart_id).delete(synchronize_session="fetch")
# 2.0
from sqlalchemy import delete, update

await session.execute(
    update(Account).where(Account.plan == "trial").values(plan="expired")
)
await session.execute(
    delete(CartItem).where(CartItem.cart_id == cart_id)
)

Three things to check on each converted call. First, the old code frequently passed synchronize_session=False because the old default, "evaluate", raised on complex criteria. The new default, "auto", does not raise, so dropping the argument is usually right and makes stale objects less likely. Keep False only where profiling shows the fetch matters and the session provably holds none of the rows.

Second, Query.update() accepted a dictionary keyed by attributes or strings; values() accepts keyword arguments or the same dictionary. Both work — keyword arguments read better and type-check against the model when using the typing guidance from SQLAlchemy 2.0 type annotations.

Third, Query.update() returned the row count directly; session.execute() returns a CursorResult, and the count is result.rowcount. Code that used the return value as a boolean needs the attribute.

With SQLALCHEMY_WARN_20=1 under 1.4, or future=True sessions, each remaining legacy call emits a RemovedIn20Warning naming the method, which turns the migration into a checklist — the process in fixing RemovedIn20Warning deprecation warnings.

Frequently Asked Questions

Does an ORM-enabled update fire before_update events?

No. Mapper events fire during flush, and a bulk statement does not flush the affected rows. Use the session-level do_orm_execute event to intercept bulk statements, or keep per-object flushes where the hooks are essential.

What does synchronize_session="auto" do?

It tries to apply the WHERE criteria to objects already in the session in Python, and falls back to fetching the matched primary keys with RETURNING if the criteria cannot be evaluated. Either way, in-session objects end up with the new values.

Can I get the updated objects back?

Yes. Add .returning(Account) and execute with session.scalars(); you receive ORM objects with their current values, which avoids a separate select.

Is session.execute(update(Model), list_of_dicts) the same as bulk_update_mappings?

It is the 2.0 replacement. It runs an executemany keyed by primary key, and like the legacy method it skips the unit of work and does not refresh in-session objects.