Batch Upsert Patterns for pgvector

Row-at-a-time ingestion is the default that quietly caps a pgvector pipeline. Every autocommitted INSERT flushes WAL, re-descends the table’s indexes, and pays a full network round-trip, so a single writer stalls near a few hundred rows per second while the corpus grows into the millions. Reach for the obvious fix — one giant COPY — and you gain raw throughput but lose the one property an ingestion pipeline cannot live without: idempotency. Re-run yesterday’s batch after a partial failure and COPY happily duplicates every chunk, because it has no conflict handling at all. The engineering problem for bulk vector writes is reconciling three things that pull against each other: throughput, idempotent upsert semantics, and WAL/lock pressure on a shared cluster. This is a core write-side concern within Embedding Ingestion Pipeline Engineering, and it has a clean answer — stage with COPY, then merge with ON CONFLICT — that this page builds end to end with runnable SQL and both psycopg3 and asyncpg drivers.

Up: Embedding Ingestion Pipeline Engineering

Three batch-write strategies for a pgvector target A normalized batch ordered by conflict key fans out to three write strategies. Path a is a binary COPY: fastest bulk append but with no conflict handling, so it duplicates on replay. Path b is a multi-row INSERT with ON CONFLICT DO UPDATE: idempotent but pays per-row index maintenance and is the slowest. Path c copies the batch into an UNLOGGED staging table and then runs a single INSERT INTO target SELECT ... ON CONFLICT merge, achieving near-COPY speed while staying idempotent. All three write into the doc_embeddings pgvector target. Normalized batch ordered by conflict key a. COPY … FORMAT BINARY fastest append · no conflict handling b. INSERT … ON CONFLICT idempotent · per-row index cost c. COPY to staging → merge near-COPY speed · idempotent doc_embeddings

Architectural Divergence & Trade-offs

Three write strategies dominate bulk vector ingestion, and they occupy genuinely different points on the throughput/idempotency/WAL curve. Picking one is a decision about which property you are willing to trade.

Binary COPY is the fastest way to get vectors into PostgreSQL. COPY doc_embeddings FROM STDIN WITH (FORMAT BINARY) streams rows through a single dedicated protocol path, skips per-statement planning, and — critically — encodes each vector in its native binary layout instead of parsing a text literal like '[0.1,0.2,...]'. For a 1536-dimensional embedding that is the difference between shipping ~6 KB of raw float4 and parsing a ~20 KB ASCII string per row. The catch is total: COPY has no ON CONFLICT clause. It is an append. Feed it a doc_id/chunk_index that already exists and it either duplicates the row (no unique constraint) or aborts the whole batch on the first violation (unique constraint present). It is the right tool only for a first load into an empty or partition-fresh table where every key is guaranteed new.

Multi-row INSERT … ON CONFLICT DO UPDATE is the idempotent workhorse. A single statement carrying several hundred VALUES tuples with a conflict target of (doc_id, chunk_index) upserts cleanly: new keys insert, existing keys update in place, and a replay of the same batch is a no-op if you add a content_hash guard. The price is throughput. Every row is planned as an index probe against the arbiter constraint, and each affected row maintains all indexes on the table — including the HNSW or IVFFlat vector index, whose insert path is far heavier than a b-tree’s. Under churn this also generates dead tuples that autovacuum must later reclaim.

COPY into an UNLOGGED staging table, then a single set-based INSERT INTO target SELECT … ON CONFLICT DO UPDATE merge combines the best of both. The bulk landing is a binary COPY at full speed into a throwaway table that carries no indexes and, being UNLOGGED, writes no WAL. The merge is then one large, planned, set-oriented statement the executor can optimize as a whole, rather than N separate upserts. You keep idempotency (the ON CONFLICT lives on the merge) and pay index maintenance only once, in bulk, on the target. This is the default recommendation for any pipeline that re-ingests changed documents.

