Configuring connect_args and server_settings for asyncpg

Put SQLAlchemy's own options in engine keyword arguments, asyncpg's in connect_args (ssl, timeout, command_timeout), and PostgreSQL run-time parameters in connect_args["server_settings"] (application_name, statement_timeout, timezone) — a setting in the wrong layer either raises TypeError or is silently ignored. This guide belongs to configuring async engines and connection pools.

Quick Answer

Three layers of configuration look interchangeable and are not. libpq-style names do not reach asyncpg, and PostgreSQL parameters do not belong at the top level of connect_args.

Four places a setting can live Four tiles. Engine keyword arguments configure SQLAlchemy itself: pool size, recycling, pre-ping, echo. connect_args are passed to asyncpg connect: ssl, timeout, statement cache sizes. server_settings inside connect_args become PostgreSQL run-time parameters for every connection: application_name, statement_timeout, timezone. And execution options are per statement or per connection: isolation level, schema translate map, yield_per. engine kwargs pool_size, pool_recycle pool_pre_ping, echo connect_args ssl, timeout statement caches server_settings application_name statement_timeout, timezone execution_options per statement isolation, schema map A setting in the wrong layer either raises TypeError or is silently ignored.

Before — settings in the wrong layers:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@db/shop",
    connect_args={
        "sslmode": "require",                  # libpq name: asyncpg has no such argument
        "connect_timeout": 10,                 # libpq name
        "application_name": "orders-api",      # a PostgreSQL parameter, not an asyncpg one
        "options": "-c statement_timeout=5000",
    },
)
# TypeError: connect() got an unexpected keyword argument 'sslmode'

After — each setting in the layer that understands it:

import ssl

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

ssl_context = ssl.create_default_context()

engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@db/shop",
    # SQLAlchemy's own options.
    pool_size=10,
    max_overflow=5,
    pool_pre_ping=True,
    pool_recycle=1800,
    # asyncpg's connect() options.
    connect_args={
        "ssl": ssl_context,
        "timeout": 10,             # seconds to establish the connection
        "command_timeout": 30,     # client-side ceiling per command
        # PostgreSQL run-time parameters, sent at start-up.
        "server_settings": {
            "application_name": "orders-api",
            "statement_timeout": "5s",
            "idle_in_transaction_session_timeout": "30s",
            "timezone": "UTC",
        },
    },
)
Session = async_sessionmaker(engine, expire_on_commit=False)

Every value in server_settings must be a string — asyncpg sends them verbatim as start-up parameters — and PostgreSQL accepts interval strings like "5s" as well as bare milliseconds for the timeout settings.

Execution Context & Async Workflow Integration

The three layers correspond to three different pieces of software.

Two timeouts, two effects Left: command_timeout is enforced by asyncpg in the client; when it fires the driver cancels the query and raises, but the server may still be finishing the statement. Right: statement_timeout is enforced by PostgreSQL; the server stops executing and returns an error, which also protects the database from queries issued by anything else. command_timeout (client) asyncpg cancels and raises TimeoutError in your code the server may still be working per connection or per call statement_timeout (server) PostgreSQL stops the statement QueryCanceledError protects the database itself per session, via server_settings Set both: the server-side one is the real limit, the client-side one bounds what your task waits for.

Engine keyword arguments configure SQLAlchemy: how many connections to pool, when to recycle them, whether to ping before handing one out, whether to echo statements. They never reach the driver.

connect_args is passed through to asyncpg.connect(). Its names are asyncpg's, which is why libpq spellings fail: ssl rather than sslmode, timeout rather than connect_timeout, and no options parameter at all. The full set worth knowing is small — ssl, timeout, command_timeout, server_settings, statement_cache_size, prepared_statement_cache_size, prepared_statement_name_func — and migrating from psycopg2 to asyncpg lists the translations.

server_settings is a dictionary asyncpg sends as PostgreSQL start-up parameters. They take effect before the first statement, with no extra round trip, and apply for the life of the connection. Anything settable with SET is settable here, which makes it the right place for per-service defaults.

The two timeouts are worth separating clearly, because they fail differently. command_timeout is enforced in the client: asyncpg stops waiting, cancels the query and raises, which bounds how long your task is blocked. statement_timeout is enforced by PostgreSQL: the server stops executing and returns an error, which bounds how long the database works. A client-side timeout alone can leave the server still executing; a server-side timeout alone leaves your task waiting for a cancellation round trip. Setting both, with the client value slightly higher, covers each case.

idle_in_transaction_session_timeout is the one most often missing, and the most valuable. A session that opens a transaction and then stops working — a leaked session, a task cancelled at the wrong moment, a developer's psql window — holds its locks and prevents vacuum from cleaning up rows for as long as it lives. Thirty seconds turns that into a terminated backend rather than an incident, and it is exactly the failure described in detecting idle-in-transaction sessions from async code.

