← Django & Python Frameworks

asyncpg or psycopg3 for FastAPI? Allow Me to Present the Evidence.

The driver question arrives with every new FastAPI project. Permit me to spare you the guesswork.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 22 min read
The artist is debating which driver to use for the illustration. We understand the difficulty.

Good evening. You have a FastAPI application and a PostgreSQL database.

The question, inevitably, is which async driver to use. asyncpg or psycopg3. Both are excellent. Both are actively maintained. Both speak the PostgreSQL wire protocol fluently. And yet they are, in important ways, quite different instruments.

asyncpg was built from the ground up for speed — a Cython-accelerated, binary-protocol driver that has been the de facto choice for high-performance async Python since 2016. It was created by Yury Selivanov, one of the architects of Python's asyncio, and it shows. The design is opinionated: binary protocol only, aggressive prepared statement caching, custom type codecs, and a record type that trades flexibility for speed. A decade of production use has polished it into something quite refined.

psycopg3 arrived in 2021, the successor to psycopg2 — a library that has been the standard Python PostgreSQL driver for over two decades. psycopg3 brought modern async support, pipeline mode, and a more Pythonic interface to the table, while maintaining the API familiarity that psycopg2 users expect. Where asyncpg was born async, psycopg3 grew into it — and brought along lessons from twenty years of production experience.

I have benchmarked both under realistic FastAPI concurrency, traced their wire protocol behavior, tested their PgBouncer compatibility, mapped their failure modes, and used both extensively in production systems. If you'll permit me, I shall present what I've found.

How do asyncpg and psycopg3 differ at the protocol level?

This is where the conversation must begin, because every performance difference, every compatibility quirk, and every deployment consideration traces back to how each driver communicates with PostgreSQL over the wire.

PostgreSQL supports two data transfer formats: text and binary. Text format sends everything as human-readable strings — the integer 42 travels as the characters "4" and "2". Binary format sends the native machine representation — 42 travels as four bytes of integer data. The binary format is faster to encode and decode, but the text format is simpler to debug and more universally supported.

asyncpg uses the binary protocol exclusively. Every query result is decoded from PostgreSQL's binary wire format using Cython-optimized routines written in C. There is no text fallback, no configuration option, no negotiation. Binary, always. It also aggressively caches prepared statements — by default, the last 100 unique queries per connection are held as server-side prepared statements, eliminating parse and plan overhead on repeated queries.

psycopg3 defaults to text protocol but supports binary output per-query or globally. Its prepare_threshold parameter (default: 5) automatically promotes frequently-executed queries to server-side prepared statements after the fifth execution. This is more conservative than asyncpg's approach, which prepares everything immediately.

# asyncpg — binary protocol, C-speed parsing
import asyncpg

pool = await asyncpg.create_pool(
    "postgresql://user:pass@localhost/mydb",
    min_size=5, max_size=20,
    statement_cache_size=100  # prepared statements cached per connection
)

async with pool.acquire() as conn:
    rows = await conn.fetch("SELECT id, name FROM users WHERE active = $1", True)
# psycopg3 — PostgreSQL-native types, pipeline mode
import psycopg
from psycopg_pool import AsyncConnectionPool

pool = AsyncConnectionPool(
    "postgresql://user:pass@localhost/mydb",
    min_size=5, max_size=20,
    open=False
)
await pool.open()

async with pool.connection() as conn:
    rows = await conn.execute("SELECT id, name FROM users WHERE active = %s", (True,))
    results = await rows.fetchall()

The practical difference: asyncpg is faster out of the box for repeated queries because every query is prepared immediately and decoded from binary. psycopg3 is more cautious, which makes it friendlier with connection poolers but slightly slower for the first few executions of any given query.

I should note that psycopg3's text-mode default is a deliberate design choice, not a limitation. Text mode is safer with older PostgreSQL extensions, more debuggable, and sufficient for the majority of applications. Binary mode is available when you need it — and when you enable it, the performance gap between the two drivers narrows considerably.

Type handling: where the protocol difference becomes tangible

The binary-versus-text distinction is not merely a performance curiosity. It fundamentally shapes how each driver handles PostgreSQL's type system — and PostgreSQL has rather a lot of types.

