Module 7: Bulk Operations
Atomic upserts with `ON CONFLICT`
"Upsert" = INSERT or UPDATE. The typical case: you import data where some records already exist. Without upsert, your options are:
SELECTthenINSERT/UPDATE: race condition between the check and the write.- Try
INSERT, catch the error,UPDATE: ugly, two round-trips, possible deadlocks. - Truncate + re-insert: loses data, not idempotent.
PostgreSQL has the canonical answer since 9.5: INSERT ... ON CONFLICT. It's atomic (no race), idempotent (re-running the same INSERT causes no errors), and bulk-friendly (combines with executemany and COPY-to-temp).
In this capsule you'll learn the exact syntax, the three variants (DO NOTHING, DO UPDATE, partial), how to use EXCLUDED correctly, and the most common anti-patterns.
The basic syntax
INSERT INTO tasks (id, title, status)
VALUES (1, 'Task 1', 'pending')
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
status = EXCLUDED.status,
updated_at = NOW();
Reading it step by step:
INSERT INTO ... VALUES: try to insert.ON CONFLICT (id): if there's a conflict on theidcolumn (because it already exists).DO UPDATE SET ...: instead of failing, do this UPDATE.EXCLUDED.title: refers to the value that was going to be inserted. PostgreSQL creates a pseudo-tableEXCLUDEDholding the proposed row.
EXCLUDED is the key keyword. It lets you refer to the "new" value in the UPDATE, distinguishing it from the existing row.
Three variants of ON CONFLICT
Variant 1: DO NOTHING (skip duplicates)
INSERT INTO tasks (id, title) VALUES (1, 'Task 1')
ON CONFLICT (id) DO NOTHING;
If id=1 already exists, it does nothing. Useful for idempotent imports where you don't want to overwrite.
Typical use case: an event log where duplicates are possible but the first one wins.
Variant 2: DO UPDATE (overwrite)
INSERT INTO tasks (id, title, status) VALUES (1, 'Updated', 'completed')
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
status = EXCLUDED.status;
If id=1 exists, it updates title and status with the new values.
Typical use case: syncing from an external source. The latest value always wins.
Variant 3: partial DO UPDATE (with WHERE)
INSERT INTO tasks (id, title, status, version) VALUES (1, 'Updated', 'completed', 5)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
status = EXCLUDED.status,
version = EXCLUDED.version
WHERE tasks.version < EXCLUDED.version; -- Only update if the new version is newer
Conditional update. The row is updated only if the condition holds. Useful for "last-write-wins" with timestamps or version numbers.
Conflict target: which column(s)
ON CONFLICT (cols) requires the columns to have a UNIQUE or PRIMARY KEY constraint:
-- ✅ id is PK
INSERT INTO tasks (id, ...) VALUES (...) ON CONFLICT (id) DO UPDATE ...;
-- ✅ email is UNIQUE
INSERT INTO users (email, ...) VALUES (...) ON CONFLICT (email) DO UPDATE ...;
-- ❌ status is not UNIQUE — error
INSERT INTO tasks (...) VALUES (...) ON CONFLICT (status) DO UPDATE ...;
-- ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
Conflict on a composite key
-- Composite UNIQUE constraint
ALTER TABLE order_items ADD CONSTRAINT uq_order_book UNIQUE (order_id, book_id);
INSERT INTO order_items (order_id, book_id, quantity) VALUES (1, 42, 3)
ON CONFLICT (order_id, book_id) DO UPDATE SET
quantity = order_items.quantity + EXCLUDED.quantity;
Useful for "add if it doesn't exist, sum the quantity if it does".
Conflict on a constraint name
-- If you have several UNIQUE constraints, you can specify which one
INSERT INTO ...
ON CONFLICT ON CONSTRAINT my_unique_constraint DO UPDATE ...;
Implementation in SQLAlchemy 2.0
Single row upsert
from sqlalchemy.dialects.postgresql import insert as pg_insert
async def upsert_task(session, task_data: dict):
stmt = pg_insert(Task).values(**task_data)
stmt = stmt.on_conflict_do_update(
index_elements=["id"], # ON CONFLICT (id)
set_={
"title": stmt.excluded.title,
"status": stmt.excluded.status,
}
)
await session.execute(stmt)
await session.commit()
stmt.excluded is the translation of EXCLUDED.col in SQL.
Bulk upsert with ON CONFLICT
async def bulk_upsert_tasks(session, records: list[dict]):
stmt = pg_insert(Task).values(records)
stmt = stmt.on_conflict_do_update(
index_elements=["id"],
set_={
"title": stmt.excluded.title,
"status": stmt.excluded.status,
}
)
await session.execute(stmt)
await session.commit()
pg_insert(Task).values(list_of_dicts) runs a multi-row INSERT with ON CONFLICT on each row. Atomic.
DO NOTHING
stmt = pg_insert(Task).values(records)
stmt = stmt.on_conflict_do_nothing(index_elements=["id"])
await session.execute(stmt)
Conditional UPDATE
from sqlalchemy import text
stmt = pg_insert(Task).values(records)
stmt = stmt.on_conflict_do_update(
index_elements=["id"],
set_={
"title": stmt.excluded.title,
"version": stmt.excluded.version,
},
where=Task.version < stmt.excluded.version, # Conditional
)
Complete pattern in an endpoint
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.postgresql import insert as pg_insert
router = APIRouter()
@router.post("/tasks/sync")
async def sync_tasks(
tasks: list[TaskSyncData],
db: AsyncSession = Depends(get_db),
):
"""Sync tasks from an external source (idempotent).
- If the task doesn't exist (by external_id), insert it.
- If it exists, update it with the new values.
"""
records = [
{
"external_id": t.external_id,
"title": t.title,
"status": t.status,
"synced_at": datetime.now(timezone.utc),
}
for t in tasks
]
stmt = pg_insert(Task).values(records)
stmt = stmt.on_conflict_do_update(
index_elements=["external_id"],
set_={
"title": stmt.excluded.title,
"status": stmt.excluded.status,
"synced_at": stmt.excluded.synced_at,
}
)
await db.execute(stmt)
await db.commit()
return {"synced": len(records)}
The client can call this endpoint multiple times with the same data — it always reaches the same final state. Perfect idempotency.
Common use cases
1. Sync from an external source
# Cron job that syncs with an external system
async def sync_from_external():
external_data = fetch_from_external_api()
await bulk_upsert_tasks(external_data)
# Re-running it is safe
2. Incremental counter
-- Tracking page views: increment the counter or create it if it doesn't exist
INSERT INTO page_views (page_id, view_count, last_viewed)
VALUES ($1, 1, NOW())
ON CONFLICT (page_id) DO UPDATE SET
view_count = page_views.view_count + 1,
last_viewed = NOW();
page_views.view_count + 1 (reference to the original table) is important. EXCLUDED.view_count + 1 would be wrong (always 1+1=2).
3. Idempotency for deduplicated messages
-- Dedupe messages by message_id
INSERT INTO messages (message_id, content, created_at) VALUES ($1, $2, $3)
ON CONFLICT (message_id) DO NOTHING
RETURNING id;
If the message already exists, it returns no row. The app knows it already processed it.
4. Last-write-wins with version
INSERT INTO documents (id, content, version) VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET
content = EXCLUDED.content,
version = EXCLUDED.version
WHERE documents.version < EXCLUDED.version;
Only updates if the new version is higher. Race-safe.
Referencing the current table vs EXCLUDED
Common confusion: when to use tasks.col vs EXCLUDED.col:
INSERT INTO tasks (id, view_count) VALUES (1, 1)
ON CONFLICT (id) DO UPDATE SET
view_count = tasks.view_count + 1; -- current value + 1
-- vs
view_count = EXCLUDED.view_count + 1; -- the value that was going to be inserted (1) + 1 = always 2
tasks.col= the current value of the existing row.EXCLUDED.col= the value you were going to insert.
For counters: tasks.view_count + 1.
For overwrite: EXCLUDED.col.
For incremental sum: tasks.col + EXCLUDED.col.
Limitations
ON CONFLICT doesn't work with direct COPY
-- ❌ There's no COPY ... ON CONFLICT
COPY tasks FROM STDIN ON CONFLICT (id) DO UPDATE SET ...; -- Syntax error
For bulk upserts, the pattern is COPY into a temp table + INSERT...ON CONFLICT from the temp (capsule 06).
Only one conflict target column at a time
You can't have multiple ON CONFLICT clauses with different columns in a single query. Only one target.
RETURNING can be confusing
INSERT ... ON CONFLICT DO UPDATE ... RETURNING id, (xmax = 0) AS inserted;
xmax = 0 is a trick to tell whether it was an INSERT (true) or an UPDATE (false). Useful for reporting stats.
Pitfalls and common mistakes
1. ON CONFLICT (col) on a column without a UNIQUE constraint.
Error: "there is no unique or exclusion constraint matching the ON CONFLICT specification". Check that you have UNIQUE or PRIMARY KEY on that column.
2. Confusing EXCLUDED with NEW.
NEW only exists in PL/pgSQL triggers. In ON CONFLICT, it's EXCLUDED.
3. EXCLUDED for counters.
-- ❌ Wrong: always sums 1+1=2 (or the initial value)
ON CONFLICT (id) DO UPDATE SET count = EXCLUDED.count + 1;
-- ✅ Right: increments the current value
ON CONFLICT (id) DO UPDATE SET count = tasks.count + 1;
4. Forgetting columns in SET.
If your UPDATE only sets title, other columns keep their existing value. For a "full overwrite", list every column.
5. WHERE on ON CONFLICT with an incorrect condition.
-- If the condition isn't met, it does NOT fail — it simply doesn't update.
INSERT ... ON CONFLICT (id) DO UPDATE SET ... WHERE FALSE;
This is like DO NOTHING. Be careful with the WHERE logic.
6. ON CONFLICT DO UPDATE with PL/pgSQL triggers.
Triggers fire on the underlying INSERT and on the resulting UPDATE. If your trigger has side effects (logging, audit), it fires twice. Check.
7. Expected vs real performance.
ON CONFLICT adds per-row overhead (checking the unique constraint). On very large bulk loads, it can be ~10-30% slower than a plain INSERT. For 100k rows: 4s without ON CONFLICT, 5-6s with it.
8. bulk_insert (SQLAlchemy ORM bulk) does NOT support ON CONFLICT.
session.execute(insert(Model), [...]) doesn't support on_conflict_do_update easily. Use pg_insert from sqlalchemy.dialects.postgresql, which does.
Exercise: implement a sync endpoint
Setup:
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
external_id: Mapped[str] = mapped_column(String(100), unique=True)
title: Mapped[str] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(50))
synced_at: Mapped[datetime] = mapped_column(server_default=func.now())
external_id is UNIQUE — the conflict target.
Step 1: implement POST /tasks/sync with upsert.
from sqlalchemy.dialects.postgresql import insert as pg_insert
@router.post("/tasks/sync")
async def sync_tasks(tasks: list[TaskSync], db: AsyncSession = Depends(get_db)):
records = [t.model_dump() for t in tasks]
stmt = pg_insert(Task).values(records)
stmt = stmt.on_conflict_do_update(
index_elements=["external_id"],
set_={
"title": stmt.excluded.title,
"status": stmt.excluded.status,
"synced_at": stmt.excluded.synced_at,
}
)
await db.execute(stmt)
await db.commit()
return {"synced": len(records)}
Step 2: test idempotency.
# Run twice — the second must not fail
data = [{"external_id": "ext-1", "title": "T1", "status": "pending"}]
await sync_tasks(data, db)
await sync_tasks(data, db) # No error, same final state
Step 3: test that values get updated.
# First run: insert
await sync_tasks([{"external_id": "ext-1", "title": "Original", "status": "pending"}], db)
# Second: update
await sync_tasks([{"external_id": "ext-1", "title": "Updated", "status": "completed"}], db)
# Verify
task = await db.scalar(select(Task).where(Task.external_id == "ext-1"))
assert task.title == "Updated"
assert task.status == "completed"
Step 4: add reporting (how many inserts vs updates).
stmt = pg_insert(Task).values(records).returning(
Task.id,
text("(xmax = 0) AS inserted") # true if insert, false if update
)
stmt = stmt.on_conflict_do_update(...)
result = await db.execute(stmt)
rows = result.all()
inserted = sum(1 for r in rows if r.inserted)
updated = len(rows) - inserted
return {"inserted": inserted, "updated": updated}
Step 5: add a conditional update (only if the version is higher).
stmt = stmt.on_conflict_do_update(
index_elements=["external_id"],
set_={...},
where=Task.version < stmt.excluded.version,
)
See discussion
Step 1 — implementation: works out-of-the-box.
Step 2 — idempotency:
Run the same payload 100 times. The final state is the same. No errors.
Step 3 — update:
Existing tasks get updated. synced_at too, which gives you auditability.
Step 4 — reporting:
xmax = 0 is a PostgreSQL trick to identify whether it was an INSERT vs an UPDATE. Useful for returning stats to the client.
Step 5 — conditional:
If a task has version=5 locally and you receive data with version=3 (out-of-order), it doesn't get updated. Protection against delivery race conditions.
Key takeaways:
ON CONFLICTsolves idempotency at the SQL level, atomically.- Combined with bulk insert, it gives you re-runnable imports.
EXCLUDEDvstable.colmatters a lot — tell them apart clearly.- A conditional WHERE protects against out-of-order updates.
Summary and next step
What you learned:
INSERT ... ON CONFLICT (col): PostgreSQL's native atomic upsert.- 3 variants:
DO NOTHING(skip),DO UPDATE(overwrite),DO UPDATE WHERE(conditional). EXCLUDED= the value that was going to be inserted.table.col= the current value.- Conflict target: requires a UNIQUE/PK constraint on the column(s).
- SQLAlchemy 2.0: use
pg_insertfromsqlalchemy.dialects.postgresql, not the genericinsert. - Bulk upsert works with a list of dicts.
- Limitation: direct COPY doesn't support ON CONFLICT — it needs the temp-table pattern (capsule 06).
Before moving on, you should be able to:
- Write SQL
INSERT ... ON CONFLICT (col) DO UPDATE SET col = EXCLUDED.col. - Implement an upsert with
pg_insertin SQLAlchemy 2.0. - Tell when to use
EXCLUDEDvstable.col. - Apply
WHEREon ON CONFLICT for conditional updates.
In the next capsule we combine COPY with ON CONFLICT — the canonical pattern for large bulk upserts. Since COPY doesn't support ON CONFLICT directly, the pattern is: COPY into a temp table → INSERT...ON CONFLICT from the temp. You'll learn the complete setup and why this pattern is the one any serious PostgreSQL ETL uses.
Resources
- PostgreSQL Docs —
INSERT ... ON CONFLICT— official reference. - SQLAlchemy 2.0 —
pg_insert.on_conflict_do_update— reference. - PostgreSQL Wiki — UPSERT — history and cases.
- Brandur Leach — Postgres queries patterns — patterns with ON CONFLICT.
- Citus Data — UPSERT performance — cases at scale.
- Heap — Idempotent ETL — ON CONFLICT in a pipeline.
Capsule 05 of 08 — Module 7 — SQL Patterns for Production APIs Guide