Now-Next

SaaS architecture

current transaction is aborted in PostgreSQL (25P02)

Measured with psql, psycopg2, psycopg 3, SQLAlchemy and Django: where 'commands ignored until end of transaction block' comes from, and the silent commit.

Chris van Eijk, Now-Next · · 7 min read

ERROR: current transaction is aborted, commands ignored until end of transaction block (SQLSTATE 25P02) is never the real error. It means an earlier statement in the same transaction failed, and PostgreSQL now refuses everything except ending the transaction or rolling back to a savepoint. The error you need is the one before it — a duplicate key, a division by zero, a lock timeout — and it is often invisible because the code caught it and carried on.

The part that costs data is what happens next: on an aborted transaction, COMMIT does not fail. The server answers ROLLBACK, and conn.commit() in psycopg2 and psycopg 3 — and the end of a with block in psycopg2, psycopg 3, psycopg_pool and SQLAlchemy’s engine.begin() — returned without an exception while the rows were gone. Only the SQLAlchemy ORM raised (PendingRollbackError) when a flush had failed. We measured this on PostgreSQL 17.11 and 16.15, with psql, psycopg2 2.9.13, psycopg 3.3.6, SQLAlchemy 2.1.6 and Django 6.1.2.

What does “current transaction is aborted” mean?

In psql, inside one transaction:

begin;
select 1/0;
ERROR:  22012: division by zero
select 1;
ERROR:  25P02: current transaction is aborted, commands ignored until end of transaction block
commit;
ROLLBACK

The COMMIT came back with the tag ROLLBACK, without an error or a warning. Everything the transaction did before the error was undone. Outside a transaction block, in autocommit, the same failing statement just fails and the next one runs; 25P02 only exists inside a transaction block. psycopg2, psycopg 3 and SQLAlchemy open one implicitly on the first statement; Django and psql run in autocommit until you open one yourself (atomic(), begin).

In an aborted transaction PostgreSQL accepted ROLLBACK, COMMIT, END, ABORT and PREPARE TRANSACTION (all answered ROLLBACK), ROLLBACK AND CHAIN, and ROLLBACK TO SAVEPOINT to a savepoint that existed before the error. It refused with 25P02: select 1, show, set, release savepoint and a new savepoint. Rolling back to a savepoint name that did not exist gave 3B001 and left the transaction aborted.

Why does the application only show 25P02?

Because the first exception was caught. In psycopg2 with the default (no autocommit), the first failing insert raised psycopg2.errors.UniqueViolation (23505); every statement after it on that connection raised psycopg2.errors.InFailedSqlTransaction (25P02), a subclass of InternalError. Code with except Exception: pass around the insert, or a logger that only reports the last failure, shows only the second. The server log had the original error first, as ERROR: duplicate key value violates unique constraint … — search the log for the same backend just before the 25P02.

What each layer turned the situation into:

LayerFirst exceptionEvery query after it
psycopg2UniqueViolationInFailedSqlTransaction
psycopg 3UniqueViolationInFailedSqlTransaction
SQLAlchemy ORM, error during flush, caughtIntegrityErrorPendingRollbackError: This Session's transaction has been rolled back due to a previous exception during flush… — not 25P02, with the original error in its text
SQLAlchemy session.execute(text(…)) or a ConnectionIntegrityErrorInternalError: (psycopg2.errors.InFailedSqlTransaction) current transaction is aborted…
Django ORM inside atomic(), IntegrityError caughtIntegrityErrorTransactionManagementError: An error occurred in the current transaction. You can't execute queries until the end of the 'atomic' block.
Django raw connection.cursor() inside atomic()IntegrityErrorInternalError: current transaction is aborted…

So in Django and SQLAlchemy the same situation has two different names, depending on whether you went through the ORM or past it. And if no query follows the caught IntegrityError, Django raises nothing at all: leaving atomic() sent ROLLBACK, and with ATOMIC_REQUESTS the view returned 200 ok while the row it had just created was gone.

If the 25P02 is raised inside the except of the first error, Python’s traceback shows both, joined by During handling of the above exception, another exception occurred — the quickest place to find the original. And before committing you can see the state: psycopg 3’s conn.info.transaction_status was INERROR (psycopg2: TRANSACTION_STATUS_INERROR).

