Cookbook/ Transactions & Concurrency/ Current Transaction Is Aborted Cascade
Transactions & Concurrency · Runbook

Current Transaction Is Aborted Cascade

After one statement fails inside a transaction, every following statement returns 'current transaction is aborted, commands ignored until end of...

🧩 5 steps ⏱ ~15 min 📊 Intermediate 🐘 PostgreSQL 14--18 Last reviewed June 26, 2026

In 10 seconds

A nightly job that posts account transfers started logging a wall of 'ERROR: current transaction is aborted, commands ignored until end of transaction block', and the transfers stopped landing. The transaction does an 'ensure the account row exists' INSERT before moving money. That INSERT collides with an already-present primary key (23505), which flips the transaction into the aborted state. Every statement after it -- including the two UPDATEs that actually move the money -- is rejected with 25P02, and the final COMMIT is downgraded to ROLLBACK. The flood of 25P02 lines is a symptom; the single 23505 at the top is the disease.

Why This Happens

PostgreSQL transactions are all-or-nothing. The moment any statement raises an error inside a transaction block, the backend marks the whole transaction as aborted. From that point it refuses to execute further commands -- returning 25P02 -- until the block is closed with COMMIT or ROLLBACK, and either way the result is a rollback.

This protects integrity: it is impossible to half-apply a transaction after part of it failed. So 25P02 is never the root cause; it is the consequence of an earlier error that was not handled. Here the earlier error is an unguarded upsert-style INSERT (an 'ensure exists' that assumed the row was absent).

The cure is to keep that first statement from failing -- INSERT ... ON CONFLICT DO NOTHING turns the collision into a harmless no-op -- or, when a step legitimately may fail, to isolate it with a SAVEPOINT so the rest of the transaction can still commit. ON CONFLICT DO NOTHING turns the collision into a harmless no-op -- or, when a step legitimately may fail, to isolate it with a SAVEPOINT so the rest of the transaction can still commit.

DIAGNOSE

Diagnose

Free Tier -- Basic Approach

Confirm the blast radius. The transaction was supposed to move 100 from Alice to Bob; check their balances and see that nothing changed -- the entire transaction was discarded, not just the failing statement.

SQL
SELECT id, owner, balance FROM accounts WHERE id IN (1,2) ORDER BY id;
OUTPUT -- captured on real PostgreSQL
id | owner | balance
----+-------+---------
  1 | Alice |    1000
  2 | Bob   |    1000
(2 rows)

SELECT id, owner, balance FROM accounts WHERE id IN (1,2); both are still at 1000, proving the transfer never persisted.

PRO

Advanced Approach -- Production-Safe

The 25P02 lines are noise -- find the FIRST error, the real cause. Show the row that already existed, then reproduce the bare offending INSERT outside the poisoned transaction so the true 23505 surfaces cleanly with its DETAIL.

SQL
SELECT id, owner FROM accounts WHERE id = 1;
OUTPUT -- captured on real PostgreSQL
id | owner
----+-------
  1 | Alice
(1 row)
SQL
INSERT INTO accounts(id, owner, balance) VALUES (1, 'Alice', 0);
OUTPUT -- captured on real PostgreSQL
ERROR:  23505: duplicate key value violates unique constraint "accounts_pkey"
DETAIL:  Key (id)=(1) already exists.
SCHEMA NAME:  public
TABLE NAME:  accounts
CONSTRAINT NAME:  accounts_pkey
LOCATION:  _bt_check_unique, nbtinsert.c:666

SELECT id, owner FROM accounts WHERE id = 1 shows Alice already exists; then a bare INSERT (1,'Alice',0) raises 'duplicate key value violates unique constraint "accounts_pkey"' with DETAIL Key (id)=(1) already exists -- the statement that aborted the transaction.

FIX

Fix

Keep the first statement from failing. The 'ensure exists' INSERT should be a no-op when the row is already there, so add ON CONFLICT (id) DO NOTHING. The transaction stays healthy and the transfer commits. This is the literal capture.

SQL
BEGIN;
INSERT INTO accounts(id, owner, balance) VALUES (1, 'Alice', 0) ON CONFLICT (id) DO NOTHING;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
OUTPUT -- captured on real PostgreSQL
BEGIN
INSERT 0 0
UPDATE 1
UPDATE 1
COMMIT

BEGIN; INSERT (1,'Alice',0) ON CONFLICT (id) DO NOTHING (returns INSERT 0 0, no error); UPDATE -100 on id 1; UPDATE +100 on id 2; COMMIT -- all four lines succeed.

VERIFY

Verify

Confirm the healthy state: the transfer persisted (Alice 900, Bob 1100), and the primary-key constraint is still enforced -- a bare duplicate INSERT still raises 23505. We handled the conflict; we did not weaken the rule.