One caution about server_settings: search_path belongs there only for a single-tenant service. For schema-per-tenant routing, the connection is pooled and shared, so the schema has to change per request — which is what schema_translate_map is for, as described in switching schemas per request.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
TypeError: connect() got an unexpected keyword argument 'sslmode'A libpq parameter name in connect_args.ssl= with a mode string or an SSLContext.
TypeError: connect() got an unexpected keyword argument 'application_name'A PostgreSQL parameter at the top level of connect_args.Move it into server_settings.
InvalidParameterValueError: invalid value for parameter "statement_timeout": 5000A non-string value in server_settings.All values must be strings: "5000" or "5s".
TypeError: 'int' object is not subscriptable from asyncpg start-upserver_settings given a non-dict, or nested wrongly.A flat dict[str, str].
ssl.SSLCertVerificationError: certificate verify failedThe server's CA is not in the default trust store.ssl_context.load_verify_locations(cafile=...) with the provider's bundle.
Settings appear to be ignoredThey were placed in the URL query string, where only some are recognised.Use connect_args; the URL is for a small documented subset.
InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_1__" does not existPrepared statements behind a transaction-pooling proxy.statement_cache_size: 0 and a name function; see the PgBouncer guide.
server_settings on every connection Four steps. The pool needs a new connection and asyncpg connects. asyncpg sends the server_settings dictionary as start-up parameters, so they apply from the first statement with no extra round trip. PostgreSQL records them for that session, visible in pg_settings and pg_stat_activity. Every statement on that connection inherits them, including statements from migrations or background jobs that share the engine. pool opens a connection asyncpg.connect(...) with server_settings start-up parameters sent no extra round trip applied before the first query PostgreSQL records them pg_settings, pg_stat_activity per session every statement inherits them until the connection closes Because they are start-up parameters, a SET issued later can still override them per transaction.

Verifying that the settings actually arrived takes one query, and it is worth doing in a startup check rather than assuming — a typo in a server_settings key is accepted by PostgreSQL as a custom parameter and silently does nothing:

from sqlalchemy import text

EXPECTED = {
    "application_name": "orders-api",
    "statement_timeout": "5s",
    "idle_in_transaction_session_timeout": "30s",
    "TimeZone": "UTC",
}


async def verify_server_settings(engine) -> None:
    async with engine.connect() as conn:
        rows = await conn.execute(text(
            "SELECT name, setting FROM pg_settings WHERE name = ANY(:names)"
        ), {"names": list(EXPECTED)})
        actual = {name: setting for name, setting in rows}
    for name, expected in EXPECTED.items():
        if name not in actual:
            raise RuntimeError(f"{name} was not applied — check the server_settings key")

Note that PostgreSQL normalises some names and values: timezone is reported as TimeZone, and "5s" comes back as "5s" while "5000" comes back as "5s" too. Compare against what pg_settings reports rather than what you sent.

TLS deserves an explicit context rather than ssl="require". The string form asks for encryption without verifying the server's identity, which protects against passive eavesdropping and not against an impostor. A context built from the provider's CA bundle verifies both:

import ssl

ssl_context = ssl.create_default_context(cafile="/etc/ssl/certs/rds-global-bundle.pem")
ssl_context.check_hostname = True
ssl_context.verify_mode = ssl.CERT_REQUIRED

Advanced: Per-Service Settings and Measured Tuning

Different entry points in the same codebase want different settings, and copying connect_args into each one is how a timeout ends up missing from the worker. One function with role-specific overrides keeps them together:

Five settings worth having Five settings. application_name makes the service identifiable in pg_stat_activity. statement_timeout bounds any runaway query. idle_in_transaction_session_timeout kills sessions that hold a transaction open without working. timezone set to UTC makes date functions deterministic across hosts. And jit off is often worth measuring on short OLTP queries, where planning with JIT can cost more than it saves. application_name: 'orders-api' pg_stat_activity shows which service holds each connection statement_timeout: '5s' any query that runs long fails instead of occupying a connection indefinitely idle_in_transaction_session_timeout: '30s' a leaked transaction cannot hold locks and block vacuum forever timezone: 'UTC' date_trunc and now() behave identically on every host jit: 'off' (measure first) for short queries the JIT compilation can cost more than the execution saves
import ssl
from typing import Any, Literal

from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

Role = Literal["api", "worker", "report", "migration"]

_BASE_SERVER_SETTINGS = {
    "timezone": "UTC",
    "idle_in_transaction_session_timeout": "30s",
}

_PER_ROLE: dict[Role, dict[str, Any]] = {
    "api":       {"pool_size": 10, "max_overflow": 5,  "statement_timeout": "5s"},
    "worker":    {"pool_size": 4,  "max_overflow": 2,  "statement_timeout": "60s"},
    "report":    {"pool_size": 2,  "max_overflow": 0,  "statement_timeout": "600s"},
    "migration": {"pool_size": 1,  "max_overflow": 0,  "statement_timeout": "0"},
}