Strategy Relative throughput Idempotent? WAL volume Lock behavior Best fit
COPY … FORMAT BINARY Highest No — appends/duplicates Full (target is logged) Brief ROW EXCLUSIVE, short First load into empty/fresh partition
Multi-row INSERT … ON CONFLICT Lowest Yes Full + dead-tuple churn ROW EXCLUSIVE + per-row index locks, held for batch Small trickle upserts, mixed insert/update
COPYUNLOGGED staging → merge Near-COPY Yes Staging = none; merge = full once Two short locks; merge is one set op Bulk re-ingestion of changing corpora

Two decisions sit above the table. First, normalize vectors before the write, not after: unit-normalizing in the pipeline lets you use the cheaper inner-product operator downstream and keeps the stored representation canonical, as detailed in normalizing embeddings before pgvector insertion. Second, the conflict key is a schema decision, not a write-time one — the (doc_id, chunk_index) identity and any content hash come from your metadata mapping and schema design, and getting it wrong turns every upsert into a silent duplicate.

Parameter Space & Diagnostic Workflow

Batch writing has one dominant tunable — rows per batch — and several supporting knobs. Batch geometry is a three-way trade: larger batches amortize WAL flushes and round-trips (good), but hold locks longer and buffer more rows in memory (bad), and past a point return diminishing throughput while raising deadlock probability under concurrency.

Parameter Typical range Production recommendation Notes
Rows per batch 500–10,000 1,000–5,000 for 1536-dim vectors ~6 KB/row raw; 5,000 rows ≈ 30 MB in flight. Tune down if deadlocks appear
maintenance_work_mem 64 MB default 512 MB–2 GB during bulk load Speeds the one-time index maintenance on merge
synchronous_commit on off for the ingest session only Removes commit-time fsync stall; safe for replayable batches
Staging table CREATE TEMP … UNLOGGED, no indexes No WAL, no index maintenance on landing
checkpoint_timeout / max_wal_size 5 min / 1 GB Raise max_wal_size before large loads Prevents checkpoint storms from frequent forced checkpoints
Batch ordering unordered sort by (doc_id, chunk_index) Deterministic lock-acquisition order prevents deadlocks
autovacuum_vacuum_scale_factor (table) 0.2 0.02–0.05 on churny vector tables Reclaims dead tuples from repeated DO UPDATE promptly

The diagnostic that matters most for an upsert workload is whether the write actually did what you think. PostgreSQL exposes this through the system column xmax: on an INSERT … ON CONFLICT, a freshly inserted row has xmax = 0, while a row that took the DO UPDATE path has a non-zero xmax. RETURNING (xmax = 0) AS inserted lets the pipeline count inserts versus updates per batch and alert when a supposedly incremental load is rewriting everything:

SQL
WITH up AS (
  INSERT INTO doc_embeddings (doc_id, chunk_index, content_hash, embedding)
  VALUES ($1, $2, $3, $4)
  ON CONFLICT (doc_id, chunk_index) DO UPDATE
    SET embedding = EXCLUDED.embedding,
        content_hash = EXCLUDED.content_hash,
        updated_at = now()
    WHERE doc_embeddings.content_hash IS DISTINCT FROM EXCLUDED.content_hash
  RETURNING (xmax = 0) AS inserted
)
SELECT count(*) FILTER (WHERE inserted)      AS inserts,
       count(*) FILTER (WHERE NOT inserted)  AS updates
FROM up;

To see whether a running bulk load is generating pathological WAL or waiting on locks, watch the ingestion role in pg_stat_activity and the table’s churn in pg_stat_user_tables:

SQL
SELECT n_tup_ins, n_tup_upd, n_dead_tup,
       n_dead_tup::float / nullif(n_live_tup, 0) AS dead_ratio
FROM pg_stat_user_tables
WHERE relname = 'doc_embeddings';

A dead_ratio climbing above ~0.2 during ingestion means DO UPDATE churn is outrunning autovacuum — the source of index bloat discussed under Failure Modes.

Step-by-Step Implementation

The following builds the recommended path — binary COPY into an UNLOGGED staging table, then a set-based merge — and shows the driver-native binary COPY calls for both psycopg3 and asyncpg.

