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.

Measure the driver, not the framework Left: timing an HTTP endpoint mixes routing, serialisation, ORM hydration and the driver, so a five percent driver difference disappears into the noise and a serialisation change looks like a driver change. Right: timing Core statements on a warmed pool, from a host with production-like latency, leaves the driver as the main variable. timing an endpoint routing + auth + ORM + driver serialisation dominates driver differences invisible every change moves the number timing Core statements warmed pool, fixed statement no ORM hydration driver is the variable p50 and p95 over many runs Benchmark the layer you are choosing. Then confirm the endpoint improved, which is a separate question.

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.

One run, five phases Five phases. Create the engine and open every pooled connection, so connection setup is not measured. Run a warm-up pass to populate statement caches and let the database load its pages. Run the measured pass, recording each operation individually rather than a total. Dispose the engine cleanly. Then repeat the whole run for the other driver, alternating so that a machine warming up or cooling down does not favour one. 1 · warm the pool open every connection setup is not the measurement 2 · warm-up pass statement caches, page cache discard these timings 3 · measured pass record per operation keep the distribution, not the mean 4 · dispose await engine.dispose() no leaked connections 5 · alternate and repeat A, B, A, B Reporting a single total from one run of each driver is how benchmarks end up measuring the machine.

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

SymptomRoot CauseProduction Fix
One driver looks dramatically faster on the first runThe 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 measuredOther load on the machine, or the database on the same CPU.Alternate drivers, repeat, and compare medians across runs.
psycopg looks far slower than expectedThe pure-Python implementation is installed.pip install "psycopg[binary]"; check psycopg.pq.__impl__.
asyncpg looks slower on repeated identical queriesStatement 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 memoryEverything buffered client-side.Stream with yield_per, and measure streaming for both drivers.
Numbers do not match production at allLoopback network, cold cache, tiny dataset.Production-like latency, warmed cache, realistic row widths.
TimeoutError from the pool during the runConcurrency above pool_size with max_overflow=0.Match the pool to the concurrency being tested, deliberately.
Four workloads, four answers Four tiles. Small indexed lookups are dominated by network latency, so the drivers are close. Large result sets favour asyncpg, whose decoding is compiled and binary. Bulk inserts favour psycopg 3 pipelined executemany, and COPY beats both. Concurrent mixed traffic tests the pool and the event loop rather than the protocol, and usually shows less difference than a single-statement benchmark suggests. small lookups latency-bound drivers close large result sets decoding-bound asyncpg ahead bulk insert round-trip-bound psycopg pipeline strong concurrent mixed load pool and loop bound smallest difference A single number for "which driver is faster" hides all four of these.

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.

The shape a real comparison takes Bar chart, illustrative, showing relative time with asyncpg as one. Small lookups are nearly identical. Fetching a hundred thousand rows takes noticeably longer on psycopg. A pipelined executemany of ten thousand rows is faster on psycopg than on asyncpg. COPY is far faster than either on both. 1,000 indexed lookups psycopg ≈ asyncpg — latency dominates fetch 100,000 rows psycopg slower: decoding is the cost executemany 10,000 rows psycopg faster: pipeline mode COPY 10,000 rows both drivers: COPY beats INSERT Illustrative only — the point is that the ordering changes by workload. Measure your own.

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.

What has to be constant Four conditions. The network path, because a driver difference of a few percent is invisible next to a change from loopback to cross-zone latency. The database, warmed and not competing with the benchmark for CPU. The statement and the data, byte for byte. And the Python build, including whether psycopg is installed as the binary or pure-Python distribution. the network path run from a host with production-like latency; loopback flatters both drivers equally but hides the shape the database warmed page cache, no other load, and not on the same CPU as the benchmark process the statement and the data identical SQL, identical rows, identical column types — types decide decoding cost the Python build psycopg[binary] or [c], not the pure-Python fallback; check psycopg.pq.__impl__

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.