psycopg2 vs asyncpg for Extension Automation Scripts

A decision guide for choosing the PostgreSQL driver behind your extension-automation scripts — weighing psycopg2’s synchronous simplicity and autocommit control against asyncpg’s fleet-wide concurrency, specifically for ALTER EXTENSION and catalog work.

Up: ALTER EXTENSION Automation — this page is the driver-selection companion to that automation, under the Automated Execution & Rollback Workflows area of the site.

Context & When This Applies

Both drivers can run every statement an extension pipeline needs, so the choice is about shape, not capability. psycopg2 (or its successor psycopg 3 in sync mode) is a synchronous, battle-tested driver whose explicit autocommit and transaction control map cleanly onto the “run this one ALTER EXTENSION correctly” problem. asyncpg is an asynchronous driver built for high-concurrency I/O, which shines when you are upgrading many databases across a fleet and want them to proceed in parallel without a thread per connection. This guide applies to Python 3.8+ automation on PostgreSQL 12–17; the decision usually comes down to whether your unit of work is one careful transition or hundreds of concurrent ones.

Getting it wrong is rarely fatal but costs you either way: asyncpg for a single sequential upgrade adds an event-loop and cancellation-semantics burden you do not need; psycopg2 for a 500-node fleet sweep serializes work that could have overlapped, stretching the window. The safest default for most extension work is psycopg2 for its transaction clarity, reaching for asyncpg only when fleet concurrency is the bottleneck.

Choosing psycopg2 or asyncpg by the shape of the work The driver decision turns on the shape of the work. If the unit of work is a single careful extension transition, a small script, or anything needing precise autocommit and transaction control, psycopg2 is the simplest correct choice. If the work is a high-concurrency sweep upgrading many databases in parallel where connection I/O is the bottleneck, asyncpg's asynchronous model runs them concurrently without a thread per connection. Both can run any ALTER EXTENSION statement; the difference is concurrency model and transaction ergonomics, not capability. many DBs in parallel? psycopg2 (sync) explicit autocommit + transactions simplest for one careful transition battle-tested, minimal machinery asyncpg (async) concurrent connections, no thread each fleet sweep where I/O dominates event loop + cancellation to manage no yes

Concept: Autocommit Ergonomics vs Concurrency Model

The two properties that actually matter for extension work are autocommit ergonomics and concurrency model. Some extension operations must run outside a transaction block — TimescaleDB’s ALTER EXTENSION ... UPDATE spawns background workers and errors inside BEGIN — so precise control over autocommit is not a nicety. psycopg2 exposes this directly as conn.autocommit = True, and its synchronous flow makes “this statement runs standalone, that block runs in a transaction with a savepoint” easy to read and reason about. asyncpg is autocommit-by-default (statements outside an explicit async with conn.transaction() commit immediately), which is convenient but means the transaction boundary is implicit unless you opt in.

The concurrency model is the other axis. psycopg2 is one-connection-one-thread: upgrading 500 databases means a thread pool or sequential loop. asyncpg multiplexes many connections on one event loop, so 500 concurrent upgrades cost 500 coroutines, not 500 threads — a decisive advantage when the work is I/O-bound fan-out rather than CPU. But that power comes with async cancellation semantics: a cancelled coroutine mid-ALTER EXTENSION interacts with the non-transactional steps catalogued in ALTER EXTENSION Automation in ways you must handle deliberately. The idempotent orchestration layer is what makes either driver’s retries safe.

Dimension psycopg2 (sync) asyncpg (async)
Concurrency Thread per connection Many connections per loop
Autocommit control Explicit conn.autocommit Autocommit default; opt-in transactions
Best unit of work One careful transition High-fan-out fleet sweep
Transaction clarity High — synchronous, explicit Implicit unless transaction() used
Cancellation risk Low Async cancel needs deliberate handling
Ecosystem maturity Very mature Mature, async-first

Runnable Implementation

The same guarded update in both drivers, so the trade-off is concrete. Both set autocommit correctly for a TimescaleDB-style non-transactional update.

# --- psycopg2: synchronous, explicit autocommit ---
import psycopg2


def update_extension_sync(dsn: str, name: str, target: str) -> str:
    conn = psycopg2.connect(dsn)
    conn.autocommit = True                      # required: no transaction block
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT extversion FROM pg_extension WHERE extname = %s",
                        (name,))
            row = cur.fetchone()
            if row and row[0] == target:
                return "noop"
            cur.execute(f'ALTER EXTENSION "{name}" UPDATE TO %s', (target,))
        return "updated"
    finally:
        conn.close()
# --- asyncpg: one coroutine per database, run many concurrently ---
import asyncio
import asyncpg


async def update_extension_async(dsn: str, name: str, target: str) -> str:
    conn = await asyncpg.connect(dsn)           # autocommit by default
    try:
        installed = await conn.fetchval(
            "SELECT extversion FROM pg_extension WHERE extname = $1", name)
        if installed == target:
            return "noop"
        # Run standalone (no transaction) so a bgworker-spawning update succeeds.
        await conn.execute(f'ALTER EXTENSION "{name}" UPDATE TO $1', target)
        return "updated"
    finally:
        await conn.close()


async def sweep(dsns: list[str], name: str, target: str) -> list[str]:
    # 500 databases upgraded concurrently on one event loop.
    return await asyncio.gather(
        *(update_extension_async(d, name, target) for d in dsns))

Whichever you choose, guard the update on the installed version (idempotency) and keep a restore point via Snapshot & Point-in-Time Recovery.

Expected Output & Verification

Both return the same per-database verdict — "noop" or "updated" — so the calling pipeline is driver-agnostic. Verify the result the same way regardless of driver:

-- Confirm the observed version matches the intended target post-run.
SELECT extname, extversion FROM pg_extension WHERE extname = 'timescaledb';

For an asyncpg sweep, also confirm concurrency actually helped: the wall-clock for 500 databases should approach the slowest single upgrade, not their sum. If it does not, the bottleneck is a shared resource (the pooler, disk) rather than connection I/O, and the async model buys nothing — a signal to reconsider per Threshold Tuning for Downtime Windows.

Edge Cases & Gotchas

Neither driver may wrap a non-transactional update in a transaction. A TimescaleDB update inside BEGIN fails identically in both:

ERROR:  ALTER EXTENSION ... UPDATE cannot run inside a transaction block

With psycopg2, set conn.autocommit = True. With asyncpg, run the statement outside any async with conn.transaction() block, since asyncpg would otherwise wrap it when you opt into transactions.

asyncpg cancellation can strand a non-transactional step. If a coroutine running ALTER EXTENSION is cancelled (a timeout, a gather sibling failing) after a background-worker registration commits, the catalog is ahead of the pipeline’s belief. Handle cancellation explicitly and re-run the idempotent orchestrator to converge.

psycopg2’s default is a transaction, not autocommit. Forgetting conn.autocommit = True silently opens a transaction, so a non-transactional update fails and an ordinary one holds a lock longer than expected. Set autocommit deliberately per operation.

Connection limits bite the async sweep first. 500 concurrent asyncpg connections can exhaust max_connections or the pooler. Bound concurrency with a semaphore rather than an unbounded gather, and route through the connection pooler the way Threshold Tuning for Downtime Windows accounts for drain time.