Handling Aurora failover and read-only transaction errors

After a failover, pooled connections stay healthy but point at a node that is now a reader, so writes fail with cannot execute INSERT in a read-only transaction — add a handle_error listener that marks SQLSTATE 25006 as a disconnect so SQLAlchemy invalidates the pool and reconnects through the cluster endpoint. This guide belongs to dialect-specific gotchas and driver quirks.

Quick Answer

pool_pre_ping tests whether a connection works, not whether it is connected to the right node. After a failover it works perfectly — against a read-only replica.

A healthy connection to the wrong node Five steps. The pool holds connections to the current writer. Aurora fails over: the old writer becomes a reader and the writer endpoint resolves to a different instance. The pooled connections are still open and still healthy, so pre-ping succeeds — they now point at a read-only node. The next INSERT fails with cannot execute INSERT in a read-only transaction. Only invalidating the pool makes the application reconnect through the endpoint and reach the new writer. pool holds connections to the writer healthy nothing wrong yet failover the old writer becomes a reader the endpoint now resolves elsewhere pre-ping succeeds SELECT 1 works fine the connection is alive the next write fails 25006 read-only transaction and keeps failing invalidate the pool reconnect through the endpoint This is the failure mode pre-ping cannot catch: the connection is not broken, it is misplaced.

Before — the error reaches the application and the pool keeps the connection:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@shop.cluster-abc.eu-west-1.rds.amazonaws.com/shop",
    pool_size=10,
    pool_pre_ping=True,      # passes: the connection is alive
)

# After a failover, every write on a pooled connection:
# sqlalchemy.exc.InternalError: (asyncpg.exceptions.ReadOnlySQLTransactionError)
# cannot execute INSERT in a read-only transaction
# ... and the connection goes back into the pool, so the next request fails too.

After — the error is treated as a disconnect, so the pool recovers:

from sqlalchemy import event
from sqlalchemy.ext.asyncio import create_async_engine

# Codes that mean "this connection is no longer usable for its purpose".
DISCARD_CONNECTION = {
    "25006",   # read_only_sql_transaction — this node is now a reader
    "57P01",   # admin_shutdown — the backend was terminated
    "57P03",   # cannot_connect_now — the server is starting up
}

engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@shop.cluster-abc.eu-west-1.rds.amazonaws.com/shop",
    pool_size=10,
    pool_pre_ping=True,
    pool_recycle=300,
)


@event.listens_for(engine.sync_engine, "handle_error")
def _treat_as_disconnect(context) -> None:
    original = context.original_exception
    code = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
    if code in DISCARD_CONNECTION:
        context.is_disconnect = True

Setting is_disconnect is what makes SQLAlchemy discard the connection and invalidate the rest of the pool, so the next checkout opens a fresh connection through the cluster endpoint — which by then resolves to the new writer.

Execution Context & Async Workflow Integration

An Aurora failover promotes a replica to writer and demotes the old writer to a reader. The cluster's writer endpoint is a DNS name whose record is updated to point at the new writer, so new connections reach the right node within a DNS TTL. Existing connections are unaffected as TCP connections — they remain open, to an instance that is now read-only.

Make the pool discard the connection Left: the read-only error propagates as an ordinary database error, the connection returns to the pool, and the next request that picks it up fails the same way — so the outage lasts until the pool happens to recycle every connection. Right: a handle_error listener marks the error as a disconnect, so SQLAlchemy invalidates the connection and the rest of the pool, and the next checkout reconnects through the endpoint. as an application error one request fails the connection goes back to the pool the next request fails too until every connection recycles as a disconnect ctx.is_disconnect = True the pool is invalidated the next checkout reconnects recovery in seconds SQLAlchemy already has the machinery; it just does not know that 25006 means "wrong node".

That is why pool_pre_ping does not help. Pre-ping runs a trivial statement at checkout to detect connections the server has closed; a SELECT 1 against a reader succeeds. The connection is healthy and useless, and nothing in the pool's model of health captures the difference.

SQLAlchemy does have the right machinery, in the form of disconnect handling. When the dialect judges an error to be a disconnect, the pool discards that connection and invalidates every other connection created before that moment, so the whole pool is refreshed as connections are returned. The handle_error event lets you extend that judgement, which is exactly what the listener above does: it tells SQLAlchemy that a read-only error means this connection is no longer usable for its purpose.

The recovery then takes about as long as one failover plus one DNS lookup: the first write fails, the pool is invalidated, and the next checkout connects through the endpoint to the new writer.

Three related settings shorten the window further.

pool_recycle in the low hundreds of seconds means any connection somehow missed by the invalidation is replaced soon anyway. It also covers the reverse case — a connection to a node that has become the writer again — and idle-timeout drops from load balancers, as configuring pool_pre_ping to handle stale connections describes.

The cluster writer endpoint must be the connection target. An instance endpoint names one specific node and cannot follow a failover at all: after promotion, it simply points at a reader forever.

