Bulk upserting rows with insert().on_conflict_do_update()

Pass a list of dicts to session.execute() with an on_conflict_do_update() statement and SQLAlchemy issues one executemany per chunk with the conflict clause applied per row — but the batch must be deduplicated on the conflict key first, or PostgreSQL rejects it, and sorted, or concurrent batches deadlock. This is the bulk form of upserts and conflict resolution strategies.

Quick Answer

Four things to do to a batch before executing it Four steps, each preventing a specific failure. Deduplicating on the conflict key is mandatory rather than an optimisation: PostgreSQL refuses a statement that would affect the same row twice, so a batch containing two rows for one key fails outright with CardinalityViolation. Sorting by that key makes deadlocks between concurrent batches impossible, because every batch then takes its row locks in the same order. Chunking bounds both memory and the size of a single statement, keeping it under any proxy or driver limit. And running the chunks inside one transaction keeps the load atomic — either the whole import lands or none of it does. deduplicate on the conflict key keep whichever row should win — usually the last by timestamp not an optimisation: PostgreSQL rejects the statement otherwise sort by the conflict key every batch then takes its row locks in the same order which makes deadlocks between concurrent batches impossible chunk to a bounded size two to five thousand rows is the usual range bounds memory and keeps statements under proxy limits run the chunks in one transaction the whole import lands, or none of it does a chunk-per-transaction load leaves a half-applied import behind The first two are the ones people discover in production: one produces an immediate error, the other an intermittent deadlock that only appears when two jobs overlap.

Before — a loop of single upserts:

for row in rows:                      # one round trip each: 1 000 000 round trips
    stmt = insert(Product).values(**row)
    stmt = stmt.on_conflict_do_update(index_elements=[Product.sku],
                                      set_={"price": stmt.excluded.price})
    await session.execute(stmt)

After — one statement per chunk:

from __future__ import annotations

from collections.abc import Iterable, Iterator
from itertools import islice

from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession

UPSERT_COLUMNS = ("name", "price", "updated_at")


def _chunks(rows: Iterable[dict], size: int) -> Iterator[list[dict]]:
    it = iter(rows)
    while chunk := list(islice(it, size)):
        yield chunk


async def bulk_upsert_products(session: AsyncSession, rows: Iterable[dict],
                               chunk_size: int = 2_000) -> int:
    stmt = insert(Product)
    stmt = stmt.on_conflict_do_update(
        index_elements=[Product.sku],
        set_={name: getattr(stmt.excluded, name) for name in UPSERT_COLUMNS},
    )

    affected = 0
    for chunk in _chunks(rows, chunk_size):
        # Mandatory: one row per key, or PostgreSQL rejects the whole statement.
        deduped = {row["sku"]: row for row in chunk}
        # Sorted: concurrent batches then take row locks in the same order.
        batch = [deduped[key] for key in sorted(deduped)]
        result = await session.execute(stmt, batch)
        affected += result.rowcount or 0
    return affected

The dict comprehension keeps the last row for each key, which is the usual intent when rows arrive in arrival order. Whichever rule you want, make it explicit — silently keeping the first is a different decision.

Execution Context & Async Workflow Integration

Passing a list of parameter dictionaries as the second argument to execute() is what switches SQLAlchemy into executemany mode. The statement is compiled once and the parameter sets are sent together, which is where nearly all of the speed-up comes from — the same mechanism described in batch inserting millions of rows with SQLAlchemy Core execute, with the conflict clause applied per row.

Adding RETURNING changes the strategy. SQLAlchemy's insertmanyvalues machinery rewrites the operation into batched VALUES statements so results can come back, which is slower than a plain executemany but far faster than per-row execution.

# Async — bulk upsert that needs the resulting ids back
async def bulk_upsert_returning(session: AsyncSession, rows: list[dict]) -> list[int]:
    stmt = insert(Product)
    stmt = stmt.on_conflict_do_update(
        index_elements=[Product.sku],
        set_={"price": stmt.excluded.price},
    ).returning(Product.id)

    deduped = {row["sku"]: row for row in rows}
    batch = [deduped[key] for key in sorted(deduped)]
    result = await session.execute(stmt, batch)
    return list(result.scalars())

