Incident Response

PostgreSQL "could not serialize access due to concurrent update": Why It Happens and How to Handle It

SQLSTATE 40001 is not a bug in PostgreSQL. It is the database refusing to give you a wrong answer. Here is where it comes from, which isolation levels raise it, and how to retry and reduce it without dropping back to unsafe code.

· 12 min read · AG Data Team

PostgreSQL "could not serialize access due to concurrent update" means your transaction tried to change a row that another transaction changed and committed after yours had taken its snapshot. PostgreSQL can either let you overwrite a change you never saw or abort you. At Repeatable Read and Serializable it aborts. The fix is almost never to make that error disappear. You make the application retry correctly, and you make collisions rare.

The messages and what they have in common

You will see one of these, usually from an ORM wrapped in its own exception type:

ERROR:  could not serialize access due to concurrent update

ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

There is also a sibling, could not serialize access due to concurrent delete, for a row that was deleted underneath you. All of these carry SQLSTATE 40001 (serialization_failure). That code is what your retry logic should match on, not the message text.

When each one appears

A minimal Repeatable Read reproduction:

-- session A                                -- session B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT stock FROM items WHERE id = 7;   -- 10
                                            UPDATE items SET stock = 9 WHERE id = 7;
                                            -- autocommit, done
UPDATE items SET stock = stock - 1 WHERE id = 7;
-- ERROR:  could not serialize access due to concurrent update

Step 1: retry the whole transaction, correctly

The documentation is direct about this: applications using these levels must be prepared to retry. The retry rules are short:

  1. Retry the entire transaction from BEGIN, including the application logic that decided what to write. If you re-run only the failed UPDATE with values computed from the old snapshot, you have rebuilt the lost-update bug the error was protecting you from.
  2. Match on SQLSTATE. 40001 can be retried unconditionally. 40P01 (deadlock, see our deadlock detected guide) is also worth retrying. Unique violations (23505) and exclusion violations (23P01) may be retried only with more care, because they can be persistent errors.
  3. Add a limit and backoff. The docs warn that under high contention many attempts may be needed. Cap the attempts, add jittered backoff, and log every retry so you can see the rate.
  4. Do not run external side effects inside the transaction. An email, payment API call or message published before the commit will repeat on every retry. Move such side effects after the commit or use an outbox table.
Never "fix" 40001 by catching it and continuing. Nothing in that transaction was committed, and data it read must not be trusted. Swallowing the exception turns a safe abort into silent data loss in the application layer.

Step 2: find out why collisions are frequent

-- which isolation level do sessions actually use?
SHOW default_transaction_isolation;
SELECT rolname, setconfig FROM pg_roles WHERE setconfig::text LIKE '%isolation%';

-- under SERIALIZABLE: how many predicate locks are held right now?
SELECT locktype, count(*) FROM pg_locks
WHERE mode = 'SIReadLock' GROUP BY locktype;

-- long transactions widen the conflict window
SELECT pid, now() - xact_start AS xact_age, state, left(query, 60)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL ORDER BY xact_start LIMIT 10;

Server logs show the failed statement, because log_min_error_statement defaults to error. Group them by statement text and you usually find one or two hot spots: a counter row, a "last updated" column on a parent record, a queue table, a balance.

Step 3: reduce the failure rate

On large databases

Key takeaways

PostgreSQL Transactions Isolation Troubleshooting
Work with us

Serialization failures piling up?

We find the transactions that collide, fix retry logic and indexing, and tune PostgreSQL so strict isolation stays safe and fast under production load.

Get in touch More from the blog