asyncpg vs psycopg3: Picking the Right Async Postgres Driver for FastAPI

Two drivers can sit under async SQLAlchemy and talk to Postgres: asyncpg and psycopg version 3. Both work. Both are maintained. The SQLAlchemy code above them is byte-for-byte identical, so the choice feels cosmetic — right up until the afternoon your API starts throwing prepared statement "__asyncpg_stmt_3__" does not exist at ten percent of requests because your managed Postgres put a transaction pooler in front of the database. This page resolves that choice before it costs you a weekend. Part of the Async Database Access with SQLAlchemy guide.

The short version: raw speed favours asyncpg, but raw speed is almost never the constraint on a commercial API. Compatibility with your hosting model is, and that is where the two drivers genuinely diverge.

How each driver actually reaches Postgres

asyncpg is a from-scratch implementation of the Postgres wire protocol written in Cython. It does not link against libpq at all. It speaks the extended query protocol directly, requests binary result formats wherever it can, and keeps a per-connection cache of prepared statements so a repeated query skips the parse and plan phase. That design is why it wins benchmarks: fewer format conversions, no C library boundary to marshal across, and a hot path that stays in compiled code.

psycopg 3 is the successor to the psycopg2 that half the Python world already runs. It keeps libpq underneath (with a pure-Python fallback), but the async support is native rather than bolted on — AsyncConnection is a real coroutine API, not a thread pool wearing a costume. It uses server-side parameter binding by default, supports pipeline mode to cut round trips, and prepares statements automatically only after it has seen the same query five times.

Under SQLAlchemy 2.0 you never touch either API. You pick a dialect in the URL and everything above it stays the same.

Driver stacks compared Both drivers sit under the same SQLAlchemy AsyncSession, but asyncpg implements the Postgres protocol itself in Cython while psycopg 3 wraps libpq. Same code on top, different wire implementation SQLAlchemy 2.0 AsyncSession postgresql+asyncpg postgresql+psycopg Own protocol, Cython libpq, or pure Python Binary, statements cached Text or binary, pipeline PostgreSQL server

Switching between them is one string. Drive it from the environment so staging can prove a swap before production sees it:

Python
import os
from sqlalchemy.ext.asyncio import create_async_engine

def build_url() -> str:
    """Turn a plain DATABASE_URL into a driver-specific SQLAlchemy URL."""
    dsn = os.environ["DATABASE_URL"]
    match os.getenv("DB_DRIVER", "asyncpg"):
        case "asyncpg":
            return dsn.replace("postgresql://", "postgresql+asyncpg://", 1)
        case "psycopg":
            return dsn.replace("postgresql://", "postgresql+psycopg://", 1)
        case unknown:
            raise RuntimeError(f"unsupported DB_DRIVER: {unknown!r}")

engine = create_async_engine(
    build_url(),
    pool_size=int(os.getenv("DB_POOL_SIZE", "5")),
    max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "5")),
    pool_pre_ping=True,
)

Note the dialect name for psycopg 3 is psycopg, not psycopg3. That trips up everyone once.

Throughput and latency: where the gap disappears

asyncpg is genuinely faster on the wire, and the benchmarks you find online are not lying. They are just measuring a layer you do not ship. Once SQLAlchemy Core compiles the statement and the ORM hydrates rows into model instances, Python object construction dominates and the driver's share of the cost shrinks toward noise.

The shape below is what you should expect from a simple indexed SELECT returning twenty rows against a Postgres on the same private network. Treat the absolute numbers as illustrative of the ratio, not as a promise for your hardware — the point is how the gap narrows as you add layers.

Median query latency by layer Grouped bars showing p50 microseconds per query for asyncpg and psycopg 3 at the raw driver, SQLAlchemy Core, and ORM layers; the gap falls from about 45 percent to under 10 percent. p50 microseconds per query, 20-row SELECT asyncpg psycopg3 0 200 400 600 800 microseconds 110 160 280 340 620 680 Raw driver gap 45% SQLAlchemy Core gap 21% ORM objects gap 10%

Now price it. Suppose a request runs three queries and you serve a million requests a month. A sixty-microsecond per-query delta at the ORM layer costs 180 microseconds of CPU per request, or about three minutes of compute across the whole month. On a container that costs you a dollar or two of margin. Meanwhile a single unindexed lookup or one N+1 loop will cost you a hundred times that. If you are still sizing infrastructure spend, work through calculating cost per API request before you optimise a driver.

Where asyncpg does earn its keep is bulk work: streaming a hundred thousand rows into a report, or a query returning wide numeric columns where binary decoding beats text parsing outright. If your product has an export endpoint or an analytics job, that 45 percent at the driver layer becomes real seconds.

Prepared statements and poolers: the actual decision