Two constraints come with the transaction shape. Running every chunk inside one session.begin() makes the whole load atomic, which is usually what an import wants — but it also means every row lock is held until the end, so a very long load blocks other writers on those keys for its duration. Committing per chunk releases the locks progressively at the cost of a partially applied import if the job fails halfway.

Which to choose is a domain question rather than a technical one: a nightly full refresh should be atomic, while a continuous ingest stream is better off committing per chunk and being resumable. For the resumable case, recording the last successfully processed key alongside the commit turns a failed run into a restart rather than a redo.

A duplicate within one statement is rejected Two batches. A batch with distinct keys works exactly as expected: each row either inserts or updates the row already stored. A batch containing two rows for the same key fails with cannot affect row a second time — the statement would have to update a row it inserted moments earlier within the same command, which PostgreSQL does not allow because the second row has no committed version to conflict against. The fix is entirely in the application: reduce the batch to one row per key before executing, choosing deliberately which one wins. distinct keys in the batch each row inserts or updates exactly as intended one statement, one round trip nothing to think about two rows for the same key CardinalityViolation "cannot affect row a second time" no committed row to conflict with reduce to one row per key first Reducing the batch is a decision, not a cleanup: which of the duplicates should win is domain knowledge, and taking the last one by arrival order is a choice worth stating.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
CardinalityViolation: ON CONFLICT DO UPDATE command cannot affect row a second timeTwo rows in one batch share the conflict key.Deduplicate the batch on that key before executing, choosing deliberately which wins.
Intermittent DeadlockDetected between two jobsConcurrent batches took row locks in different orders.Sort every batch by the conflict key.
sqlalchemy.exc.ProgrammingError: bind message has ... parametersThe chunk is large enough that the total parameter count exceeded the driver limit.Reduce the chunk size; the practical bound is parameters, not rows.
Memory grows through the loadThe whole input was materialised as a list before chunking.Chunk from an iterator, as _chunks does, so only chunk_size rows are resident.
rowcount is -1Some drivers do not report affected rows for executemany.Count what you sent, or add RETURNING and count the rows that come back.
Half the import applied after a failureChunks were committed individually.One transaction for atomicity, or record progress and make the job resumable.

Advanced: Deduplication Rules and Staging Tables

Deduplicating a batch requires deciding which duplicate wins, and the answer is domain-specific often enough that it deserves to be explicit rather than incidental.

from __future__ import annotations

import datetime
from collections.abc import Iterable


def dedupe_latest(rows: Iterable[dict], key: str, timestamp: str) -> list[dict]:
    """Keep the row with the newest timestamp per key — correct for out-of-order feeds."""
    best: dict[str, dict] = {}
    for row in rows:
        current = best.get(row[key])
        if current is None or row[timestamp] > current[timestamp]:
            best[row[key]] = row
    return [best[k] for k in sorted(best)]


def dedupe_merged(rows: Iterable[dict], key: str) -> list[dict]:
    """Combine partial updates for the same key, later fields winning."""
    merged: dict[str, dict] = {}
    for row in rows:
        merged.setdefault(row[key], {}).update(
            {k: v for k, v in row.items() if v is not None}
        )
    return [merged[k] for k in sorted(merged)]

The second form matters for feeds where each message carries only the fields that changed: keeping the last message outright would null out fields an earlier message had set, whereas merging preserves them.

For very large loads, a staging table is frequently faster than a large upsert, because it separates the fast part from the expensive part:

# 1. COPY into an unlogged staging table — the fastest write PostgreSQL offers
await session.execute(text(
    "CREATE UNLOGGED TABLE IF NOT EXISTS products_stage "
    "(LIKE products INCLUDING DEFAULTS)"
))
await session.execute(text("TRUNCATE products_stage"))
# ... COPY the rows in ...

# 2. One set-based merge, with the deduplication done in SQL
await session.execute(text("""
    INSERT INTO products (sku, name, price, updated_at)
    SELECT DISTINCT ON (sku) sku, name, price, updated_at
    FROM   products_stage
    ORDER  BY sku, updated_at DESC
    ON CONFLICT (sku) DO UPDATE
       SET name = EXCLUDED.name,
           price = EXCLUDED.price,
           updated_at = EXCLUDED.updated_at
"""))