asyncpg also supports target_session_attrs, which asks the server whether it is writable during connection set-up:

engine = create_async_engine(
    URL, connect_args={"target_session_attrs": "read-write"}, pool_pre_ping=True
)

With that, a connection attempt that lands on a reader is rejected at connect time rather than at the first write — useful when a stale DNS answer would otherwise put a fresh connection on the wrong node.

Resolving Warnings, Errors & Common Mistakes

Exact errorRoot CauseProduction Fix
ReadOnlySQLTransactionError: cannot execute INSERT in a read-only transactionThe pooled connection is to a node that is now a reader.Treat 25006 as a disconnect; connect via the cluster endpoint.
The same error repeating for minutes after a failoverThe connection went back into the pool and is handed out again.Same fix: is_disconnect invalidates the pool.
AdminShutdown: terminating connection due to administrator commandThe backend was terminated during failover or maintenance.Already a disconnect for SQLAlchemy; retry the unit of work.
CannotConnectNowError: the database system is starting upConnecting to an instance that is still recovering.Retry connection attempts with backoff at start-up.
Writes work, reads return stale dataReads are going to a replica with replication lag.Route reads deliberately, and read your own writes from the writer.
Failover recovery works in staging, not in productionStaging uses an instance endpoint, or a different pool size.Match the endpoint type and pool settings.
TimeoutError from the pool right after failoverEvery connection invalidated at once, and the reconnect storm exceeded pool_timeout.Slightly larger pool_timeout, and retry at the unit-of-work level.
Four codes worth treating as disconnects Four tiles. 25006, read-only SQL transaction, means the node is no longer the writer. 57P01, admin shutdown, means the backend was terminated, typically during a failover or a restart. 57P03, cannot connect now, means the server is starting up. And 08006, connection failure, is the plain broken-socket case SQLAlchemy already handles. 25006 read-only SQL transaction failover: wrong node 57P01 admin shutdown backend terminated 57P03 cannot connect now server starting up 08006 connection failure already a disconnect Only the first needs teaching: the others SQLAlchemy already recognises as disconnects.

Retrying deserves care, because a failover can interrupt a transaction whose outcome is unknown. A read is always safe to retry. A write is safe only if it is idempotent, or if the retry can determine whether the first attempt committed:

import asyncio
import logging

from sqlalchemy.exc import DBAPIError

log = logging.getLogger("db.retry")
RETRYABLE = {"25006", "57P01", "57P03", "08006", "08003"}


def _code(exc: DBAPIError) -> str | None:
    original = exc.orig
    return getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)


async def with_failover_retry(operation, *, attempts: int = 3, base_delay: float = 0.5):
    """Retry an idempotent unit of work across a failover."""
    for attempt in range(1, attempts + 1):
        try:
            return await operation()
        except DBAPIError as exc:
            if _code(exc) not in RETRYABLE or attempt == attempts:
                raise
            delay = base_delay * (2 ** (attempt - 1))
            log.warning("retrying after %s (attempt %d)", _code(exc), attempt)
            await asyncio.sleep(delay)

operation must open its own session, because the session that failed is no longer usable — its transaction was rolled back and its connection discarded. Making the unit of work a callable that takes no session, and opens one itself, is what makes the retry correct rather than superficially plausible.

For non-idempotent writes, add an idempotency key the operation checks first — the pattern in handling IntegrityError on concurrent inserts — so a retry after an unknown outcome cannot double-charge or double-ship.

Advanced: Reads, Replicas and Read-Your-Own-Writes

An Aurora cluster offers a reader endpoint that load-balances across replicas, and using it is the main reason to have replicas at all. It also introduces two problems that the failover handling above does not address.

Shortening the outage Four settings. A handle_error listener that turns read-only errors into disconnects, so the pool recovers itself. A short pool_recycle, so any connection missed by the invalidation is replaced quickly. Connecting through the cluster writer endpoint rather than an instance endpoint, so DNS can move. And a retry at the unit-of-work level for idempotent operations, so a request that hit the window succeeds on its second attempt rather than reaching the user. handle_error → is_disconnect for 25006 the pool invalidates itself instead of serving the wrong node repeatedly pool_recycle in the low hundreds of seconds any connection the invalidation missed is replaced soon after the cluster writer endpoint, never an instance endpoint an instance endpoint cannot follow a failover at all retry idempotent units of work once the failover window is seconds; one retry hides most of it

Replication lag. A replica is behind the writer by milliseconds to seconds. A request that writes and then reads its own write through the reader endpoint can legitimately not find it, which surfaces as a user creating something and being told it does not exist. The rule that avoids it is to read your own writes from the writer: route by operation, not by endpoint availability.

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

writer_engine = create_async_engine(WRITER_URL, pool_size=10)
reader_engine = create_async_engine(READER_URL, pool_size=10)

WriterSession = async_sessionmaker(writer_engine, expire_on_commit=False)
ReaderSession = async_sessionmaker(reader_engine, expire_on_commit=False)

