asyncpg bulk COPY vs executemany benchmarks
Inserting a million parsed customs-document rows through asyncpg’s executemany can take minutes, while the same rows through copy_records_to_table land in seconds — but only if you handle type coercion correctly, because COPY binds values differently than the parameterized path and will reject a str where executemany quietly accepted it. This page benchmarks the two insert methods head to head for high-volume ingestion of parsed invoice and manifest lines: throughput in rows per second, peak resident memory, and the coercion rules copy_records_to_table enforces on Decimal, date, and nullable columns. It ends with a batch-sizing rule so you can size the COPY chunk to your worker rather than guessing. This is a comparison of two methods within one driver, not a driver-versus-driver shootout.
Prerequisites
This benchmark assumes a specific database and record shape. Confirm each before running it:
- PostgreSQL 13+ reachable over
asyncpg0.27+.copy_records_to_tableand itsrecordsiterable protocol are stable in these versions; older builds differ in how they stream and in error reporting. - Rows already parsed into native Python types. The document parsers upstream must emit
Decimalfor money and rates,datetime.datefor dates, andNonefor nulls — not strings.COPYin binary mode binds by type, so a stray"12.50"string into a numeric column raises where the parameterized path silently cast it. The queue that feeds these rows is described in Implementing async queues for bulk customs docs. - A target table with a known column order.
copy_records_to_tablematches tuple position to thecolumns=list, not by name. An off-by-one in that list silently writes the entered value into the weight column. Pin the order once and share it between the parser and the loader. - A benchmark table you can
TRUNCATEbetween runs. Never benchmark into a table with live indexes and triggers you cannot reset; a leftover unique index turns run two into a constraint-violation profile, not a throughput profile. - Structured logging configured (stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat, orstructlog) so each run’s rows-per-second and byte count are recorded for later comparison.
Implementation
The harness generates a deterministic batch of parsed customs-document rows, then times both insert paths against the same rows and the same connection settings. executemany sends one parameterized INSERT per row over the extended-query protocol; copy_records_to_table streams the whole batch through PostgreSQL’s COPY machinery in one pass.
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Iterable, Optional, Sequence
import asyncpg
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("asyncpg_copy_bench")
COLUMNS: tuple[str, ...] = (
"doc_id", "line_no", "hts_code", "description",
"quantity", "unit_price", "entered_value", "entry_date",
)
DDL = """
CREATE TABLE IF NOT EXISTS parsed_doc_line (
doc_id text NOT NULL,
line_no integer NOT NULL,
hts_code text,
description text,
quantity integer NOT NULL,
unit_price numeric(14,4) NOT NULL,
entered_value numeric(14,2) NOT NULL,
entry_date date NOT NULL
);
"""
@dataclass(frozen=True)
class DocLine:
doc_id: str
line_no: int
hts_code: Optional[str]
description: str
quantity: int
unit_price: Decimal # Decimal, never float — numeric(14,4)
entered_value: Decimal
entry_date: date
def as_record(self) -> tuple:
# Position must match COLUMNS exactly; COPY binds by position.
return (
self.doc_id, self.line_no, self.hts_code, self.description,
self.quantity, self.unit_price, self.entered_value, self.entry_date,
)
@dataclass(frozen=True)
class BenchResult:
method: str
rows: int
wall_seconds: Decimal
rows_per_sec: Decimal
def make_batch(n: int) -> list[DocLine]:
"""Deterministic parsed rows with native types — the COPY-ready contract."""
unit = Decimal("12.5000")
return [
DocLine(
doc_id=f"DOC-{i // 20:06d}",
line_no=i % 20,
hts_code=None if i % 97 == 0 else "8517620000",
description=f"line item {i}",
quantity=i % 50 + 1,
unit_price=unit,
entered_value=(unit * (i % 50 + 1)).quantize(Decimal("0.01")),
entry_date=date(2026, 7, 16),
)
for i in range(n)
]
async def bench_executemany(
conn: asyncpg.Connection, rows: Sequence[DocLine]
) -> BenchResult:
stmt = (
"INSERT INTO parsed_doc_line "
"(doc_id, line_no, hts_code, description, quantity, "
" unit_price, entered_value, entry_date) "
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8)"
)
payload = [r.as_record() for r in rows]
await conn.execute("TRUNCATE parsed_doc_line")
start = time.perf_counter()
await conn.executemany(stmt, payload) # one bind+exec per row
return _result("executemany", len(rows), start)
async def bench_copy(
conn: asyncpg.Connection, rows: Sequence[DocLine], *, batch: int = 50_000
) -> BenchResult:
await conn.execute("TRUNCATE parsed_doc_line")
start = time.perf_counter()
for chunk in _chunked(rows, batch):
await conn.copy_records_to_table(
"parsed_doc_line",
records=(r.as_record() for r in chunk), # streamed, not listed
columns=list(COLUMNS),
)
return _result("copy_records", len(rows), start)
def _chunked(rows: Sequence[DocLine], size: int) -> Iterable[Sequence[DocLine]]:
for i in range(0, len(rows), size):
yield rows[i : i + size]
def _result(method: str, n: int, start: float) -> BenchResult:
wall = Decimal(str(time.perf_counter() - start)).quantize(Decimal("0.001"))
rps = (Decimal(n) / wall).quantize(Decimal("1")) if wall else Decimal(0)
res = BenchResult(method, n, wall, rps)
logger.info(
"method=%-13s rows=%d wall=%ss rows_per_sec=%s",
res.method, res.rows, res.wall_seconds, res.rows_per_sec,
)
return res
async def main(dsn: str, n: int = 500_000) -> None:
conn = await asyncpg.connect(dsn)
try:
await conn.execute(DDL)
rows = make_batch(n)
em = await bench_executemany(conn, rows)
cp = await bench_copy(conn, rows)
speedup = (cp.rows_per_sec / em.rows_per_sec).quantize(Decimal("0.1"))
logger.info("copy_records is %sx executemany throughput", speedup)
finally:
await conn.close()
if __name__ == "__main__":
import os
asyncio.run(main(os.environ["CUSTOMS_DSN"]))
copy_records_to_table accepts a generator for records, so it never materializes a second copy of the batch the way the executemany payload list does. That single difference is why COPY wins on memory as well as throughput — it streams tuples straight into the wire protocol.
copy_records_to_table streams the batch in one pass and far outpaces per-row executemany — provided every value arrives as a native Decimal, date, or None in the exact column order.Verification steps
Run these checks before adopting a method or a batch size in production:
- Row-count parity. After each method,
SELECT count(*) FROM parsed_doc_linemust equaln. A COPY that silently dropped a chunk on a swallowed exception will show a shortfall here, not an error at the call site. - Confirm the throughput gap is real, not warm-cache noise. Run each method three times, discard the first, and compare medians. If
copy_recordsis not several times faster, check thatexecutemanyis not benefiting from a prepared-statement cache that would not exist on a cold connection pool. - Type-coercion negative test. Feed one row with
unit_price="12.50"as a string.executemanymay cast it;copy_records_to_tablemust raise. If COPY accepts the string silently, you are on a text-mode fallback that defeats binary binding — investigate before trusting any number it wrote. - Null handling. Confirm rows with
hts_code=Noneland as SQLNULL, not the literal textNone. Then confirm aNULLin theNOT NULLentered_valuecolumn is rejected by both methods identically. A gap here means the parser is emitting placeholders that only one path catches. - Column-order guard. Deliberately swap two entries in the
columns=list and confirm the resulting data is visibly wrong (entered value in the quantity column). This proves your schema tests would catch a real position drift; then restore the order. - Peak-memory comparison. Sample resident-set size during each run. The
executemanypayload list holds a full second copy of the batch; the COPY generator should show a materially lower peak. If they match, something is materializing the COPY generator into a list before the call. - Batch-size sweep. Run
bench_copyat 10k, 50k, and 100k row chunks. Throughput should rise then plateau while memory rises linearly; pick the smallest chunk on the plateau so a retry re-sends less work.
Edge cases & gotchas
- COPY binds by position, not by name.
copy_records_to_tablemaps tuple index to thecolumns=list. There is no field-name safety net, so a parser change that reorders its output tuple corrupts every row without an error. Build the record tuple from a single shared column definition, and assert its length equalslen(COLUMNS)before the call. - String-typed numbers are the classic COPY failure. The parameterized path forgives
"12.50"into a numeric column; binary COPY does not. Coerce toDecimalat parse time, never at load time, so the same rows that passexecutemanydo not surprise you when you switch to COPY. executemanyis not one round trip. It is convenient but still binds and executes per row under the hood, so it does not approach COPY throughput no matter the batch size. Reach for it only for small batches or where you need per-rowRETURNING, which COPY cannot give you.- COPY bypasses row triggers and
RETURNING. If your table depends onBEFORE INSERTtriggers to fill columns, or you need generated ids back, COPY will not run them. Move that logic into the parser or a post-loadUPDATE, or the switch to COPY silently changes semantics. - A single bad row fails the whole COPY chunk. COPY is all-or-nothing per call. One un-coercible value rolls back the entire chunk, so validate rows before the call and route rejects to a dead-letter path rather than losing a 50k batch to one malformed line — the same resilience posture used by Async Batch Processing for High Volume.
- Batch size trades throughput against retry cost. Bigger chunks amortize protocol overhead but make a failed chunk more expensive to re-send and hold a larger transaction lock. Size the chunk to leave worker-memory headroom and to keep a retry cheap, and treat that size as part of the ingestion contract so a re-run splits the work identically.
- Idempotency lives above the insert. Neither method deduplicates. A retried batch double-inserts unless the table carries a unique key and you COPY into a staging table then upsert, or you make the whole batch a single transaction that a retry can safely repeat. Decide this before benchmarking, because a unique index changes the throughput numbers.