Here is the failure that decides the argument for most builders. Managed Postgres platforms put a connection pooler in front of the database — Supabase, Neon, and most PgBouncer setups run transaction pooling mode, which hands your client a different backend connection for every transaction. A prepared statement lives on one backend. Prepare it in transaction one, execute it in transaction two, and you land on a different backend that has never heard of it.

asyncpg caches prepared statements by default, so it walks straight into this. The symptom is intermittent, which is the worst kind: it only fires when the pooler reassigns you, so it passes local testing, passes staging with one connection, and breaks under production concurrency.

Why prepared statements break in transaction pooling A sequence showing a statement prepared on backend A and then executed after the pooler reassigns the client to backend B, which raises a does-not-exist error. FastAPI worker PgBouncer Postgres backends PARSE stmt_7 (txn 1) routed to backend A A holds stmt_7 EXECUTE stmt_7 (txn 2) routed to backend B ERROR: prepared statement does not exist Fix: disable statement caching, or give every statement a unique name

Both drivers can be told to stop. psycopg 3 takes one keyword — prepare_threshold=None — and never prepares anything. asyncpg needs its statement cache zeroed and, because SQLAlchemy still emits a named statement per execution, a name generator that never repeats across backends:

Python
import os
from uuid import uuid4
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool

BEHIND_POOLER = os.getenv("DB_BEHIND_POOLER", "false").lower() == "true"
DRIVER = os.getenv("DB_DRIVER", "asyncpg")

def pooler_kwargs() -> dict:
    if not BEHIND_POOLER:
        return {}
    match DRIVER:
        case "psycopg":
            return {"connect_args": {"prepare_threshold": None}}
        case "asyncpg":
            return {
                "connect_args": {
                    "statement_cache_size": 0,
                    "prepared_statement_name_func": lambda: f"__api_{uuid4()}__",
                }
            }
        case _:
            return {}

engine = create_async_engine(
    os.environ["SQLALCHEMY_URL"],
    poolclass=NullPool if os.getenv("DB_SERVERLESS") == "1" else None,
    **pooler_kwargs(),
)

Count the failure modes. The psycopg 3 path has one knob and forgetting it costs you nothing but a little planning time on repeated queries. The asyncpg path has two knobs, and forgetting either produces an intermittent production error that only appears under load. That asymmetry is worth more than 45 percent on a microbenchmark. If your pool is already misbehaving, fixing connection pool exhaustion covers the sizing side of the same problem.

Type handling: strict versus forgiving

asyncpg is strict. It maps Postgres types to Python types with no implicit casting: a uuid column wants a uuid.UUID, not a string, and passing the wrong type raises DataError rather than letting Postgres sort it out. JSON and JSONB come back as raw strings unless you register a codec. That strictness catches bugs early, and it also means a careless str(user_id) somewhere in your code becomes a 500 instead of a silent success.

psycopg 3 is forgiving in the way psycopg2 taught everyone to expect. Dictionaries go in and out of JSONB through the Json/Jsonb wrappers, ranges and arrays have first-class adapters, and unknown types fall back to text so Postgres applies its own cast.

Python
import json
import os
import asyncpg  # strict: register a codec or JSONB arrives as a string

async def read_events_asyncpg() -> list[dict]:
    conn = await asyncpg.connect(os.environ["DATABASE_URL"])
    try:
        await conn.set_type_codec(
            "jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog"
        )
        rows = await conn.fetch("SELECT payload FROM usage_events LIMIT $1", 100)
        return [dict(r["payload"]) for r in rows]
    finally:
        await conn.close()
Python
import os
import psycopg  # forgiving: dicts adapt straight into JSONB
from psycopg.types.json import Jsonb

async def write_event_psycopg(payload: dict) -> None:
    async with await psycopg.AsyncConnection.connect(os.environ["DATABASE_URL"]) as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "INSERT INTO usage_events (payload) VALUES (%s)", (Jsonb(payload),)
            )
        await conn.commit()

Under SQLAlchemy the dialects paper over most of this: declare a column as JSONB and both drivers hand you a dict. The difference surfaces the moment you drop to text() for a query the ORM cannot express, which every real API does eventually.

One more asyncpg quirk deserves a warning. Because it caches statement plans and type OIDs per connection, a migration that alters a table or recreates an enum can leave live connections holding stale plans, producing InvalidCachedStatementError on the next execute. Setting pool_pre_ping=True and recycling connections after a migration mitigates it, but it is a genuine hazard during zero-downtime deploys. psycopg 3 re-plans and never hits it.

Which to pick per hosting model