# asyncpg returns native Python types — no conversion needed
async with pool.acquire() as conn:
    row = await conn.fetchrow("""
        SELECT id, created_at, metadata, tags, balance
        FROM accounts WHERE id = $1
    """, account_id)

    # row['created_at']  -> datetime.datetime (native)
    # row['metadata']    -> dict (JSONB decoded automatically)
    # row['tags']        -> list (PostgreSQL array -> Python list)
    # row['balance']     -> decimal.Decimal (numeric type preserved)
    # All decoded from binary wire format via Cython. No text parsing.

# psycopg3 in text mode (default) parses string representations
async with pool.connection() as conn:
    cur = await conn.execute("""
        SELECT id, created_at, metadata, tags, balance
        FROM accounts WHERE id = %s
    """, (account_id,))
    row = await cur.fetchone()

    # row[0]  -> int (parsed from text '42')
    # row[1]  -> datetime.datetime (parsed from text '2025-03-05 14:30:00+00')
    # row[2]  -> dict (parsed from text '{"key": "value"}')
    # row[3]  -> list (parsed from text '{tag1,tag2,tag3}')
    # row[4]  -> Decimal (parsed from text '1234.56')
    # Text parsing adds overhead — measurable at high throughput.

# psycopg3 in binary mode — explicit opt-in
async with pool.connection() as conn:
    cur = await conn.execute(
        "SELECT id, created_at, metadata FROM accounts WHERE id = %s",
        (account_id,),
        binary=True  # per-query binary output
    )
    # Now types are decoded from binary format, closing the gap with asyncpg.

asyncpg's binary decoding means types arrive as native Python objects with zero text parsing. A TIMESTAMP WITH TIME ZONE arrives as a datetime.datetime, a JSONB column arrives as a dict, and a PostgreSQL array arrives as a Python list. The Cython decoders handle this at C speed. For applications processing thousands of rows with complex types — JSONB payloads, array columns, composite types — this is where asyncpg's performance advantage is most pronounced.

psycopg3 in text mode must parse string representations: "2025-03-05 14:30:00+00" becomes a datetime, '{"key": "value"}' becomes a dict, '{tag1,tag2,tag3}' becomes a list. This parsing is well-optimized — psycopg3 uses a C-accelerated adaptation layer when the psycopg[c] extra is installed — but it is inherently slower than decoding binary representations.

The honest counterpoint: psycopg3's type adaptation system is more flexible. It queries PostgreSQL's type catalog at connection time and can automatically handle custom types — enums, composite types, range types — without manual codec registration. asyncpg requires you to register custom type codecs explicitly, which is more work but gives you finer control.

# asyncpg custom type codecs — powerful but manual
import asyncpg
import json

async def setup_codecs(conn):
    # Register a custom JSONB codec
    await conn.set_type_codec(
        'jsonb',
        encoder=json.dumps,
        decoder=json.loads,
        schema='pg_catalog',
        format='text'  # or 'binary'
    )

pool = await asyncpg.create_pool(dsn, init=setup_codecs)

# Every connection in the pool runs setup_codecs on creation.
# You control exactly how types are encoded and decoded.
# But: you must register codecs for every custom type.
# Forget one, and you get raw bytes or a codec error at runtime.

# psycopg3 uses PostgreSQL's type OIDs — automatic type resolution
import psycopg
from psycopg.types.json import Jsonb

async with pool.connection() as conn:
    await conn.execute(
        "INSERT INTO events (data) VALUES (%s)",
        (Jsonb({"event": "signup", "user_id": 42}),)
    )
    # psycopg3 queries the server's type catalog on first use
    # and adapts types automatically. Enums, composites, ranges —
    # all resolved without manual codec registration.

For applications that use PostgreSQL's richer type features — domain types, custom enums, composite types — psycopg3's automatic type resolution saves meaningful development time. For applications that stick to standard types and need maximum throughput, asyncpg's binary decoders are faster.

Benchmarks under realistic FastAPI concurrency

I configured a straightforward benchmark: a FastAPI endpoint that queries a users table (500,000 rows) with a filtered SELECT, measured under 10, 100, and 500 concurrent users via wrk. PostgreSQL 16, connection pool of 20, Ubuntu on a 4-core machine. Nothing exotic — the sort of setup one encounters in production daily.

