Benchmarking asyncpg vs psycopg throughput
Benchmark Core statements on a warmed pool from a host with production-like latency, record every operation rather than a total, alternate the drivers, and measure four workloads separately — small lookups, large result sets, bulk inserts and concurrent mixed load — because the drivers differ by workload rather than overall. This guide belongs to choosing between asyncpg and psycopg async drivers.
Quick Answer
Most driver benchmarks measure something else: the ORM, the web framework, or the machine the benchmark ran on. A useful harness holds everything except the driver constant.
Before — timing an endpoint and attributing the difference to the driver:
import time
import httpx
start = time.perf_counter()
for _ in range(100):
httpx.get("http://localhost:8000/orders?limit=50")
print(time.perf_counter() - start)
# Measures routing, auth, ORM hydration, JSON serialisation and the driver,
# then reports one number with no distribution.
After — one statement, warmed pool, per-operation timings:
import asyncio
import statistics
import time
from collections.abc import Awaitable, Callable
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
LOOKUP = text("SELECT id, sku, price_cents FROM products WHERE id = :id")
async def warm(engine: AsyncEngine, pool_size: int) -> None:
"""Open every pooled connection so setup is not part of the measurement."""
conns = [await engine.connect() for _ in range(pool_size)]
for conn in conns:
await conn.execute(text("SELECT 1"))
for conn in conns:
await conn.close()
async def measure(engine: AsyncEngine, runs: int = 2_000) -> list[float]:
timings: list[float] = []
async with engine.connect() as conn:
for i in range(200): # warm-up, discarded
await conn.execute(LOOKUP, {"id": (i % 1000) + 1})
for i in range(runs):
started = time.perf_counter()
await conn.execute(LOOKUP, {"id": (i % 1000) + 1})
timings.append((time.perf_counter() - started) * 1000)
return timings
async def run(driver: str, runs: int) -> dict[str, float]:
engine = create_async_engine(
f"postgresql+{driver}://shop:secret@db/shop", pool_size=5, max_overflow=0
)
try:
await warm(engine, pool_size=5)
timings = await measure(engine, runs)
finally:
await engine.dispose()
quantiles = statistics.quantiles(timings, n=100)
return {"driver": driver, "p50": quantiles[49], "p95": quantiles[94],
"p99": quantiles[98], "n": len(timings)}
async def main() -> None:
for _ in range(3): # alternate so drift cancels out
for driver in ("asyncpg", "psycopg"):
print(await run(driver, runs=2_000))
asyncio.run(main())
Reporting p50, p95 and p99 rather than a mean is what makes the result readable: driver differences show up in the median, while the tail is usually the machine or the database.
Execution Context & Async Workflow Integration
A query's wall-clock time is the sum of several costs, and only some of them belong to the driver: building the statement in SQLAlchemy, encoding parameters, the network round trip, the server's parse-plan-execute, the network return, decoding rows, and — for ORM queries — hydrating objects. A driver benchmark has to make the driver-attributable parts visible against the rest.
Three of those parts dominate in different situations, which is why one number is never enough.
Round trips dominate small queries. A primary-key lookup against a database one millisecond away spends most of its time waiting. Both drivers wait identically, so the measured difference is a few percent of a small number. Any benchmark showing a large difference here is measuring something else — most often connection setup, because a pool that was not warmed opens connections during the run.
Decoding dominates large reads. Turning a hundred thousand rows into Python objects is CPU work in the driver, and it is where asyncpg's compiled, binary protocol implementation is genuinely faster. The gap scales with rows and with column count, and it is larger for types with expensive codecs — numeric, timestamptz, arrays and JSON.
Round-trip count dominates bulk writes. Here psycopg 3's pipeline mode changes the picture: executemany sends many statements without waiting for each result, which can make it faster than asyncpg for inserts. And both are beaten by COPY, which is a different mechanism rather than a faster driver — see loading rows with Postgres COPY through asyncpg.
Two measurement decisions follow from this. Use Core statements, not ORM queries, when comparing drivers: ORM hydration is identical for both and dilutes the difference, so including it answers a different question. And run from a host whose latency to the database resembles production. Benchmarking over loopback exaggerates the decoding difference, because the waiting that normally hides it has been removed.
The pool must be warm before the measured pass. create_async_engine opens nothing until first use, so the first few operations of an unwarmed run include a TCP handshake, TLS negotiation and authentication — tens of milliseconds that swamp everything else, and that differ between drivers for reasons unrelated to query throughput.
Resolving Warnings, Errors & Common Mistakes
| Symptom | Root Cause | Production Fix |
|---|---|---|
| One driver looks dramatically faster on the first run | The pool was not warmed, so connection setup was measured. | Open and exercise every connection before timing. |
| Results change between runs by more than the difference being measured | Other load on the machine, or the database on the same CPU. | Alternate drivers, repeat, and compare medians across runs. |
| psycopg looks far slower than expected | The pure-Python implementation is installed. | pip install "psycopg[binary]"; check psycopg.pq.__impl__. |
| asyncpg looks slower on repeated identical queries | Statement caching disabled for PgBouncer compatibility. | Compare like with like: disable it for both, or neither. |
| A benchmark of 100,000-row reads runs out of memory | Everything buffered client-side. | Stream with yield_per, and measure streaming for both drivers. |
| Numbers do not match production at all | Loopback network, cold cache, tiny dataset. | Production-like latency, warmed cache, realistic row widths. |
TimeoutError from the pool during the run | Concurrency above pool_size with max_overflow=0. | Match the pool to the concurrency being tested, deliberately. |
The statement-cache asymmetry is the one that most often produces a misleading comparison. asyncpg prepares and caches every statement by default; psycopg prepares after five executions. A benchmark that disables asyncpg's cache for PgBouncer compatibility — prepared_statement_cache_size=0 — while leaving psycopg's default in place is comparing a driver that re-parses every statement with one that does not. Decide which configuration matches production, and use the equivalent for both:
ASYNCPG_ARGS = {"statement_cache_size": 0, "prepared_statement_cache_size": 0}
PSYCOPG_ARGS = {"prepare_threshold": None} # the equivalent setting
Concurrency deserves its own measurement rather than being an accident of the harness. A sequential loop measures per-statement cost; a concurrent run measures the pool, the event loop and the database's ability to serve parallel work, which is closer to what a service experiences:
import asyncio
async def concurrent_measure(engine, concurrency: int, per_task: int) -> float:
async def worker() -> None:
async with engine.connect() as conn:
for i in range(per_task):
await conn.execute(LOOKUP, {"id": (i % 1000) + 1})
started = time.perf_counter()
async with asyncio.TaskGroup() as tg:
for _ in range(concurrency):
tg.create_task(worker())
return time.perf_counter() - started
Keep concurrency at or below pool_size, or the measurement includes time queued in the pool — which is a real cost, but not a driver cost. The same distinction matters when sizing pools, as in setting up asyncpg pool size for high concurrency.
Advanced: Attributing Time to Server, Network and Client
Knowing that one driver is faster is less useful than knowing where the time goes, because that tells you whether a driver change would help at all. Three measurements split the wall clock.
Server time comes from the database. EXPLAIN ANALYZE reports execution time for a statement, and pg_stat_statements reports the mean across real traffic. If the server accounts for most of the wall clock, no driver will help, and the work belongs in the query or an index.
Round-trip time is measurable with the cheapest possible statement. SELECT 1 does almost no server work and no decoding, so its median is a good estimate of the fixed cost per statement:
async def round_trip_ms(engine) -> float:
timings = []
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
for _ in range(500):
started = time.perf_counter()
await conn.execute(text("SELECT 1"))
timings.append((time.perf_counter() - started) * 1000)
return statistics.median(timings)
Client decoding time is what is left. Run the same query twice, once selecting the full rows and once selecting count(*) over the same predicate: the server does comparable work in both, while only the first decodes rows. The difference approximates decoding and transfer.
FULL = text("SELECT * FROM products WHERE brand = :brand")
COUNT = text("SELECT count(*) FROM products WHERE brand = :brand")
Putting the three together turns a driver decision into arithmetic. A service whose statements are ninety percent server time and round trip will not notice a driver change. One that pulls large result sets into Python — an export, a report, a data pipeline — may notice a lot, and that is exactly the workload where asyncpg's decoding advantage applies.
The listener-based instrumentation used in production is the more honest source for the first two numbers, because it measures the real statement mix rather than a synthetic one. Logging slow async queries with SQLAlchemy events shows how to record driver execution time per statement, and comparing that distribution before and after a driver change on a canary instance is worth more than any benchmark harness.
Turning the Numbers Into a Decision
A benchmark answers "how much faster", and the decision needs "does that matter here, and what does it cost". Four questions usually settle it.
Where does the service spend its database time? If the answer is thousands of small queries, the drivers are close and the decision should be made on other grounds — one driver for sync and async, feature needs, operational familiarity. If the answer is a handful of large reads, decoding matters and asyncpg's advantage is real.
Is the bottleneck even the driver? A service at ten percent CPU whose p95 is dominated by server time has nothing to gain. Measure before migrating: a driver change touches connection configuration, error handling and datetime behaviour, as migrating from psycopg2 to asyncpg lists, and that is real work to spend on a measured gain rather than a hoped-for one.
Can the query change instead? Streaming with yield_per instead of buffering, selecting the five columns the page shows instead of the entity, or adding an index, routinely produce larger improvements than any driver, and none of them require a migration. A driver comparison that shows a fifty percent difference on a hundred-thousand-row fetch is also telling you that something is fetching a hundred thousand rows.
What else does the choice buy? psycopg 3 serves both worlds from one package, which matters during a long migration, and its pipelined executemany is strong for bulk writes. asyncpg is faster for large reads. Both are well maintained. Neither choice is difficult to reverse if error handling is SQLSTATE-based and connection arguments live in one module — the arrangement described in using psycopg 3 async with SQLAlchemy.
Finally, write down what was measured alongside the decision: the workload, the host, the latency, the driver versions and the dates. A benchmark without that context becomes folklore within a year — "we use asyncpg because it is faster" — long after the workload that justified it has changed.
Frequently Asked Questions
Is asyncpg faster than psycopg 3?
For large result sets, yes, because its protocol implementation and decoding are compiled and binary. For small queries the difference is small and dominated by network latency, and psycopg 3's pipelined executemany is faster for bulk inserts.
Why do my benchmark numbers vary so much?
Usually an unwarmed pool, other load on the machine, or a cold database cache. Warm the pool and the cache, alternate the drivers, repeat the run, and compare medians rather than totals.
Should I benchmark through the ORM?
Not when comparing drivers: ORM hydration costs the same for both and dilutes the difference. Use Core statements to compare drivers, then measure the endpoint separately to confirm the change helped.
How do I know whether the driver is my bottleneck?
Split the time: server time from EXPLAIN ANALYZE or pg_stat_statements, round-trip time from a SELECT 1 loop, and decoding as the remainder. If server time and round trips dominate, no driver change will help.
Related
- Choosing Between asyncpg and psycopg Async Drivers — The parent guide: the two drivers compared.
- Using psycopg 3 async with SQLAlchemy — Configuration, features and the one-driver argument.
- Benchmarking Core executemany bulk insert performance — The same method applied to write paths.
- Logging slow async queries with SQLAlchemy events — Measuring the real statement mix in production.