Step 1 — Define the target with a conflict key and a content hash. Idempotency is a schema property. The primary key is the conflict arbiter; content_hash lets the merge skip unchanged chunks.

SQL
CREATE TABLE IF NOT EXISTS doc_embeddings (
    doc_id       text         NOT NULL,
    chunk_index  int          NOT NULL,
    content_hash text         NOT NULL,
    embedding    vector(1536) NOT NULL,
    updated_at   timestamptz  NOT NULL DEFAULT now(),
    PRIMARY KEY (doc_id, chunk_index)
);

Step 2 — Binary COPY a batch with psycopg3. Register the vector adapter on the connection so pgvector.psycopg.register_vector teaches psycopg3 to encode a numpy array or list directly into the binary vector wire format. The copy() context manager streams rows without building one giant string.

PYTHON
import numpy as np
import psycopg
from pgvector.psycopg import register_vector

def copy_batch(conn: psycopg.Connection, rows: list[dict]) -> None:
    register_vector(conn)                       # binary vector adaptation
    with conn.cursor() as cur:
        with cur.copy(
            "COPY staging (doc_id, chunk_index, content_hash, embedding) "
            "FROM STDIN WITH (FORMAT BINARY)"
        ) as copy:
            copy.set_types(["text", "int4", "text", "vector"])
            for r in rows:
                copy.write_row((
                    r["doc_id"], r["chunk_index"], r["content_hash"],
                    np.asarray(r["embedding"], dtype=np.float32),
                ))

Step 3 — Or binary-load with asyncpg. asyncpg’s copy_records_to_table runs the binary COPY protocol natively. Register the vector type once per physical connection so the codec is installed, then hand it an iterable of tuples.

PYTHON
import asyncpg
from pgvector.asyncpg import register_vector

async def copy_batch_async(conn: asyncpg.Connection, rows: list[dict]) -> None:
    await register_vector(conn)                 # installs the vector codec
    await conn.copy_records_to_table(
        "staging",
        columns=["doc_id", "chunk_index", "content_hash", "embedding"],
        records=[
            (r["doc_id"], r["chunk_index"], r["content_hash"], r["embedding"])
            for r in rows
        ],
    )

Step 4 — Land into an UNLOGGED staging table, then merge. Create the staging table as UNLOGGED (or TEMP) with no indexes so the COPY in Step 2/3 pays neither WAL nor index cost. Then a single set-based statement moves everything into the target with conflict resolution.

SQL
CREATE UNLOGGED TABLE IF NOT EXISTS staging (
    doc_id       text         NOT NULL,
    chunk_index  int          NOT NULL,
    content_hash text         NOT NULL,
    embedding    vector(1536) NOT NULL
);
TRUNCATE staging;                       -- reuse across batches

-- ... COPY the batch into staging (Step 2 or 3) ...

INSERT INTO doc_embeddings AS t
      (doc_id, chunk_index, content_hash, embedding, updated_at)
SELECT doc_id, chunk_index, content_hash, embedding, now()
FROM   staging
ORDER  BY doc_id, chunk_index            -- deterministic lock order
ON CONFLICT (doc_id, chunk_index) DO UPDATE
   SET embedding    = EXCLUDED.embedding,
       content_hash = EXCLUDED.content_hash,
       updated_at   = now()
   WHERE t.content_hash IS DISTINCT FROM EXCLUDED.content_hash;

The ORDER BY inside the SELECT gives every concurrent merge the same lock-acquisition order, which is the single most effective deadlock defense (see Failure Modes). The WHERE t.content_hash IS DISTINCT FROM EXCLUDED.content_hash guard turns re-ingestion of unchanged chunks into a genuine no-op — no row version, no WAL, no index churn.

Step 5 — Session-level tuning for the bulk window. For a large one-shot load, widen the WAL and index-build budgets and relax commit durability for the ingest session only. These are safe precisely because the batches are replayable.

SQL
SET synchronous_commit = off;           -- no per-commit fsync stall
SET maintenance_work_mem = '1GB';       -- faster one-time index maintenance
-- max_wal_size is a server GUC; raise it in postgresql.conf before big loads