Median response time (p50) by concurrency level
Driver           | 10 users | 100 users | 500 users | Protocol
-----------------+----------+-----------+-----------+----------
asyncpg          |   1.2 ms |    3.8 ms |   12.4 ms | Binary
psycopg3 (async) |   1.8 ms |    5.1 ms |   14.2 ms | Binary
psycopg3 (text)  |   2.1 ms |    6.3 ms |   18.7 ms | Text
psycopg2         |     N/A  |      N/A  |      N/A  | Text/Sync

asyncpg holds a consistent 25-35% latency advantage over psycopg3 in async mode, widening slightly under heavy concurrency. The gap comes from three sources: Cython-optimized binary decoding, immediate prepared statement caching, and a leaner connection state machine.

That said, psycopg3 in binary mode narrows the gap considerably. If you set cursor_factory=psycopg.AsyncClientCursor and enable binary output, the difference shrinks to 15-20%. Still measurable, but perhaps not decisive.

The tail latencies tell a slightly different story:

Extended benchmark: p50, p95, p99, throughput, resource usage (500 concurrent users)
Metric              | asyncpg    | psycopg3 (binary) | psycopg3 (text)
--------------------+------------+--------------------+-----------------
p50 latency         |     1.2 ms |             1.5 ms |          2.1 ms
p95 latency         |     3.1 ms |             4.0 ms |          5.8 ms
p99 latency         |     8.7 ms |            10.2 ms |         14.3 ms
Throughput (rps)    |     12,400 |             10,100 |          7,800
Memory per conn     |      ~42 KB |            ~38 KB |         ~38 KB
Connection setup    |      4.2 ms |             2.8 ms |          2.8 ms

Two numbers deserve attention. First, asyncpg's throughput advantage (12,400 vs 10,100 requests per second in binary mode) is substantial — a 23% difference that compounds in high-traffic applications. Second, psycopg3's connection setup time is 33% faster than asyncpg's (2.8ms vs 4.2ms). This matters in serverless environments, during autoscaling events, and after pool recycling — any situation where connections are established frequently.

The memory-per-connection numbers are effectively equivalent. Neither driver is wasteful. The difference of 4KB per connection would require thousands of connections to become meaningful, and if you have thousands of connections, you have a pooling problem, not a driver problem.

The more interesting number is the throughput ceiling. At 500 concurrent users with a pool of 20 connections, both drivers are fundamentally bottlenecked by PostgreSQL's ability to process queries — the driver overhead becomes noise relative to query execution time. A query that takes 50ms on the database does not care whether the driver adds 0.3ms or 0.5ms of overhead.

The prepared statement question — and PgBouncer's opinion on the matter

This is where the decision gets interesting, because most production PostgreSQL deployments use a connection pooler, and connection poolers have strong opinions about prepared statements.

# asyncpg with PgBouncer in transaction mode:
# This WILL fail with DuplicatePreparedStatementError
pool = await asyncpg.create_pool(dsn, statement_cache_size=100)

# Fix: disable the statement cache entirely
pool = await asyncpg.create_pool(dsn, statement_cache_size=0)
# But now you lose the 15-30% performance advantage of prepared statements.

# psycopg3 handles this more gracefully:
# prepare_threshold=0 disables server-side prepares
pool = AsyncConnectionPool(conninfo, kwargs={"prepare_threshold": 0})
# Or prepare_threshold=5 (default) works with PgBouncer 1.21+

The core issue: PgBouncer in transaction mode reassigns server connections between transactions. Prepared statements are bound to a specific server connection. When asyncpg prepares a statement on connection A, then PgBouncer routes the next transaction to connection B, the prepared statement does not exist — and asyncpg raises DuplicatePreparedStatementError.

I have written an entire article on this trap because it generates more confused GitHub issues than any other asyncpg topic. The short version:

asyncpg's fix is blunt: set statement_cache_size=0. This works, but you lose the 15-30% performance advantage that prepared statements provide. psycopg3's prepare_threshold approach is more nuanced — it can work with PgBouncer 1.21+ which added prepared statement forwarding, and gracefully degrades when set to 0. Because psycopg3 only prepares statements after five executions by default, the total number of prepared statements in play is smaller, which reduces the surface area for pooler conflicts.