Hosting modelPickWhy
Dedicated Postgres, direct connectionasyncpgNothing blocks caching; take the speed
Supabase / Neon pooled portpsycopg3One flag, no intermittent errors
Serverless or scale-to-zeropsycopg3NullPool plus no prepared state
Bulk export or analytics jobsasyncpgBinary decode wins on wide result sets
Mixed sync and async codebasepsycopg3One driver for Alembic and the app
Driver decision tree A decision tree: connect directly and choose asyncpg; go through a transaction pooler and choose psycopg 3 with prepare threshold disabled, or asyncpg with statement caching off. Do you go through a pooler? no yes Direct connection Choose asyncpg keep the statement cache Transaction pooling? psycopg3 prepare_threshold=None one flag, hard to botch asyncpg statement_cache_size=0 plus unique statement names Serverless or scale-to-zero: add NullPool on either branch

Traffic level barely enters this. Below roughly a thousand requests per minute, neither driver is your bottleneck — your bottleneck is query shape and pool size. Above that, and only on a dedicated instance where you control the connection path, asyncpg's binary decoding starts buying you measurable headroom per container, which is real money when you are running on a per-second billed host. The Render vs Railway vs Fly.io comparison covers which of those hosts even gives you a direct connection.

Migrating from one to the other

The move is genuinely small — a couple of hours, most of it testing. Do it in this order:

  1. Install the other driver alongside the current one: pip install "psycopg[binary,pool]" or pip install asyncpg. Keep both until you have shipped.
  2. Change the URL scheme only, via the DB_DRIVER environment variable from the first snippet. Nothing in your models or queries changes.
  3. Grep for raw SQL. text() blocks using :name bind parameters are portable. Anything reaching the DBAPI directly — $1 placeholders, conn.fetch, copy_records_to_table — needs rewriting.
  4. Move driver-specific connect_args across. server_settings on asyncpg becomes options="-c ..." on psycopg, and command_timeout becomes a statement timeout set through options.
  5. Update exception handling. Catch sqlalchemy.exc.DBAPIError and inspect orig, rather than importing asyncpg.exceptions directly, and the code stays driver-agnostic forever.
  6. Re-run the suite against a real Postgres, not SQLite. If you have not set that up, testing Python APIs with pytest covers the fixtures.
  7. Deploy to one instance first and watch p95 query latency for an hour before rolling the rest.

Step five is the one people skip and regret. Driver-specific exception imports are what turn a two-hour migration into a two-day one.

Builder verdict

Ship psycopg 3 unless you have a specific reason not to. It is the driver that survives contact with the hosting reality most builders actually have: a managed Postgres behind a transaction pooler, an Alembic setup that wants a synchronous connection, and a team of one or two people who cannot afford to debug intermittent prepared-statement errors at eleven at night. Disabling its prepared statements is one keyword argument, its type adapters behave the way every Python developer already expects, and using one driver for both sync tooling and the async app removes a whole category of configuration drift. The performance you give up is roughly ten percent of the driver's share of a request that is dominated by ORM hydration and network time — call it a dollar or two a month at a million requests, which is less than one support conversation costs you in attention.

Switch to asyncpg when you have earned the right to: a dedicated Postgres instance you connect to directly, a profile that shows the driver on the hot path, or bulk endpoints where binary decoding of wide result sets is the actual bottleneck. That is a real win at that point, and the migration above takes an afternoon. Until then, optimise for the errors you will not have to debug rather than the microseconds you will not be able to measure.

FAQ

Does driver choice measurably change my hosting bill? Only at scale and only on a direct connection. At a million requests a month with three queries each, the ORM-layer difference works out to single-digit dollars of compute. Below ten million requests a month, spend that attention on query indexes and caching instead — both move the number by an order of magnitude more.

Which driver is safer on Supabase or Neon? psycopg 3, because their pooled connection strings run transaction pooling and psycopg 3 needs one flag (prepare_threshold=None) to be compatible. asyncpg works too, but needs both a zeroed statement cache and a unique statement-name function; miss either and you get intermittent 500s under concurrency that never reproduce locally.

How risky is migrating a live API between the two? Low, if you keep the switch behind an environment variable and roll one instance at a time. The risk concentrates in three places: raw DBAPI-level SQL, driver-specific connect_args, and except asyncpg.SomeError imports. Fix those first and the rest is a URL scheme change you can roll back in seconds.

Can I run asyncpg for the API and psycopg for Alembic? Yes, and plenty of projects do — Alembic runs migrations on a separate connection and does not care which driver serves requests. It does mean two drivers to keep patched and two sets of connection quirks in your head, which is exactly the tax psycopg 3 removes by handling both modes itself.

Do I still need a connection pooler if I use asyncpg? Almost always, yes. SQLAlchemy's pool lives inside one process, so ten containers with a pool of ten each demand a hundred backend connections from a database that often allows sixty. A pooler multiplexes that down. The driver never changes that arithmetic — it only changes how gracefully it copes with the pooler being there.

Same track:

Other tracks: