Using psycopg 3 async with SQLAlchemy

Use postgresql+psycopg:// with create_async_engine() — psycopg 3 serves synchronous and asynchronous engines from one package, so a codebase migrating gradually keeps one driver, one configuration shape and one exception hierarchy. This guide belongs to choosing between asyncpg and psycopg async drivers.

Quick Answer

psycopg 3 is the only PostgreSQL driver SQLAlchemy can use for both worlds, which is what makes it the pragmatic choice during a long async migration.

One driver for both worlds Left: psycopg2 for the synchronous code and asyncpg for the asynchronous code means two connection configurations, two sets of driver exceptions and two behaviours for types and timestamps. Right: psycopg 3 serves create_engine and create_async_engine from the same package and the same URL scheme, so configuration and behaviour are shared. psycopg2 + asyncpg two URL schemes two connect_args shapes two exception hierarchies behaviour differs per driver psycopg 3 for both postgresql+psycopg:// either way one connect_args shape one exception hierarchy identical type handling This is the main reason to choose psycopg 3: a long migration where both worlds coexist.

Before — two drivers, two configurations:

from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import create_async_engine

# Synchronous: psycopg2, libpq-style options.
sync_engine = create_engine(
    "postgresql+psycopg2://shop:secret@db/shop?sslmode=require",
    connect_args={"connect_timeout": 10, "options": "-c statement_timeout=5000"},
)

# Asynchronous: asyncpg, different parameter names entirely.
async_engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@db/shop",
    connect_args={"ssl": "require", "timeout": 10,
                  "server_settings": {"statement_timeout": "5000"}},
)

After — one driver, one configuration shape:

from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

URL = "postgresql+psycopg://shop:secret@db/shop"
CONNECT_ARGS = {
    "sslmode": "require",
    "connect_timeout": 10,
    "options": "-c statement_timeout=5000",
    "application_name": "orders-api",
}

sync_engine = create_engine(URL, connect_args=CONNECT_ARGS, pool_size=5)
async_engine = create_async_engine(
    URL, connect_args=CONNECT_ARGS, pool_size=10, pool_pre_ping=True
)
Session = async_sessionmaker(async_engine, expire_on_commit=False)

psycopg 3 keeps libpq's connection parameter names, so sslmode, connect_timeout and options work as they did with psycopg2 — which also means an existing connection string needs no translation. Install it as psycopg[binary] (or psycopg[c] where a compiler is available); the pure-Python fallback works but is slower on every statement.

Execution Context & Async Workflow Integration

psycopg 3 is a rewrite of psycopg with an asyncio API alongside the synchronous one, and SQLAlchemy's psycopg dialect drives both. create_engine("postgresql+psycopg://...") uses the synchronous connection class; create_async_engine with the same URL uses AsyncConnection. The SQL generated, the type handling and the exception classes are identical, which is the practical benefit: a service that runs a synchronous admin command and an async web process no longer has two driver behaviours to reason about.

Prepared statements, after the fifth run Four steps. The first executions of a statement are sent unprepared. After prepare_threshold executions, five by default, psycopg prepares the statement on the server and reuses the plan. Subsequent executions reference the prepared statement by name, which is faster and is what breaks behind a transaction-pooling proxy that may route the next statement to a different server connection. Setting prepare_threshold to None disables preparation entirely. first executions sent unprepared parse each time after prepare_threshold (5) PREPARE on the server plan reused later executions referenced by name faster, and proxy-sensitive prepare_threshold=None never prepared asyncpg prepares everything by default; psycopg lets you choose, which is useful behind a pooler.

Three configuration details differ from asyncpg and are worth setting deliberately.

Prepared statements are opt-in by threshold. psycopg prepares a statement on the server after it has been executed prepare_threshold times — five by default — and then reuses the plan. That is good for a long-lived connection and wrong behind a transaction-pooling proxy, where the next statement may land on a server connection that never prepared it. Behind PgBouncer in transaction mode, set connect_args={"prepare_threshold": None} to disable preparation entirely. asyncpg has the same problem with a different default and a different fix, both covered in handling asyncpg prepared statement errors with PgBouncer.

Windows needs the selector event loop. psycopg's async implementation does not work with the default ProactorEventLoop, so a Windows development machine needs the policy set before any loop is created:

import asyncio
import sys

if sys.platform == "win32":
    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

Timestamps behave like psycopg2, not like asyncpg. psycopg sends timestamps in a form the server interprets, so a naive datetime written to a timestamptz column is interpreted using the session's TimeZone rather than the client's local zone. That is a different failure mode from asyncpg's, and it is why a driver change calls for an audit of datetime handling — the subject of fixing timezone-aware datetime errors with asyncpg. The durable answer is the same for both drivers: timezone-aware columns and aware values everywhere.