If you are using Supabase's Supavisor, Neon's connection pooler, or any transaction-mode pooler, this distinction matters enormously. psycopg3 handles it with less friction.

If you control your own PgBouncer and can upgrade to 1.21+, asyncpg's statement cache works again with max_prepared_statements enabled in the PgBouncer configuration. But not every team controls their pooler, and not every managed service has upgraded. Evaluate your deployment before choosing based on this criterion.

Pipeline mode: psycopg3's genuine advantage

psycopg3 supports PostgreSQL's pipeline mode, which allows sending multiple queries in a single network round trip without waiting for individual responses. This feature was added to libpq in PostgreSQL 14, and psycopg3 is the only major Python async driver that exposes it.

# psycopg3's pipeline mode — multiple queries, one round trip
async with pool.connection() as conn:
    async with conn.pipeline():
        cur1 = await conn.execute("SELECT count(*) FROM orders WHERE status = %s", ("pending",))
        cur2 = await conn.execute("SELECT count(*) FROM orders WHERE status = %s", ("shipped",))
        cur3 = await conn.execute("SELECT sum(total) FROM orders WHERE created_at > %s", (cutoff,))
    # All three execute in a single network round trip
    # Saves 2-4ms of latency per additional query

For endpoints that need to execute 3-5 independent queries — and in my experience, most dashboard and analytics endpoints fall into this category — pipeline mode eliminates 2-4ms of per-query network latency. At 100 requests per second, that is 200-400ms of saved wall-clock time per second across the system.

The savings are even more dramatic when the application and database are not co-located. If your PostgreSQL database is in a different availability zone (0.5-1ms network latency) or a different region (10-50ms), each eliminated round trip saves real time. Three pipelined queries to a cross-AZ database save 1-2ms. Three pipelined queries to a cross-region database save 20-100ms. That is not a micro-optimization. That is architecture.

asyncpg does not currently support pipeline mode. It compensates somewhat with its executemany for batch inserts, but for read-heavy endpoints executing multiple independent SELECTs, psycopg3's pipeline mode is a genuine advantage that asyncpg cannot match through configuration alone.

The honest boundary: pipeline mode only helps when you have multiple independent queries. If your queries depend on each other's results — query B's WHERE clause uses a value from query A's result — they cannot be pipelined. And a single, well-constructed query that retrieves everything in one round trip is always faster than three pipelined queries. Pipeline mode is not a substitute for good query design. It is a tool for the cases where multiple independent queries are genuinely the right approach.

COPY, LISTEN/NOTIFY, and the features at the edges

Most driver comparisons stop at query performance and pooler compatibility. But production applications touch other parts of the PostgreSQL protocol — bulk loading, notifications, cursors — and the drivers differ here too.

# psycopg3's COPY support — bulk data loading
async with pool.connection() as conn:
    async with conn.cursor().copy(
        "COPY events (timestamp, user_id, event_type, payload) FROM STDIN"
    ) as copy:
        for event in events:
            await copy.write_row((
                event.timestamp,
                event.user_id,
                event.event_type,
                Jsonb(event.payload),
            ))
    # COPY is 5-10x faster than INSERT for bulk loading.
    # psycopg3 streams rows to PostgreSQL without buffering the entire dataset.

# asyncpg also supports COPY, but with a different API:
async with pool.acquire() as conn:
    result = await conn.copy_records_to_table(
        'events',
        columns=['timestamp', 'user_id', 'event_type', 'payload'],
        records=[(e.timestamp, e.user_id, e.event_type, e.payload) for e in events],
    )
    # copy_records_to_table uses binary COPY protocol — very fast.
    # But it requires all records in memory as a list.
    # For truly large datasets (millions of rows), psycopg3's streaming wins.

Both drivers support PostgreSQL's COPY protocol for bulk data loading, which is 5-10x faster than individual INSERT statements. asyncpg's copy_records_to_table uses the binary COPY format and is extremely fast, but requires all records in memory as a list. psycopg3's cursor.copy supports streaming — rows are sent to PostgreSQL as they are generated, without buffering the entire dataset. For truly large imports (millions of rows from a CSV or event stream), psycopg3's streaming COPY is the more practical choice.