Two engines and two factories make the choice explicit at every call site, which is clumsier than a clever router and much easier to reason about during an incident. The more automatic approaches — a session that picks a bind per operation — are covered in routing reads to replicas with async engines.

Failover affects the reader endpoint too. During a failover the promoted replica leaves the reader pool and the demoted writer joins it, so reader connections are also invalidated and re-established. The same handle_error listener should be installed on both engines — a read against a node being restarted raises 57P01 just as a write does.

Write the listener once and apply it to every engine:

from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine

DISCARD_CONNECTION = {"25006", "57P01", "57P03"}


def install_failover_handling(engine: AsyncEngine) -> None:
    @event.listens_for(engine.sync_engine, "handle_error")
    def _treat_as_disconnect(context) -> None:
        original = context.original_exception
        code = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
        if code in DISCARD_CONNECTION:
            context.is_disconnect = True


for engine in (writer_engine, reader_engine):
    install_failover_handling(engine)

One caution about the reader endpoint and 25006: a reader connection is legitimately read-only, so a write attempted on it produces the same error for an entirely different reason — a routing bug rather than a failover. Marking it a disconnect on the reader engine is harmless but hides the bug, so it is worth logging the endpoint alongside the code, and asserting in tests that read-only sessions are never handed write operations.

Testing Failover Behaviour Before It Happens

Failover handling is code that runs once or twice a year, in the worst conditions, and is therefore worth testing deliberately rather than hoping. Three tests are practical without an actual Aurora cluster.

Where the failover is handled Left: the application connects to the cluster endpoint, so it owns failover handling — invalidation, recycling and retries. Right: RDS Proxy sits in front, keeps its own connections to the cluster, and holds client connections open across a failover, so the application sees a pause rather than read-only errors; it also caps the connections the cluster sees. direct to the cluster endpoint the application handles failover invalidate, recycle, retry DNS TTL matters no extra component through RDS Proxy the proxy handles failover client connections held open connection count capped watch session pinning A proxy does not remove the need for retries: a write in flight at the moment of failover still fails.

Force a read-only transaction. PostgreSQL's default_transaction_read_only reproduces exactly the error a demoted writer produces, on any instance:

import pytest
from sqlalchemy import text
from sqlalchemy.exc import DBAPIError

from shop.models import Customer


@pytest.mark.asyncio
async def test_read_only_error_invalidates_the_pool(engine, session_factory):
    pool = engine.sync_engine.pool

    async with session_factory() as session:
        await session.execute(text("SET default_transaction_read_only = on"))
        session.add(Customer(email="a@example.com"))
        with pytest.raises(DBAPIError):
            await session.commit()

    # The listener marked it a disconnect, so the pool discarded the connection.
    assert pool.checkedout() == 0
    async with session_factory() as session:          # a fresh connection
        session.add(Customer(email="b@example.com"))
        await session.commit()                        # succeeds

Terminate the backend. pg_terminate_backend against your own connection produces 57P01, which is what a failover does to in-flight work:

import pytest
from sqlalchemy import text


@pytest.mark.asyncio
async def test_survives_a_terminated_backend(session_factory):
    async with session_factory() as session:
        pid = await session.scalar(text("SELECT pg_backend_pid()"))
        await session.execute(text("SELECT pg_terminate_backend(:pid)"), {"pid": pid})
        # The next statement on this session fails; a new session works.
    async with session_factory() as session:
        assert await session.scalar(text("SELECT 1")) == 1

Verify the retry helper is actually idempotent. A retry test that only asserts "it eventually succeeded" misses the important failure: an operation applied twice. Assert the end state, not the return value — one row, one charge, one shipment.

Beyond tests, two production practices close the loop. Run a failover in a staging cluster deliberately, at a time of your choosing, and watch how long errors last — the number should be seconds, and if it is minutes the invalidation is not working. And alert on the error codes themselves: a sudden burst of 25006 or 57P01 is the clearest possible signal that a failover has happened, often arriving before the provider's own notification. Those codes belong in the metrics described in instrumenting and observing async queries.

Frequently Asked Questions

Why does pool_pre_ping not detect a failover?

Because the pooled connection is still healthy — it now points at a node that has become a reader. Pre-ping runs SELECT 1, which succeeds. Only a write reveals the problem.

What does SQLSTATE 25006 mean?

read_only_sql_transaction: the statement attempted a write in a read-only transaction. After a failover it means the connection is to a demoted writer, so the connection should be discarded rather than retried in place.

How do I make SQLAlchemy discard a connection on a specific error?

Set context.is_disconnect = True in a handle_error event listener. SQLAlchemy then invalidates that connection and the rest of the pool created before it.

Does RDS Proxy remove the need for this?

It shortens the outage by holding client connections open across a failover, but a write in flight at the moment of failover still fails. Keep the retry logic for idempotent units of work.