Everything else in the application is unchanged. AsyncSession, loader options, stream() for server-side cursors and the pool all behave as they do on asyncpg, because they are SQLAlchemy features rather than driver features.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
ModuleNotFoundError: No module named 'psycopg'The package installed is psycopg2, which is a different distribution.pip install "psycopg[binary]".
InvalidSqlStatementName: prepared statement "_pg3_0" does not existPrepared statements behind a transaction-pooling proxy.connect_args={"prepare_threshold": None}.
RuntimeError: Psycopg cannot use the 'ProactorEventLoop' on WindowsThe default Windows event loop.asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()).
InterfaceError: the connection is closed after idle periodsThe server or a proxy closed the pooled connection.pool_pre_ping=True and a pool_recycle below the idle timeout.
Slower than expected on every statementThe pure-Python implementation is in use.Install psycopg[binary] or psycopg[c] and confirm with psycopg.pq.__impl__.
except psycopg2.errors.UniqueViolation no longer matchesThe driver changed; psycopg 3 exceptions live in psycopg.errors.Catch SQLAlchemy IntegrityError and compare SQLSTATE.
ProgrammingError: can't adapt type 'dict'A plain dict passed where a JSON adapter is needed in hand-written SQL.Use a mapped JSONB column, or psycopg.types.json.Jsonb(value).
What psycopg 3 brings Four tiles. One package serves both sync and async engines. COPY is available from the same cursor API in both worlds. Pipeline mode batches statements without waiting for each result, which makes executemany much faster. And client-side binding is available when a statement has to be sent as literal text, such as for a tool that logs complete SQL. sync + async in one driver create_engine and create_async_engine COPY from the cursor cursor.copy(...) same API both worlds pipeline mode batched round trips fast executemany ClientCursor client-side binding literal SQL when needed asyncpg remains the throughput leader for large result sets; these are the reasons to pick psycopg anyway.

Checking which implementation is installed takes one line and is worth doing in a startup log, because the pure-Python fallback is easy to get by accident in a container built without wheels:

import psycopg

print(psycopg.pq.__impl__)   # "binary", "c", or "python"

The portable way to handle errors is the SQLSTATE code, which comes from PostgreSQL and is therefore identical across psycopg2, psycopg 3 and asyncpg:

from sqlalchemy.exc import DBAPIError

UNIQUE_VIOLATION = "23505"
FOREIGN_KEY_VIOLATION = "23503"
DEADLOCK_DETECTED = "40P01"
SERIALIZATION_FAILURE = "40001"


def sqlstate(exc: DBAPIError) -> str | None:
    for candidate in (exc.orig, getattr(exc.orig, "__cause__", None)):
        code = getattr(candidate, "sqlstate", None) or getattr(candidate, "pgcode", None)
        if code:
            return code
    return None

Adopting that helper before changing drivers is what makes the change reversible: the error handling stops depending on which driver is underneath, so switching back and forth is a URL change rather than a code change. The rest of the driver-independent work is listed in migrating from psycopg2 to asyncpg, and it applies equally to a move to psycopg 3.

Advanced: Pipeline Mode, COPY and Server-Side Cursors

Three psycopg 3 capabilities are worth knowing because they change what bulk work costs.

Three things to set Three bands. Install the binary or C distribution rather than the pure-Python one, or connections are noticeably slower. Behind a transaction-pooling proxy, disable prepared statements with prepare_threshold set to None. And on Windows, select the selector event loop policy, because psycopg async does not work with the default proactor loop. install psycopg[binary] (or psycopg[c]) the pure-Python fallback works but is measurably slower behind PgBouncer: connect_args={"prepare_threshold": None} otherwise a prepared statement is looked up on a connection that never prepared it on Windows: WindowsSelectorEventLoopPolicy psycopg async is incompatible with the default ProactorEventLoop

Pipeline mode sends several statements without waiting for each result, which removes a round trip per statement. SQLAlchemy's psycopg dialect uses it for executemany, so a Core insert with a list of dictionaries is dramatically faster than the same call on psycopg2 — often close to COPY for moderate batches:

from sqlalchemy import insert

from shop.models import PageView


async def record_views(session, rows: list[dict]) -> None:
    await session.execute(insert(PageView), rows)     # pipelined executemany
    await session.commit()

COPY is available from the cursor in both the synchronous and asynchronous APIs, reached through the session's connection:

async def load_page_views(session, rows: list[tuple]) -> None:
    connection = await session.connection()
    raw = await connection.get_raw_connection()
    async with raw.driver_connection.cursor() as cursor:
        async with cursor.copy(
            "COPY page_views (viewed_at, customer_id, path, duration_ms) FROM STDIN"
        ) as copy:
            for row in rows:
                await copy.write_row(row)
    await session.commit()