# LISTEN/NOTIFY — both drivers support it, with different ergonomics

# asyncpg — callback-based
async with pool.acquire() as conn:
    await conn.add_listener('order_updates', handle_notification)
    # handle_notification(connection, pid, channel, payload)
    # Listener fires asynchronously when a NOTIFY arrives.
    # Connection must be held open for the duration.

# psycopg3 — generator-based
async with pool.connection() as conn:
    await conn.execute("LISTEN order_updates")
    async for notify in conn.notifies():
        # notify.channel, notify.payload, notify.pid
        print(f"Order update: {notify.payload}")
    # The async generator pattern integrates naturally with
    # FastAPI WebSocket handlers or background task loops.

LISTEN/NOTIFY support reveals a design philosophy difference. asyncpg uses a callback pattern: you register a function, and it fires when a notification arrives. This is flexible but requires careful connection management — the listening connection must be held open and cannot be returned to the pool. psycopg3 uses an async generator pattern that integrates more naturally with Python's async for syntax and pairs well with FastAPI WebSocket handlers or background task loops.

Neither approach is wrong. The callback pattern is more familiar to JavaScript developers; the async generator pattern is more Pythonic. Choose whichever matches your application's notification architecture.

"The abstraction layer between your application and PostgreSQL is where most performance is lost — and where most performance can be recovered."

— from You Don't Need Redis, Chapter 3: The ORM Tax

Error handling and the migration question

If you are coming from psycopg2 — and statistically, most Python teams are — the error handling story strongly favors psycopg3.

# asyncpg — specific exception hierarchy
import asyncpg

try:
    async with pool.acquire() as conn:
        await conn.execute(
            "INSERT INTO users (email) VALUES ($1)", "duplicate@example.com"
        )
except asyncpg.UniqueViolationError:
    # Specific exception for unique constraint violations
    # Inherits from asyncpg.IntegrityConstraintViolationError
    pass
except asyncpg.PostgresError as e:
    # Base class for all PostgreSQL errors
    # e.sqlstate contains the 5-char SQLSTATE code ('23505' for unique violation)
    print(f"SQLSTATE {e.sqlstate}: {e.message}")

# psycopg3 — SQLSTATE-based errors matching psycopg2's hierarchy
import psycopg
from psycopg import errors

try:
    async with pool.connection() as conn:
        await conn.execute(
            "INSERT INTO users (email) VALUES (%s)", ("duplicate@example.com",)
        )
except errors.UniqueViolation:
    # Same exception name as psycopg2 — migration-friendly
    pass
except psycopg.errors.lookup("23505"):
    # Can also look up by SQLSTATE code directly
    pass
except psycopg.DatabaseError as e:
    # e.diag.sqlstate, e.diag.message_primary, e.diag.detail
    # The diagnostics interface is richer than asyncpg's
    print(f"SQLSTATE {e.diag.sqlstate}: {e.diag.message_primary}")

psycopg3's exception hierarchy mirrors psycopg2's almost exactly. UniqueViolation, ForeignKeyViolation, CheckViolation — the same names, the same inheritance tree, the same SQLSTATE-based lookup mechanism. If your codebase catches psycopg2.errors.UniqueViolation, changing the import to psycopg.errors.UniqueViolation is the entire migration for that error handler.

asyncpg uses its own exception hierarchy. UniqueViolationError instead of UniqueViolation. PostgresError instead of DatabaseError. The SQLSTATE code is accessible via e.sqlstate rather than e.diag.sqlstate. None of this is worse — it is simply different. But if you are migrating a codebase with extensive error handling, psycopg3 requires fewer changes.

psycopg3's diagnostics interface also exposes more detail: e.diag.message_primary, e.diag.detail, e.diag.hint, e.diag.schema_name, e.diag.table_name, e.diag.column_name. For applications that provide user-facing error messages based on constraint violations — "this email is already registered" — the richer diagnostics are genuinely useful. asyncpg provides the error message and SQLSTATE code, which is sufficient for most error handling but less granular for constraint-specific messaging.

For those who wish to go further, the psycopg2 to psycopg3 migration guide covers the complete walkthrough.