Batch geometry is chosen here: pick a rows-per-batch that keeps in-flight memory bounded (≈ rows × 6 KB for 1536-dim vectors) while still amortizing round-trips. One to five thousand rows is a sound starting point; the index build itself, if you defer it until after a first load, is governed by asynchronous index build strategies, which is nearly always cheaper than maintaining the vector index row-by-row during ingest. When the writer runs inside a concurrent async consumer pool, keep the transaction short and never straddle an embedding-API call, per async processing with Python asyncio.

Validation & Recall Testing

A batch upsert can succeed at the protocol level and still be wrong — duplicated rows under a missing constraint, silent no-ops from a mis-scoped conflict target, or an update that rewrote every row because the hash guard was bypassed. Validate on three axes.

Insert-vs-update accounting. Use the xmax trick from the diagnostic section as a first-class metric per batch. On a steady incremental load you expect mostly updates for existing documents and a trickle of inserts; a batch that reports 100% inserts on a re-run means the conflict target is not matching (wrong columns, or a constraint that does not exist), and you are accumulating duplicates.

Row-count completeness. Every staged row must resolve to exactly one target row per key. Detect duplicates that would only exist if COPY had been used without a constraint:

SQL
SELECT doc_id, chunk_index, count(*)
FROM   doc_embeddings
GROUP  BY doc_id, chunk_index
HAVING count(*) > 1;

Any rows returned mean the uniqueness guarantee failed — usually a raw COPY path that skipped the merge.

Idempotency under replay. Run the identical batch twice and assert that the second run moves updated_at only for genuinely changed content. A second run that touches every row proves the content_hash IS DISTINCT FROM guard is not firing and you are burning WAL for nothing.

Plan verification. Confirm the merge itself is set-based and not degenerating into a per-row plan, and that the target’s vector index is used at query time rather than a sequential scan:

SQL
EXPLAIN (ANALYZE, BUFFERS)
INSERT INTO doc_embeddings AS t (doc_id, chunk_index, content_hash, embedding, updated_at)
SELECT doc_id, chunk_index, content_hash, embedding, now() FROM staging
ON CONFLICT (doc_id, chunk_index) DO UPDATE
   SET embedding = EXCLUDED.embedding, updated_at = now();

A healthy plan shows a single Insert on doc_embeddings node fed by a Seq Scan on staging with a Conflict Resolution: UPDATE line — one set operation, not thousands. For retrieval quality after ingest, compare the stored vectors against a ground-truth set with a Python recall-at-k check that computes exact nearest neighbors in numpy and measures overlap with the index’s answers; a recall gap points back to the ingest layer (normalization or dimension drift), not the index.

Failure Modes & Gotchas

  • Deadlocks on concurrent overlapping upserts. Two writers that touch the same (doc_id, chunk_index) keys in different orders will deadlock as each holds a row lock the other wants. The fix is deterministic ordering: ORDER BY doc_id, chunk_index in every merge so all writers acquire locks in the same sequence. Partition batches by a hash of doc_id so distinct workers rarely touch the same keys at all.
  • WAL and checkpoint storms. Thousands of small autocommitted upserts force frequent checkpoints and can saturate WAL, stalling the whole cluster. Batch writes, use the UNLOGGED staging landing (no WAL), and raise max_wal_size before a bulk window so PostgreSQL is not forced into a checkpoint mid-load.
  • Index bloat from DO UPDATE churn. Each ON CONFLICT DO UPDATE creates a dead tuple; on a high-churn table the HNSW/IVFFlat index accumulates dead entries faster than default autovacuum reclaims them, inflating index size and slowing scans. Lower autovacuum_vacuum_scale_factor on the table and monitor n_dead_tup; the content-hash guard is your first line of defense because it prevents the update entirely when nothing changed.
  • TOAST overhead on the write path. A 1536-dim float4 vector is ~6 KB — above the ~2 KB threshold at which PostgreSQL pushes values out of line into TOAST. High-dimensional vectors mean every insert also writes a TOAST tuple, roughly doubling the write amplification. COPY’s binary path minimizes the client-side cost, but the storage write remains; halving dimension width with halfvec where model quality permits is the durable mitigation.
  • COPY with no constraint silently duplicates. COPY into a table lacking a unique constraint on the conflict key appends duplicates without error. Always define the primary key first; never use bare COPY as an incremental upsert.
  • Registering the vector type on the wrong connection. Under a connection pool, register_vector must run on each physical connection; a pooled connection that never had it registered raises an adaptation error or writes a text literal that fails to parse. Wire registration into the pool’s connection-init hook rather than calling it once at startup.

