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.
Switching between them is one string. Drive it from the environment so staging can prove a swap before production sees it:
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.
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.
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:
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.
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()
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 model | Pick | Why |
|---|---|---|
| Dedicated Postgres, direct connection | asyncpg | Nothing blocks caching; take the speed |
| Supabase / Neon pooled port | psycopg3 | One flag, no intermittent errors |
| Serverless or scale-to-zero | psycopg3 | NullPool plus no prepared state |
| Bulk export or analytics jobs | asyncpg | Binary decode wins on wide result sets |
| Mixed sync and async codebase | psycopg3 | One driver for Alembic and the app |
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:
- Install the other driver alongside the current one:
pip install "psycopg[binary,pool]"orpip install asyncpg. Keep both until you have shipped. - Change the URL scheme only, via the
DB_DRIVERenvironment variable from the first snippet. Nothing in your models or queries changes. - Grep for raw SQL.
text()blocks using:namebind parameters are portable. Anything reaching the DBAPI directly —$1placeholders,conn.fetch,copy_records_to_table— needs rewriting. - Move driver-specific
connect_argsacross.server_settingson asyncpg becomesoptions="-c ..."on psycopg, andcommand_timeoutbecomes a statement timeout set throughoptions. - Update exception handling. Catch
sqlalchemy.exc.DBAPIErrorand inspectorig, rather than importingasyncpg.exceptionsdirectly, and the code stays driver-agnostic forever. - 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.
- 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.
Related
Same track:
- Async Database Access with SQLAlchemy — the parent guide, with engine lifecycle, pool sizing, and per-request transactions.
- Fixing Connection Pool Exhaustion — what to do when requests queue behind a full pool regardless of driver.
- Scaling and Operating Production Python APIs — the wider operations area this page sits in.
- Structured Logging with structlog — how to log query latency so a driver swap is measurable rather than a guess.
Other tracks:
- Logging API Usage Events to Postgres — the write-heavy workload where bulk insert performance actually matters.
- Calculating Cost per API Request — put a real number on the compute you save before optimising it.