Pool lifecycle in FastAPI: the practical wiring

A driver that benchmarks well but is awkward to wire into your application is not a practical choice. Both drivers integrate cleanly with FastAPI's lifespan events, but the patterns differ in ways that matter for operational robustness.

from contextlib import asynccontextmanager
from fastapi import FastAPI, Request

# asyncpg lifespan — minimal, direct
@asynccontextmanager
async def asyncpg_lifespan(app: FastAPI):
    app.state.pool = await asyncpg.create_pool(
        dsn,
        min_size=5, max_size=20,
        max_inactive_connection_lifetime=300,
    )
    yield
    await app.state.pool.close()

# psycopg3 lifespan — more ceremony, more control
@asynccontextmanager
async def psycopg3_lifespan(app: FastAPI):
    app.state.pool = AsyncConnectionPool(
        dsn,
        min_size=5, max_size=20,
        max_idle=300,
        max_lifetime=1800,
        open=False,
    )
    await app.state.pool.open()
    await app.state.pool.wait()  # blocks until min_size connections ready
    yield
    await app.state.pool.close()

# The wait() call is the important difference:
# psycopg3 can ensure your pool is fully warmed before serving requests.
# asyncpg creates connections lazily — the first requests pay the cost.

asyncpg's pool creation is a single await — clean, minimal, done. Connections are created lazily as requests arrive, up to min_size initially and max_size under load. This means your first few requests after startup pay the connection establishment cost (3-5ms each), which is fine for services that warm up gradually but can cause latency spikes if traffic arrives immediately after deployment.

psycopg3's pool has a more explicit lifecycle: create with open=False, then await pool.open(), then await pool.wait(). The wait() call blocks until min_size connections are established, ensuring your pool is fully warmed before FastAPI begins accepting requests. For applications behind load balancers with health checks, this is the more predictable behavior — the application reports healthy only after its database connections are proven.

For a comprehensive treatment of pool lifecycle patterns across all three major approaches (asyncpg, SQLAlchemy async, and psycopg3), I have written a dedicated guide on FastAPI connection pool management.

The SQLAlchemy question: does the driver even matter?

If you are using SQLAlchemy 2.0's async engine — and a large portion of production FastAPI applications do — you might reasonably ask whether the driver choice matters at all, since SQLAlchemy abstracts the driver behind its own connection and session management.

# SQLAlchemy 2.0 makes the driver swap nearly invisible.
# Your ORM code stays identical — only the connection string changes.

# With asyncpg (the default async dialect):
engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/mydb",
    pool_size=20,
    pool_pre_ping=True,
)

# With psycopg3:
engine = create_async_engine(
    "postgresql+psycopg://user:pass@localhost/mydb",
    pool_size=20,
    pool_pre_ping=True,
)

# Same session code, same ORM models, same queries.
# The only visible difference: connect_args change if you were
# configuring driver-specific settings like statement_cache_size.

# But: if you use raw connection features (conn.fetchrow, pipeline()),
# those APIs differ between drivers and won't be abstracted away.

The answer: it matters less, but it still matters.

SQLAlchemy abstracts away the query interface — you write the same ORM code, the same Core expressions, the same text() queries regardless of the underlying driver. The driver swap is literally a connection string change from postgresql+asyncpg:// to postgresql+psycopg://.

But SQLAlchemy does not abstract away the wire protocol. asyncpg's binary decoding is still faster for result set processing. psycopg3's pipeline mode is still available through SQLAlchemy's connection proxy. PgBouncer compatibility still depends on the driver's prepared statement behavior. And asyncpg's statement cache can interact poorly with SQLAlchemy's connection recycling — when SQLAlchemy recycles a connection, the new connection's statement cache is cold, and the performance characteristics differ from what benchmarks suggest.

My recommendation for SQLAlchemy users: if you are deploying behind a connection pooler, use psycopg3 to avoid the prepared statement trap entirely. If you are connecting directly to PostgreSQL and processing large result sets, asyncpg's binary decoding provides a measurable advantage that passes through SQLAlchemy's abstraction layer.

The decision matrix

I shall not insult your intelligence with a vague "it depends." Here is my recommendation, stated plainly:

