sql python 75 lines · 3 tabs

Batched writes with COPY (conceptual)

Shared by codesnips Jan 2026
3 tabs
CREATE TABLE events (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    source      text        NOT NULL,
    external_id text        NOT NULL,
    payload     jsonb       NOT NULL,
    occurred_at timestamptz NOT NULL,
    updated_at  timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT events_natural_key UNIQUE (source, external_id)
);

CREATE INDEX events_occurred_at_idx ON events (occurred_at);
3 files · sql, python Explain with highlit

Loading millions of rows one INSERT at a time is dominated by per-statement overhead: parsing, planning, WAL flushes, and round trips. Postgres offers a much faster path with COPY, which streams rows in a compact protocol and writes them in large batches. These files show the standard pattern for a reliable bulk load that also handles duplicates and conflicting updates, since raw COPY alone cannot express ON CONFLICT.

The first tab, schema.sql, defines the durable target events table with a natural key on source plus external_id, backed by a UNIQUE constraint. That constraint is what makes the later upsert idempotent: re-running a load with overlapping rows will not create duplicates. It also adds an updated_at column so the merge step can record when a row last changed.

The second tab, load_events.sql, is the core of the batched-write technique. It creates an UNLOGGED staging table with CREATE TEMP TABLE ... ON COMMIT DROP, which skips WAL entirely for the scratch data. Rows are streamed in with COPY events_staging FROM STDIN, the fastest ingestion primitive in Postgres. Once the raw data lands, a single INSERT ... SELECT ... ON CONFLICT DO UPDATE merges the whole batch into events in one set-based statement. The DISTINCT ON (source, external_id) guards against duplicates within the same batch, which ON CONFLICT cannot resolve on its own because a command cannot hit the same conflict target twice. The WHERE events.payload IS DISTINCT FROM excluded.payload clause avoids no-op writes, so unchanged rows do not generate dead tuples or bump updated_at.

The third tab, bulk_load.py, drives the flow from an application using psycopg. It wraps the temp-table creation, the copy stream, and the merge in one transaction so a failure rolls back cleanly and leaves events untouched. Using cursor.copy with binary-safe row iteration keeps memory flat regardless of file size.

The trade-off is that data briefly lives in an unindexed staging area, and the merge scans it fully, so extremely small batches are not worth the ceremony. For large loads the win is dramatic. This pattern is the go-to for ETL, backfills, and ingesting external feeds where both speed and idempotent upserts matter.


Related snips

Share this code

Here's the card — post it anywhere.

Batched writes with COPY (conceptual) — share card
Link copied