def build_engine(url: str, role: Role) -> AsyncEngine:
    config = _PER_ROLE[role]
    return create_async_engine(
        url,
        pool_size=config["pool_size"],
        max_overflow=config["max_overflow"],
        pool_pre_ping=True,
        pool_recycle=1800,
        connect_args={
            "ssl": ssl.create_default_context(),
            "timeout": 10,
            "command_timeout": None if role == "migration" else 120,
            "server_settings": {
                **_BASE_SERVER_SETTINGS,
                "application_name": f"shop-{role}",
                "statement_timeout": config["statement_timeout"],
            },
        },
    )

Three of those choices are deliberate and worth copying. The report role gets a long statement timeout and a tiny pool, so a slow analytical query is allowed to run but cannot occupy the connections the API needs. The migration role disables both timeouts, because a CREATE INDEX legitimately takes longer than any request. And application_name carries the role, so pg_stat_activity grouped by it answers "who is holding these connections" immediately.

Two settings are worth measuring rather than copying.

jit is on by default in PostgreSQL 12 and later, and for short OLTP queries the compilation cost can exceed the execution saving — particularly with many partitions or wide row types. "jit": "off" in server_settings is a one-line experiment: measure p95 for a representative query mix before and after, as in benchmarking asyncpg vs psycopg throughput.

statement_cache_size controls asyncpg's prepared-statement cache. Leaving it at the default is right for a direct connection and wrong behind a transaction-pooling proxy. Do not set it to zero "to be safe": statement preparation is a real saving on a direct connection, and the cost of turning it off is paid on every query.

Finally, pool_recycle interacts with infrastructure rather than with PostgreSQL. Cloud load balancers and NAT gateways commonly drop idle TCP connections after a few minutes, well before any database timeout, so set it below the shortest idle timeout on the path — the reasoning in configuring pool_pre_ping to handle stale connections.

Keeping One Engine Definition for Every Entry Point

The failure this guide is really about is drift: four entry points, four copies of the connection arguments, and only one of them with the setting that matters. A single module that owns engine construction prevents it, and it costs nothing to adopt.

One place, or several Left: the web application, the worker and the migration script each build an engine with their own copies of the connection arguments, which drift apart until only one of them has the statement timeout. Right: one function returns the arguments, each caller overrides only what genuinely differs, and a new setting is added once. copied per entry point app, worker, migrations, tests four copies of connect_args only one has statement_timeout drift is invisible one settings function engine_kwargs(role="worker") overrides only what differs a new setting lands everywhere and is visible in review Worker and migration engines legitimately differ — smaller pools, longer timeouts — so make that explicit.
# shop/db.py — the only module that calls create_async_engine
import os

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

from shop.engine_config import Role, build_engine


def database_url() -> URL:
    return URL.create(
        "postgresql+asyncpg",
        username=os.environ["PGUSER"],
        password=os.environ["PGPASSWORD"],
        host=os.environ["PGHOST"],
        port=int(os.environ.get("PGPORT", 5432)),
        database=os.environ["PGDATABASE"],
    )


def engine_for(role: Role) -> AsyncEngine:
    return build_engine(database_url(), role)


engine = engine_for(os.environ.get("SERVICE_ROLE", "api"))     # type: ignore[arg-type]
Session = async_sessionmaker(engine, expire_on_commit=False)

Two properties follow. A grep -rn "create_async_engine" --include=*.py should find exactly one hit outside tests, which makes the invariant checkable. And adding a setting — a new server parameter, a TLS change, a pool adjustment — is one edit that reaches every entry point, including the ones nobody remembered.

Tests are the legitimate exception, and they should be explicit about it: a test engine wants NullPool or a small pool, no statement timeout that could make a slow test flaky, and its own application_name so a leaked test connection is identifiable in pg_stat_activity.

The last piece is disposing the engine when the process ends, which is not a configuration setting but belongs in the same module, next to the construction. An engine that is never disposed leaves connections to be collected by the garbage collector after the event loop has closed — the warning described in fixing garbage collector non-checked-in connection warnings — and in a process that forks workers, an engine created before the fork is shared in a way that corrupts connections, which disposing async engines on shutdown and in forked workers covers.

Frequently Asked Questions

Where does application_name go for asyncpg?

Inside connect_args["server_settings"], as a string. At the top level of connect_args it raises TypeError, because asyncpg's connect() has no such parameter.

What is the difference between command_timeout and statement_timeout?

command_timeout is asyncpg's client-side ceiling: it cancels and raises locally. statement_timeout is PostgreSQL's: the server stops executing. Set both, with the client value slightly higher.

Can I put these settings in the URL instead?

Only a small documented subset is recognised in the URL query string. Everything else belongs in connect_args, where it is explicit and does not need percent-encoding.

Should I set search_path in server_settings?

Only for a single-schema service. Because connections are pooled and shared, per-tenant schema selection must be per statement — use schema_translate_map instead.