Your situationRecommended driverWhy
Direct Postgres connection, latency-criticalasyncpg25-35% lower latency from binary protocol + Cython
PgBouncer / Supavisor / cloud poolerpsycopg3Graceful prepared statement handling
Multiple independent queries per requestpsycopg3Pipeline mode saves round trips
Migrating from psycopg2psycopg3Similar API, exception hierarchy, and migration tooling
SQLAlchemy 2.0 asyncEither — but psycopg3 has fewer edge casesasyncpg's statement cache interacts poorly with SA's connection recycling
Serverless / short-lived processespsycopg333% faster connection setup, no Cython compile dependency
Custom PostgreSQL types (enums, composites)psycopg3Automatic type catalog resolution vs manual codec registration
High-throughput JSONB processingasyncpgBinary JSONB decoding is 40-60% faster than text parsing
Bulk data loading (COPY)psycopg3 for streaming; asyncpg for in-memory batchespsycopg3 streams without buffering; asyncpg's binary COPY is faster for smaller batches
Cross-region database (high network latency)psycopg3Pipeline mode eliminates round trips worth 10-50ms each

If you are starting a new FastAPI project with a direct PostgreSQL connection and raw speed is paramount, asyncpg remains the faster driver. If you are deploying behind a connection pooler, need pipeline mode, value automatic type handling, or want a smoother migration from psycopg2 — psycopg3 is the more practical choice. Both are excellent; neither is wrong.

I should also note that neither choice is permanent. If you start with asyncpg and later deploy behind a connection pooler, switching to psycopg3 is a manageable migration — particularly if you are behind SQLAlchemy. The driver is a dependency, not a destiny.

A brief word on the alternatives

asyncpg and psycopg3 are not the only options. They are the two I recommend, but you may encounter others in the wild, and it would be a disservice not to mention them.

psycopg2 remains widely used but does not support async. It works in FastAPI def (non-async) endpoints, which FastAPI runs in a thread pool, but this limits your concurrency to the thread pool size (default: 40 workers). For new projects, there is no reason to choose psycopg2 over psycopg3 — the successor is better in every dimension. For existing projects, the migration path is well-documented.

Tortoise ORM and databases (encode/databases) both wrap asyncpg or other async drivers with their own abstractions. They are not drivers themselves. Tortoise is a full ORM; databases is a thin async wrapper. Both add overhead and abstraction that may or may not serve your needs. If you want an ORM, SQLAlchemy 2.0 async is more mature. If you want raw driver access, use the driver directly.

aiopg wraps psycopg2 for async use. It is effectively unmaintained and should not be used in new projects. psycopg3's native async support makes aiopg unnecessary.

What if the driver choice mattered rather less?

I will confess something: after benchmarking drivers extensively, I have come to believe that the driver accounts for perhaps 5-10% of real-world query latency. The remaining 90-95% is determined by whether the query hits an index, whether joins are materialized efficiently, and whether the connection pool is sized correctly.

A 2ms driver advantage is academic when the query itself takes 400ms because it is performing a sequential scan on a table with 10 million rows. I have seen teams spend a week benchmarking drivers, choose asyncpg for its 0.6ms advantage, and then deploy an application whose slowest query takes 3,200ms because nobody ran EXPLAIN ANALYZE on the dashboard endpoint. The driver saved microseconds. The missing index cost seconds.

Address the query first. Then the connection pool. Then, if you are still looking for performance, the driver. That is the order of magnitude. That is the order of attention.

This is, if I may mention it, rather the point of Gold Lapel. It sits between your FastAPI application and PostgreSQL — regardless of which driver you choose — and optimizes the queries themselves. Missing indexes are created automatically. Expensive joins are materialized. The 400ms query becomes 2ms, and the 0.3ms driver difference between asyncpg and psycopg3 becomes the sort of thing one discusses at conferences rather than loses sleep over.

Choose whichever driver suits your deployment. Then attend to the queries. That is where the real performance lives.

Frequently asked questions

Terms referenced in this article

If the question of connection pooling has been nagging at you — and it should be, given PgBouncer's particular friction with prepared statements — I have written a rather thorough comparison of PostgreSQL connection poolers that takes up precisely where this conversation leaves off.