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...
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
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.
SELECT id, owner, balance FROM accounts WHERE id IN (1,2) ORDER BY id;
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.
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.
SELECT id, owner FROM accounts WHERE id = 1;
id | owner ----+------- 1 | Alice (1 row)
INSERT INTO accounts(id, owner, balance) VALUES (1, 'Alice', 0);
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
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.
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;
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
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.
SELECT id, owner, balance FROM accounts WHERE id IN (1,2) ORDER BY id;
id | owner | balance ----+-------+--------- 1 | Alice | 900 2 | Bob | 1100 (2 rows)
INSERT INTO accounts(id, owner, balance) VALUES (1, 'Alice', 0);
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
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.
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.