Because it runs on the session's own connection, the load is inside the session's transaction — the same property the asyncpg version has, as described in loading rows with Postgres COPY through asyncpg. psycopg's copy() accepts text or binary format and takes rows as tuples, with adaptation handled by the driver, which makes it more forgiving about types than asyncpg's binary copy_records_to_table.

Server-side cursors back AsyncSession.stream(), so large reads work the same way they do on asyncpg:

from sqlalchemy import select

from shop.models import Order

result = await session.stream_scalars(
    select(Order).order_by(Order.id).execution_options(yield_per=1_000)
)
async with result:
    async for order in result:
        ...

What psycopg 3 does not match is asyncpg's raw throughput on large result sets: asyncpg's protocol implementation and binary decoding are faster, and the gap widens as row counts grow. That is the trade-off in one sentence — psycopg 3 for one driver across both worlds and richer features, asyncpg for maximum read throughput — and it is worth deciding with a measurement from your own workload, as benchmarking asyncpg vs psycopg throughput sets out.

Running Both Drivers From One Configuration

During a migration it is useful to be able to switch drivers without editing code, so the choice can be made per environment and reverted instantly if something misbehaves. Because SQLAlchemy hides the driver, that is a matter of building the URL and the connect arguments from one place.

Portable error handling Left: catching psycopg2.errors.UniqueViolation stops matching the moment the driver changes, and the handler silently falls through to a generic error path. Right: catching SQLAlchemy IntegrityError and comparing the SQLSTATE code works on every driver, because the code comes from PostgreSQL rather than from the client library. driver-specific except psycopg2.errors.UniqueViolation or asyncpg.exceptions.* stops matching on a driver change two branches to maintain SQLSTATE-based except IntegrityError as exc: if sqlstate(exc) == "23505" works on psycopg2, psycopg 3, asyncpg the code is PostgreSQL’s Adopt this before changing drivers: it is the change that makes the rest reversible.
# shop/db.py
import os
import sys
from typing import Any

from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

DRIVER = os.environ.get("PG_ASYNC_DRIVER", "psycopg")      # or "asyncpg"


def _connect_args(driver: str) -> dict[str, Any]:
    if driver == "asyncpg":
        return {
            "ssl": "require",
            "timeout": 10,
            "server_settings": {
                "application_name": "orders-api",
                "statement_timeout": "5000",
            },
        }
    return {
        "sslmode": "require",
        "connect_timeout": 10,
        "application_name": "orders-api",
        "options": "-c statement_timeout=5000",
    }


def build_url(driver: str) -> URL:
    return URL.create(
        drivername=f"postgresql+{driver}",
        username=os.environ["PGUSER"],
        password=os.environ["PGPASSWORD"],     # no escaping needed: URL.create handles it
        host=os.environ["PGHOST"],
        port=int(os.environ.get("PGPORT", 5432)),
        database=os.environ["PGDATABASE"],
    )


if DRIVER == "psycopg" and sys.platform == "win32":
    import asyncio

    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

engine = create_async_engine(
    build_url(DRIVER),
    connect_args=_connect_args(DRIVER),
    pool_size=10,
    max_overflow=5,
    pool_pre_ping=True,
)
Session = async_sessionmaker(engine, expire_on_commit=False)

URL.create() is worth using in preference to a formatted string: it escapes the password correctly, so a password containing @, / or % needs no attention, and it keeps the credentials out of any log line that prints the URL, because SQLAlchemy renders URL objects with the password masked.

With that in place, three things become easy. The integration suite can run under both drivers in CI, which surfaces behavioural differences early — type conversions, error message text, timestamp handling. A canary deployment can run one driver while the rest of the fleet runs the other, with the same image. And a rollback is an environment variable.

The remaining driver-specific code should be small enough to list: the connect arguments above, and anything that reaches driver_connection for COPY. Keeping those in one module, rather than at each call site, is what keeps the switch to a single file — and is the same discipline that makes the eventual choice, whichever way it goes, cheap to commit to.

Frequently Asked Questions

Can psycopg 3 be used for both sync and async engines?

Yes. postgresql+psycopg:// works with create_engine() and create_async_engine(), from one installed package with one configuration shape. That is its main advantage over running psycopg2 and asyncpg side by side.

Is psycopg 3 slower than asyncpg?

For large result sets, generally yes — asyncpg's protocol and decoding are faster. For small queries the difference is usually dominated by network latency, and psycopg 3's pipelined executemany is very fast for bulk inserts.

What do I set behind PgBouncer?

connect_args={"prepare_threshold": None} so statements are never prepared server-side, because transaction pooling can route the next statement to a different server connection.

Why does psycopg async fail on Windows?

Its async implementation is incompatible with the default ProactorEventLoop. Set WindowsSelectorEventLoopPolicy before any event loop is created.