DISTINCT ON (sku) ... ORDER BY sku, updated_at DESC is PostgreSQL's deduplication idiom and does in one pass what the Python function above does row by row. The staging table being UNLOGGED means it is not written to the write-ahead log, which roughly halves the cost of loading it — acceptable precisely because losing it on a crash costs nothing.

This shape is worth the extra table above roughly a million rows, or whenever the source is a file that can be COPY-ed directly rather than parsed in Python.

Batch size dominates; RETURNING costs real time Relative throughput loading one million rows with conflict handling. A per-row loop is the baseline and is limited entirely by round trips. Batching five hundred rows per statement is roughly forty times faster. Increasing the batch to five thousand adds a further improvement but a much smaller one — the round-trip cost has already been amortised and the statement size is now the constraint. Adding RETURNING to the five-thousand-row batch costs a noticeable fraction, because every affected row must be sent back; it is worth paying only when the caller genuinely needs the resulting rows. relative throughput, 1 000 000 rows with conflict handling — longer is faster per-row upsert in a loop one round trip per row — the shape to eliminate executemany, batch of 500 round-trip cost already amortised executemany, batch of 5 000 a smaller further gain; statement size now binds executemany, batch of 5 000 + RETURNING every affected row travels back — pay only if you need them Past a few thousand rows per statement the curve flattens, so a very large batch buys little and risks a driver or proxy statement-size limit.

Sizing the Chunk and Measuring the Result

Chunk size is the one parameter people tune first and understand last. The constraint that actually binds is the parameter count rather than the row count: PostgreSQL's protocol limits a single bind message to 65 535 parameters, so a table with twelve upserted columns caps out at roughly 5 400 rows per statement regardless of how much memory you have.

MAX_BIND_PARAMS = 65_535


def safe_chunk_size(columns: int, requested: int = 5_000) -> int:
    """Never exceed the protocol limit, whatever was asked for."""
    return max(1, min(requested, MAX_BIND_PARAMS // max(columns, 1)))

Within that ceiling, the curve flattens quickly. Going from one row to five hundred is transformative; five hundred to five thousand is a further improvement of a few tens of percent; beyond that the gains are noise and the risk of hitting a proxy statement-size limit rises. Two to five thousand is the range worth starting in.

Measure with the discipline in benchmarking Core executemany bulk insert performance: a production-shaped table with its real indexes, the first run discarded, and the transaction boundary held constant between variants. An upsert benchmarked against an empty table tells you almost nothing, because the conflict path — which is the expensive one — is never taken.

The number worth reporting is rows per second with the expected conflict ratio. A load where ninety per cent of rows already exist behaves very differently from one where ten per cent do, because the update path writes a new row version and leaves the old one for vacuum. On a table that is upserted continuously, that dead-tuple churn is frequently the real cost, and it shows up as autovacuum activity rather than in the load's own timings — which is why watching table bloat after adopting a bulk upsert is worth as much as timing the load itself.

Frequently Asked Questions

Why must I deduplicate the batch myself?

Because PostgreSQL evaluates the whole statement against one snapshot, and a second row for the same key would have to update a row the same command inserted moments earlier. It refuses rather than picking a winner, since which duplicate should win is domain knowledge the database does not have.

Does sorting the batch really prevent deadlocks?

Between concurrent batches using the same rule, yes: a deadlock requires two transactions to acquire the same locks in opposite orders, and a shared sort order makes that impossible. It does not protect against a deadlock with some other transaction taking locks in a different order for unrelated reasons.

Is RETURNING worth the cost in a bulk upsert?

Only if the caller needs the rows. It switches SQLAlchemy from a plain executemany to the insertmanyvalues strategy so results can be returned, which is measurably slower. If you only need a count, use rowcount — or count what you sent.

How large should a chunk be?

Start at two to five thousand rows and cap it by the protocol's 65 535-parameter limit divided by your column count. The throughput curve is nearly flat past a few thousand, so a very large chunk buys little and risks a driver or proxy limit.