Monitoring & Alerting Hooks

Instrument both the write outcome and the database’s response to it. The most actionable application-side metric is the insert/update ratio per batch, exported straight from the RETURNING (xmax = 0) accounting so a load that flips to all-inserts (broken idempotency) or all-updates (churning unchanged rows) pages immediately.

On the database side, watch dead-tuple accumulation and WAL generation from the ingestion role:

SQL
SELECT relname,
       n_tup_ins, n_tup_upd, n_dead_tup,
       round(n_dead_tup::numeric / nullif(n_live_tup, 0), 3) AS dead_ratio,
       last_autovacuum
FROM   pg_stat_user_tables
WHERE  relname IN ('doc_embeddings');

Alert when dead_ratio exceeds ~0.2 (autovacuum is losing to churn) and when last_autovacuum falls far behind a heavy load window. Track WAL pressure with the delta of pg_current_wal_lsn() over time, and lock contention with a standing query against pg_stat_activity filtered to wait_event_type = 'Lock' for the ingest application_name — a rising count there is the early signal of the deadlock/serialization pattern above. Finally, chart throughput as the delta of pg_stat_user_tables.n_tup_ins + n_tup_upd; a flat line while batches are still submitting means writes are blocked on locks or checkpoints, not merely slow.

FAQ

Q: When is a plain COPY acceptable instead of the staging-merge?

Only when every key in the batch is guaranteed new — a first load into an empty table or a freshly created partition, where there is nothing to conflict with. In that case COPY … FORMAT BINARY straight into the target is the fastest option and there is no duplication risk. The moment re-ingestion or overlapping batches enter the picture, you need the conflict handling, and the staging-merge gives you nearly the same speed while keeping it.

Q: Why an UNLOGGED staging table rather than TEMP?

Both avoid WAL on the landing, but they differ operationally. A TEMP table is private to the session and vanishes on disconnect, which is ideal for a single long-lived worker. An UNLOGGED table is shared and persists across connections, which suits a pool of workers that each COPY into the same staging area before a coordinator runs the merge. Either eliminates the WAL and index cost of the bulk landing; choose by whether the staging area must be visible across connections.

Q: How large should each batch be?

Start at 1,000–5,000 rows for 1536-dim vectors and tune from there. Larger batches amortize WAL flushes and round-trips but hold locks longer, buffer more memory (roughly rows × 6 KB), and raise deadlock odds under concurrency. If you see lock waits or deadlocks, halve the batch and add deterministic ordering before growing it again. There is no universal optimum — it depends on vector width, concurrency, and how much churn the merge produces.

Q: Does ON CONFLICT DO UPDATE maintain the HNSW index on every row?

Yes. An update that changes the embedding column is, for the vector index, a delete-plus-insert of an index entry, and that maintenance happens per affected row. This is exactly why the staging-merge is faster: it batches all the index maintenance into one set operation and, with the content_hash guard, skips it entirely for unchanged chunks. Deferring the index build itself until after a first bulk load is cheaper still.

Q: How do I count how many rows were inserted versus updated?

Use the system column xmax. On an INSERT … ON CONFLICT, a genuinely inserted row has xmax = 0 and an updated row has a non-zero xmax. Add RETURNING (xmax = 0) AS inserted and aggregate with count(*) FILTER (WHERE inserted). Exporting that ratio per batch is the cleanest way to prove your incremental load is behaving.