A psycopg 3 with block that catches a duplicate and carries on
  1. with psycopg.connect(…) as conn:Insert row 1: succeeds.
    Transaction open, one row written
  2. insert a duplicateRaises UniqueViolation; except catches it.
    transaction_status: INERROR
  3. end of the blockThe connection sends COMMIT; no exception.
    The server answers ROLLBACK
  4. afterwardsThe table is read.
    0 rows, also row 1

The same with psycopg2's with conn:, psycopg_pool's with pool.connection(), SQLAlchemy's with engine.begin() and an explicit conn.commit(), on PostgreSQL 16.15 and 17.11. With a savepoint around the duplicate insert, the rows before and after it were committed ([1, 3] in psycopg2).

How do you recover from an aborted transaction?

Roll back, or never let the failing statement abort the outer transaction. Each of these was measured to leave the connection usable:

  • conn.rollback() (psycopg2, psycopg 3) or session.rollback() (SQLAlchemy). Everything since the start of the transaction is gone, as it already was on the server.
  • autocommit = True: the failing statement fails alone and the next one runs. Right for scripts and reads, wrong for anything that has to be all-or-nothing.
  • psycopg2’s with conn:: if the exception leaves the block, the block sends ROLLBACK and the connection stays open. If you catch it inside the block, the block ends with COMMIT, the server answers ROLLBACK, and nothing is raised — the case in the figure above.
  • A savepoint around the statement that may fail, which keeps the rest of the transaction:
ToolSavepoint formWhat we saw
SQLsavepoint s; … rollback to savepoint s;the transaction went on
psycopg 3a nested with conn.transaction():SAVEPOINT "_pg3_2" / ROLLBACK TO "_pg3_2"; committed [6, 7]
SQLAlchemywith session.begin_nested():SAVEPOINT sa_savepoint_1; committed [1, 2, 3]
Djangoa nested with transaction.atomic():committed [1, 2]
psql\set ON_ERROR_ROLLBACK onpsql sent SAVEPOINT pg_psql_temporary_savepoint before and RELEASE after every statement; COMMIT kept the rows before and after the error

Two forms looked like savepoints and were not. In psycopg 3, catching the error inside a single conn.transaction() block without nesting gave InFailedSqlTransaction on the next query and 0 rows at the end. In Django, atomic(savepoint=False) as the inner block did not help. And psql’s ON_ERROR_ROLLBACK interactive only works in a terminal: run from a file or standard input, the script got 25P02 and ROLLBACK as if the setting were off.

A savepoint adds two statements to every statement it protects (SAVEPOINT and RELEASE, or ROLLBACK TO on error), so inside a loop of thousands of rows it is not free; for an import where duplicates are expected, insert … on conflict do nothing avoids the error instead of catching it (with the caveats measured in duplicate key value violates unique constraint).

Can a pool hand you an aborted connection?

Normally not. psycopg2’s ThreadedConnectionPool sent ROLLBACK when we returned a connection that was in an aborted transaction, and psycopg_pool did the same and logged WARNING: rolling back returned connection [INERROR]; the next borrower got a clean connection. SQLAlchemy’s pool also resets on return by default. Only when we switched that off, with pool_reset_on_return=None, did the next user of the connection get 25P02 on a plain select 1 — an error in one request showing up in another. If you see 25P02 on the first query of a request, look for a pool configured not to reset.

In a multi-tenant application the same reset matters for another reason: a connection that goes back to the pool should not keep the previous tenant’s context either. How a transaction-scoped tenant setting behaves in SQLAlchemy and Django is measured separately, and a lock timeout, which also aborts the transaction it happens in, is in canceling statement due to lock timeout.

What we did not measure

DBeaver, Odoo and Amazon Redshift, which appear in searches for this error but were not part of our setup. Server-side procedures that commit inside a CALL. asyncpg and other drivers.

All measurements are our own, on PostgreSQL 17.11 and 16.15 in containers, with psycopg2 2.9.13, psycopg 3.3.6, psycopg_pool 3.3.3, SQLAlchemy 2.1.6 and Django 6.1.2, on 21 October 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation