asyncpg vs psycopg3 for HTS schedule bulk upserts
Choosing a PostgreSQL driver for HTS schedule loads is a throughput decision with a compliance tail: a full HTSUS revision is roughly 20,000 statistical-suffix rows, and each quarterly or mid-year release must land as an atomic, deduplicated upsert before any entry references it. This page benchmarks the two drivers customs ETL teams actually reach for — asyncpg and psycopg3 — across the three bulk-load paths that matter (server-side COPY, executemany, and execute_values-style batched inserts), and it gives you a runnable harness plus a decision matrix so you can pick a driver on measured throughput and memory rather than folklore. The short version: asyncpg’s copy_records_to_table wins raw ingest speed for staging, psycopg3’s Copy plus pipeline mode wins ergonomics and a cleaner INSERT ... ON CONFLICT path, and the right answer depends on whether your loader is already async and how much of the upsert logic you want in SQL versus Python. Both feed the bitemporal schema described in HTS Schedule Database Design.
Prerequisites
This comparison assumes a specific staging pattern and toolchain. Confirm each before running the harness:
- PostgreSQL 14+ with a staging table and a target table. The canonical bulk-upsert pattern is COPY into an unlogged staging table, then a single
INSERT ... SELECT ... ON CONFLICT DO UPDATEinto the target — never per-row upserts across the wire.COPYcannot expressON CONFLICT, so staging is not optional for either driver. - Python 3.10+ with
asyncpg>=0.29andpsycopg[binary,pool]>=3.1. The harness usesDecimalforgeneral_rateanddataclass(frozen=True)rows; never usefloatfor a duty rate, because ISO 4217 minor-unit rounding is not representable in binary floating point. - A normalized row shape. Keys are digit-only HTS codes (strip dots on write, exactly as the resolver in Handling missing HTS codes in ETL pipelines does on read) so lookups and conflict targets align. A mixed
8517.62.0000/8517620000corpus silently splits into duplicate rows. - A parsed release in memory or on disk produced upstream by Tariff Update Ingestion Pipelines. This page loads an already-parsed release; it does not parse the WCO or USITC source.
- Structured logging configured (stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat) so per-path timing and row counts are greppable for audit.
Implementation
The harness below defines one immutable row type and one loader per path, then times each against the same batch. Every loader ends in the same idempotent INSERT ... ON CONFLICT DO UPDATE, so the only variable is how bytes reach the staging table. Read stage_asyncpg_copy and stage_psycopg_copy side by side — that pair is the whole benchmark.
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Sequence
import asyncpg
import psycopg
from psycopg import sql
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("hts_bulk_upsert")
DDL = """
CREATE UNLOGGED TABLE IF NOT EXISTS hts_stage (
hts_code text NOT NULL,
description text NOT NULL,
general_rate numeric(8,4) NOT NULL,
effective_date date NOT NULL,
expiry_date date
);
CREATE TABLE IF NOT EXISTS hts_schedule (
hts_code text NOT NULL,
effective_date date NOT NULL,
description text NOT NULL,
general_rate numeric(8,4) NOT NULL,
expiry_date date,
PRIMARY KEY (hts_code, effective_date)
);
"""
# Single idempotent merge, identical for both drivers. COPY cannot do ON CONFLICT,
# so every path stages first, then runs exactly this statement once.
MERGE = """
INSERT INTO hts_schedule (hts_code, effective_date, description, general_rate, expiry_date)
SELECT hts_code, effective_date, description, general_rate, expiry_date FROM hts_stage
ON CONFLICT (hts_code, effective_date) DO UPDATE
SET description = EXCLUDED.description,
general_rate = EXCLUDED.general_rate,
expiry_date = EXCLUDED.expiry_date;
"""
@dataclass(frozen=True)
class HtsRow:
hts_code: str # digit-only, e.g. "8517620000"
description: str
general_rate: Decimal # Decimal, never float
effective_date: date
expiry_date: date | None = None
def as_tuple(self) -> tuple:
return (self.hts_code, self.description, self.general_rate,
self.effective_date, self.expiry_date)
def _records(rows: Sequence[HtsRow]) -> list[tuple]:
# COPY column order for hts_stage.
return [(r.hts_code, r.description, r.general_rate,
r.effective_date, r.expiry_date) for r in rows]
# --- asyncpg: binary COPY straight from Python records ---------------------
async def stage_asyncpg_copy(dsn: str, rows: Sequence[HtsRow]) -> float:
conn = await asyncpg.connect(dsn)
try:
await conn.execute("TRUNCATE hts_stage")
t0 = time.perf_counter()
# copy_records_to_table streams the binary protocol — no SQL text per row,
# no client-side buffering of a giant VALUES string.
await conn.copy_records_to_table(
"hts_stage",
records=_records(rows),
columns=["hts_code", "description", "general_rate",
"effective_date", "expiry_date"],
)
await conn.execute(MERGE)
elapsed = time.perf_counter() - t0
finally:
await conn.close()
logger.info("asyncpg COPY+merge: %d rows in %.3fs", len(rows), elapsed)
return elapsed
# --- asyncpg: executemany (prepared, per-row round trips) -------------------
async def stage_asyncpg_executemany(dsn: str, rows: Sequence[HtsRow]) -> float:
conn = await asyncpg.connect(dsn)
try:
await conn.execute("TRUNCATE hts_stage")
t0 = time.perf_counter()
await conn.executemany(
"INSERT INTO hts_stage VALUES ($1,$2,$3,$4,$5)", _records(rows)
)
await conn.execute(MERGE)
elapsed = time.perf_counter() - t0
finally:
await conn.close()
logger.info("asyncpg executemany+merge: %d rows in %.3fs", len(rows), elapsed)
return elapsed
# --- psycopg3: Copy via the block protocol ---------------------------------
def stage_psycopg_copy(dsn: str, rows: Sequence[HtsRow]) -> float:
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("TRUNCATE hts_stage")
t0 = time.perf_counter()
copy_sql = sql.SQL("COPY hts_stage ({}) FROM STDIN").format(
sql.SQL(", ").join(map(sql.Identifier,
["hts_code", "description", "general_rate",
"effective_date", "expiry_date"]))
)
with cur.copy(copy_sql) as copy:
for rec in _records(rows):
copy.write_row(rec) # streams row-by-row, constant memory
cur.execute(MERGE)
conn.commit()
elapsed = time.perf_counter() - t0
logger.info("psycopg3 COPY+merge: %d rows in %.3fs", len(rows), elapsed)
return elapsed
# --- psycopg3: executemany with pipeline mode ------------------------------
def stage_psycopg_pipeline(dsn: str, rows: Sequence[HtsRow]) -> float:
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("TRUNCATE hts_stage")
t0 = time.perf_counter()
# pipeline() removes the per-round-trip latency that cripples naive
# executemany; still slower than COPY but far closer than without it.
with conn.pipeline():
cur.executemany(
"INSERT INTO hts_stage VALUES (%s,%s,%s,%s,%s)", _records(rows)
)
cur.execute(MERGE)
conn.commit()
elapsed = time.perf_counter() - t0
logger.info("psycopg3 pipeline+merge: %d rows in %.3fs", len(rows), elapsed)
return elapsed
async def run_matrix(dsn: str, rows: Sequence[HtsRow]) -> None:
await stage_asyncpg_copy(dsn, rows)
await stage_asyncpg_executemany(dsn, rows)
stage_psycopg_copy(dsn, rows)
stage_psycopg_pipeline(dsn, rows)
if __name__ == "__main__":
sample = [
HtsRow(f"{85176200 + i:08d}00", f"Apparatus {i}",
Decimal("0.0000"), date(2024, 1, 1))
for i in range(20_000)
]
asyncio.run(run_matrix("postgresql://localhost/customs", sample))
The load-bearing detail is that COPY never carries the upsert. Both copy_records_to_table (asyncpg) and cur.copy(...) (psycopg3) stream rows into unlogged staging at near-wire speed; the deduplication and rate-update logic lives entirely in the one MERGE statement, which runs once per release. That separation is what makes the load idempotent — re-running the same release is a no-op update, exactly the property Point-in-time HTS resolution with recursive CTEs relies on when it walks the effective-dated history.
ON CONFLICT merge; the split is asyncpg's raw COPY edge and async-only model versus psycopg3's dual sync/async model and SQL-composition helpers.Verification steps
Run these checks against a representative release before standardizing on a driver:
- Warm-cache timing parity. Run
run_matrixthree times and discard the first (connection and prepared-statement warm-up). Compare the median COPY-path times; on a 20,000-row HTSUS release asyncpg’scopy_records_to_tableshould lead psycopg3’scur.copyby a modest margin, and both should beat their respectiveexecutemanypaths by an order of magnitude. A tie between COPY and executemany means the merge, not the load, is your bottleneck. - Row-count identity. After each loader, assert
SELECT count(*) FROM hts_scheduleequals the distinct(hts_code, effective_date)count in the source. A shortfall means duplicate keys collapsed; an excess means dots were not stripped and8517.62.0000split from8517620000. - Idempotency. Run the same loader twice against the same release. The second run must change zero rows semantically — capture
xmaxor aRETURNINGcount and confirm the second pass is a pure update, never an insert. This is the property downstream point-in-time resolution depends on. - Memory ceiling. Profile each loader with
tracemallocor/usr/bin/time -v. The COPY and pipeline paths must hold roughly constant resident memory as row count grows; if psycopg3’s executemany withoutpipeline()balloons, you left the pipeline context out. - Decimal fidelity. Round-trip one row with
general_rate = Decimal("0.0350")and assert the value read back isDecimal("0.0350"), not0.035000000001. A drift here means afloatwas introduced between parse and load. - Transaction atomicity. Kill the process between COPY and MERGE. The target table must be unchanged (staging is unlogged and discarded); confirm no half-loaded release is visible to readers.
Edge cases & gotchas
COPYcannot upsert — do not fake it. Neither driver’s COPY acceptsON CONFLICT. Teams sometimes pre-delete conflicting keys before COPY to dodge staging; that opens a window where an entry could resolve against a missing rate. Always stage-then-merge in one transaction.- asyncpg binds by position, psycopg3 by placeholder. asyncpg uses
$1,$2; psycopg3 uses%s. Copy-pasting a query between the two silently breaks parameter binding, and a mis-boundnumeric(8,4)can truncate a rate rather than error. Keep the SQL strings driver-specific. - Type adaptation differs for
numeric. asyncpg maps PostgreSQLnumerictoDecimalnatively; psycopg3 does too, but only if you have not registered a custom loader that coerces tofloat. Verify the adapter, because a coerced rate corrupts duty math under 19 CFR reasonable care. - Naive
executemanyis a latency trap. asyncpg’sexecutemanyand psycopg3’s withoutpipeline()pay one network round trip per row — fine for 50 rows, catastrophic for 20,000. If you cannot use COPY (for example, an ORM in the way), psycopg3’spipeline()recovers most of the gap; asyncpg has no equivalent knob, so reach for COPY there. - Unlogged staging is not crash-durable — that is the point. An unlogged
hts_stageis fast because it skips WAL, but it is truncated after an unclean shutdown. Never point a reader at staging; it exists only to feed the merge. The durable copy is the target table. - Connection pooling changes the picture at concurrency. These single-connection benchmarks measure the load path, not a live service. Under a pool (
asyncpg.create_poolorpsycopg_pool), psycopg3’s ability to serve sync and async callers from one pool can outweigh asyncpg’s per-load speed when the same service also reads schedules. Benchmark the workload you actually run, not just the bulk import.