Writing PostgreSQL ON CONFLICT DO UPDATE upserts in SQLAlchemy
Import insert from sqlalchemy.dialects.postgresql, name the conflict target with index_elements, and build set_ from stmt.excluded — referencing the model column instead of excluded compiles without complaint and writes back the value that was already there. This is the construct behind upserts and conflict resolution strategies, in detail.
Quick Answer
Before — select, then insert or update:
existing = await session.scalar(select(Product).where(Product.sku == sku))
if existing is None:
session.add(Product(sku=sku, name=name, price=price)) # races
else:
existing.name, existing.price = name, price
await session.commit()
After — one statement, no window:
from __future__ import annotations
import decimal
from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models import Product
async def upsert_product(session: AsyncSession, sku: str, name: str,
price: decimal.Decimal) -> Product:
stmt = insert(Product).values(sku=sku, name=name, price=price)
stmt = stmt.on_conflict_do_update(
index_elements=[Product.sku],
set_={
"name": stmt.excluded.name, # excluded = the proposed row
"price": stmt.excluded.price,
"updated_at": func.now(),
},
).returning(Product)
return (await session.execute(stmt)).scalar_one()
Note the import: sqlalchemy.dialects.postgresql.insert, not sqlalchemy.insert. The generic construct has no on_conflict_do_update method, and the AttributeError you get from using it is the clearest error on this page.
Execution Context & Async Workflow Integration
stmt.excluded is available only after values() has been called, because it is derived from the columns the statement is inserting. Building the statement in the wrong order produces a confusing failure:
# Wrong — excluded does not know about columns that have not been named yet
stmt = insert(Product)
stmt = stmt.on_conflict_do_update(
index_elements=[Product.sku],
set_={"price": stmt.excluded.price}, # AttributeError, or silently wrong
).values(sku=sku, name=name, price=price)
# Right — values() first, then the conflict clause
stmt = insert(Product).values(sku=sku, name=name, price=price)
stmt = stmt.on_conflict_do_update(index_elements=[Product.sku],
set_={"price": stmt.excluded.price})
Under async the statement is executed exactly like any other, and the one thing worth knowing is that RETURNING gets you the resulting row in the same round trip. That matters more than usual here, because the alternative — upsert, then select — reintroduces a second statement and with it a window in which another transaction can change the row again.
# Async — a batch upsert, still one round trip per chunk
async def upsert_products(session: AsyncSession, rows: list[dict]) -> None:
stmt = insert(Product)
stmt = stmt.on_conflict_do_update(
index_elements=[Product.sku],
set_={"name": stmt.excluded.name, "price": stmt.excluded.price},
)
# Sorting by the conflict key makes deadlocks between concurrent batches
# impossible: every batch takes its row locks in the same order.
await session.execute(stmt, sorted(rows, key=lambda r: r["sku"]))
Sorting the batch is cheap insurance. Two concurrent batches touching the same two keys in opposite orders deadlock, and PostgreSQL resolves that by killing one of them — a failure that appears only under load and is tedious to reproduce deliberately.
Resolving Warnings, Errors & Common Mistakes
| Error / Warning | Root Cause | Production Fix |
|---|---|---|
AttributeError: 'Insert' object has no attribute 'on_conflict_do_update' | Imported insert from sqlalchemy rather than from sqlalchemy.dialects.postgresql. | Fix the import; the dialect construct is a different class. |
ProgrammingError: there is no unique or exclusion constraint matching the ON CONFLICT specification | No unique index over those columns, or the index is partial and index_where was omitted. | Create the index, or repeat its predicate in index_where. |
| The upsert runs but nothing changes | set_ referenced the mapped column instead of stmt.excluded. | Every entry in set_ should mention excluded, unless it deliberately combines both. |
CardinalityViolation: ON CONFLICT DO UPDATE command cannot affect row a second time | The same conflict key appears twice in one executemany batch. | Deduplicate the batch in Python, keeping whichever row should win. |
NoResultFound from scalar_one() | The where clause suppressed the update, so RETURNING produced nothing. | Use scalar_one_or_none() and treat None as "skipped". |
| Deadlocks under concurrent batches | Two batches took row locks in different orders. | Sort each batch by the conflict key before executing. |
| A column silently keeps its old value | It was omitted from set_. | Build set_ from a comprehension over the columns you intend to overwrite, so omissions are deliberate. |
Advanced: Conditional Updates and Partial Indexes
Two options turn ON CONFLICT from a blunt overwrite into something precise enough for real ingest paths.
The where clause decides whether the update happens at all, and is evaluated with access to both the stored row and the proposed one:
stmt = insert(Reading).values(sensor_id=sensor_id, taken_at=taken_at, value=value)
stmt = stmt.on_conflict_do_update(
index_elements=[Reading.sensor_id],
set_={"taken_at": stmt.excluded.taken_at, "value": stmt.excluded.value},
# Reading.taken_at is the STORED row; stmt.excluded.taken_at is the PROPOSED one.
where=Reading.taken_at < stmt.excluded.taken_at,
)
That single line makes an ingest path robust against out-of-order delivery: a message that arrives late carrying an older reading is simply ignored, without the application having to read the current value first — which would reintroduce the race the upsert was chosen to avoid.
index_where names the predicate of a partial unique index, which is required whenever the uniqueness rule applies to a subset of rows:
# CREATE UNIQUE INDEX uq_active_subscription ON subscriptions (customer_id)
# WHERE status = 'active';
stmt = insert(Subscription).values(customer_id=cid, plan=plan, status="active")
stmt = stmt.on_conflict_do_update(
index_elements=[Subscription.customer_id],
index_where=Subscription.status == "active", # must match the index exactly
set_={"plan": stmt.excluded.plan},
)
The predicate must match the index's own predicate, not merely be compatible with it — PostgreSQL performs a syntactic comparison and rejects anything it cannot prove equivalent. In practice that means keeping the model-level expression and the migration's DDL in step, which is one more reason to generate both from the same place.
Building set_ programmatically removes the omitted-column class of bug entirely:
UPSERT_COLUMNS = ("name", "price", "description", "updated_at")
stmt = stmt.on_conflict_do_update(
index_elements=[Product.sku],
set_={name: getattr(stmt.excluded, name) for name in UPSERT_COLUMNS},
)
Now adding a column to the upsert means adding it to one tuple, and it is impossible to reference the stored value by accident.
Reading the Result: RETURNING and Affected Rows
An upsert has three possible outcomes — inserted, updated, or skipped by the where clause — and callers frequently need to know which. RETURNING gives you the row; distinguishing the branches takes one more column.
from sqlalchemy import literal_column
stmt = insert(Product).values(sku=sku, name=name, price=price)
stmt = stmt.on_conflict_do_update(
index_elements=[Product.sku],
set_={"name": stmt.excluded.name, "price": stmt.excluded.price},
).returning(
Product.id,
# xmax is 0 for a freshly inserted row and non-zero for an updated one.
(literal_column("xmax") == 0).label("inserted"),
)
row = (await session.execute(stmt)).one_or_none()
if row is None:
outcome = "skipped" # only possible with a where clause
elif row.inserted:
outcome = "inserted"
else:
outcome = "updated"
The xmax trick is PostgreSQL-specific and relies on an implementation detail of MVCC, so it is worth a comment wherever it appears. It is reliable in practice and is the only way to distinguish the branches without a second statement — but if the distinction is load-bearing for business logic rather than for metrics, a guarded insert with DO NOTHING expresses the intent more honestly, as the parent guide argues.
result.rowcount is the cheaper signal when you do not need the row itself: it is 1 when the statement inserted or updated and 0 when the where clause suppressed the update. That is enough for a metric counting skipped out-of-order messages, which is usually the number worth watching on an ingest path — a sudden rise means upstream ordering has changed, and that is worth knowing before the data does something surprising.
A Review Checklist for Any Upsert
Five questions catch nearly every upsert defect, and all of them can be answered by reading the statement rather than by running it.
Does every entry in set_ mention excluded? If one references the mapped column instead, that column is being written back over itself and the upsert silently does nothing for it. The exception is a deliberate combination such as Product.stock + stmt.excluded.stock, which mentions both.
Does a unique index actually exist over index_elements? PostgreSQL raises if not, which is the good case — but a partial index needs its predicate repeated in index_where, and forgetting that produces the same error for a reason that is less obvious.
Is every column that should change present in set_? A column omitted keeps its stored value, and for updated_at in particular the row then looks untouched. Building set_ from a named tuple of columns removes this class of mistake entirely.
Is the batch deduplicated and sorted? Duplicates within one statement are rejected outright; unsorted batches deadlock against each other under concurrency. Both are properties of the caller, not of the statement.
Does the caller handle the skipped case? A where clause that suppresses the update also suppresses RETURNING, so scalar_one() raises where scalar_one_or_none() would return None.
Frequently Asked Questions
Can I use ON CONFLICT with the ORM rather than Core?
The statement is a Core construct, but it can return ORM entities with .returning(Model) and be executed through an AsyncSession. What it does not do is participate in the unit of work: no ORM events fire and the identity map is not updated, so an instance loaded earlier in the same session keeps its old values until refreshed.
Why must values() come before on_conflict_do_update()?
stmt.excluded is built from the columns the insert names, so it does not exist until values() has been called. Calling the conflict clause first either raises AttributeError or, worse, produces a statement referencing columns the insert does not set.
What happens if two rows in one batch have the same key?
PostgreSQL raises CardinalityViolation: ON CONFLICT DO UPDATE command cannot affect row a second time. It will not silently pick one. Deduplicate in Python before executing, keeping whichever row should win — usually the last by timestamp.
Does ON CONFLICT work on SQLite for tests?
Yes, through sqlalchemy.dialects.sqlite.insert, with on_conflict_do_update and on_conflict_do_nothing both available. The syntax is close enough that the code paths are exercised, but SQLite serialises writers so it cannot demonstrate the concurrency property that motivated the upsert.
Related
- Upserts and Conflict Resolution Strategies — The parent guide: which strategy fits which intent, and when an upsert is the wrong tool.
- Handling IntegrityError on concurrent inserts — What to do when a constraint fires anyway, and why a retry is not the first answer.
- Bulk upserting rows with insert().on_conflict_do_update() — Turning this statement into an executemany, and the batch-level traps.
- High-performance bulk inserts and updates — The chunking and transaction shape a large upsert load needs.