SQL
SELECT id, owner, balance FROM accounts WHERE id IN (1,2) ORDER BY id;
OUTPUT -- captured on real PostgreSQL
id | owner | balance
----+-------+---------
  1 | Alice |     900
  2 | Bob   |    1100
(2 rows)
SQL
INSERT INTO accounts(id, owner, balance) VALUES (1, 'Alice', 0);
OUTPUT -- captured on real PostgreSQL
ERROR:  23505: duplicate key value violates unique constraint "accounts_pkey"
DETAIL:  Key (id)=(1) already exists.
SCHEMA NAME:  public
TABLE NAME:  accounts
CONSTRAINT NAME:  accounts_pkey
LOCATION:  _bt_check_unique, nbtinsert.c:666

Balances now read Alice 900, Bob 1100; a fresh bare INSERT (1,'Alice',0) still errors with 'duplicate key value violates unique constraint "accounts_pkey"', which is the correct, expected behaviour.

⏪ Rollback

All work runs in a throwaway database on a disposable test environment, so cleanup is just dropping that database; the persistent demo containers are never altered. The broken transaction already rolled itself back, so there is nothing to undo from the failure -- the books are exactly as they were before it ran. The fix (ON CONFLICT DO NOTHING) and the prevention (SAVEPOINT) are ordinary committed statements; if you needed to back them out you would issue a compensating transfer, not a low-level rollback. Because a failed transaction discards everything, the safe operational stance is: on any error, ROLLBACK and retry the whole unit of work from a known state rather than trying to continue inside the aborted transaction.

📦 Version Matrix (PG 14--18)

Run the same break-and-fix on PostgreSQL 14, 15, 16, 17, and 18: a collision inside a transaction yields the identical 'current transaction is aborted' cascade on every version, and the ON CONFLICT version commits the transfer identically (transfer_ok with Alice at 900). Aborted-transaction semantics are uniform across all supported releases.

Version Behavior Version Notes
PG 14.23 broken=current transaction is aborted ✗ Not available -- use stats_reset age
PG 15.18 broken=current transaction is aborted ✗ Not available -- use stats_reset age
PG 16.14 broken=current transaction is aborted ✗ Not available -- use stats_reset age
PG 17.10 broken=current transaction is aborted ✗ Not available -- use stats_reset age
PG 18.4 broken=current transaction is aborted ✗ Not available -- use stats_reset age

Common Mistakes

Reading only the last error: the 25P02 lines are the tail of the cascade. The first error above them (here 23505) is the one that actually needs fixing; treating 25P02 as the problem leads you in circles.
Continuing to issue statements after a failure: once a transaction is aborted, every further command is ignored. Application code must detect the first error and either roll back or recover via a savepoint, not keep sending work.
Assuming the statements before the failure were saved: a transaction rolls back as a unit, so even the successful UPDATE that ran first is discarded. Nothing partial survives.
'Fixing' it by removing the unique constraint: the constraint is doing its job. Drop it and you trade a clear error for silent duplicate rows. Handle the conflict (ON CONFLICT) instead of disabling the rule.
Using ON CONFLICT DO NOTHING when you actually needed the row updated: DO NOTHING is right for 'ensure exists'. If the intent is upsert-with-update, use ON CONFLICT (id) DO UPDATE SET ... or you will silently skip a needed change.
🔒 Pro · Prevent

Stop this incident before it pages you

The free runbook clears today's failure. Pro adds the standing guardrails -- the alert query, the dashboard, and the managed-service knobs -- so it never becomes an incident again.

  • A ready-to-paste alert query with the right thresholds.
  • The topology change that prevents recurrence.
  • The exact knobs on RDS, Azure Flexible Server & Cloud SQL.
Unlock Pro$24.99/mo · $199/yr

Frequently Asked Questions

Why does every statement after the first error say 25P02?

Because the transaction is already aborted. PostgreSQL will not run any further command in an aborted transaction block; it answers each one with 25P02 until you end the block with COMMIT or ROLLBACK (both of which roll back). It is a safety mechanism, not a new failure each time.

How do I find the real error in a sea of 25P02 messages?

Scroll to the first error that is NOT 25P02 -- that is the statement that aborted the transaction. In this recipe it is 23505 (duplicate key). To confirm, re-run just that statement outside a transaction and you will see the same root error cleanly.

Can I make one statement fail without killing the whole transaction?

Yes -- wrap it in a SAVEPOINT. If the statement errors, ROLLBACK TO SAVEPOINT clears only that sub-step and the transaction stays alive, so you can continue and COMMIT the rest. Without a savepoint, any error aborts the entire transaction.

Does psql have a way to stop on the first error instead of flooding 25P02?

Yes. Run scripts with \set ON_ERROR_STOP on (or psql -v ON_ERROR_STOP=1). Then the script halts at the first real error, so you see it immediately instead of a cascade of 25P02 from the statements that followed.

References

Keep going

Related & next steps