Incident brief
Could not serialize access due to concurrent update
The REPEATABLE READ concurrent-update form of SQLSTATE 40001: a REPEATABLE READ transaction attempted to update a row that a concurrently committed transaction had already changed. This page scopes to that exact form, the one reproduced in the lab below, not the general SERIALIZABLE case.
What lands in your log
ERROR: could not serialize access due to concurrent update
In 10 seconds
- What triggers it
- Open session A and start a REPEATABLE READ transaction.
- Fix
- Rollback and retry the unit of work.
- Proof
- Reproduced on PostgreSQL 16.14 → Session A's transaction was cancelled and rolled back, no data change from session A. Session B's update had already committed successfully before session A's failure.
Fix
What to do right now
Application-level steps for this error.
- Rollback and retry the unit of work.
- Apply exponential backoff in the application layer.
- Shorten the transaction so contention windows shrink.
-- application strategy
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
-- if this raises 40001: ROLLBACK, then retry the whole transaction
-- from the beginning with bounded exponential backoffFor this error
See this error live on the server
Run these against the affected instance to confirm the diagnosis before you act.
Find overlapping transactions and confirm the isolation level before adding retries. A 40001 retry must restart the whole transaction.
Old transactions that can overlap the retry
Long REPEATABLE READ/SERIALIZABLE transactions widen the window for concurrent-update serialization failures.
SELECT pid, usename, application_name, backend_xid, backend_xmin,
now() - xact_start AS transaction_age,
wait_event_type, wait_event, left(query, 120) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND pid <> pg_backend_pid()
ORDER BY xact_start;Isolation defaults in force
Confirm whether the application or database default is using an isolation level that can raise 40001.
SELECT current_setting('default_transaction_isolation') AS default_isolation,
current_setting('transaction_isolation') AS current_isolation;Why it happens
What PostgreSQL is telling you
The mechanism behind the error, grounded in the official manual, not paraphrased.
PostgreSQL 16 Documentation, §13.2.2 Repeatable Read Isolation Level
But if the first updater commits (and actually updated or deleted the row, not just locked it) then the repeatable read transaction will be rolled back with the message ERROR: could not serialize access due to concurrent update because a repeatable read transaction cannot modify or lock rows changed by other transactions after the repeatable read transaction began. When an application receives this error message, it should abort the current transaction and retry the whole transaction from the beginning. [...] Note that only updating transactions might need to be retried; read-only transactions will never have serialization conflicts.Read the full section on postgresql.org →
Session A
Session A read balance = 1000.00 under REPEATABLE READ, which fixes its snapshot at that moment. While it slept, session B changed the same row and committed. When session A finally tried to write, PostgreSQL's own error, "could not serialize access due to concurrent update", is its way of refusing to let session A commit a change based on data that is no longer current. PostgreSQL cancels the transaction instead of silently overwriting session B's committed update.Session B
Session B never touched a stale snapshot: it began, updated the row, and committed immediately. From PostgreSQL's perspective this transaction did nothing wrong, which is exactly why it succeeds, the conflict is entirely attributed to session A's outdated read.Reproduce & verify
A real, two-session PostgreSQL reproduction
A literal transcript of SQL run against a live PostgreSQL instance in an isolated lab. The commands below are exactly what was executed.
- 1Open session A and start a REPEATABLE READ transaction.
- 2Read the target row in session A and keep the transaction open.
- 3Open session B, update the same row, and commit.
- 4Return to session A and attempt to update the row.
Setup runs first, then session A begins, then session B begins while session A is still open.
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
id integer primary key,
balance numeric(10,2) not null,
updated_at timestamptz default now()
);
INSERT INTO accounts (id, balance) VALUES (1, 1000.00);BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT id, balance FROM accounts WHERE id = 1;
SELECT pg_sleep(3);
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;What PostgreSQL actually returned
DROP TABLE
CREATE TABLE
INSERT 0 1BEGIN
id | balance
----+---------
1 | 1000.00
(1 row)
pg_sleep
----------
(1 row)
ERROR: could not serialize access due to concurrent update
ROLLBACKBEGIN
UPDATE 1
COMMITRollback and retry, the textbook recovery path, is executed against this exact incident and the resulting data is shown, proving the recovery actually restores correctness. This is recovery after the fact, not a way to prevent the conflict from occurring.
Without this
Above: session A's transaction was cancelled and made no data change; session B's update had already committed.
With this, tested
Below: the same update, resubmitted as a fresh transaction after rollback, committed cleanly, no error, no lost update.
- A second operational test: exact SQL, raw output, measured result, and engineer notes
- Fix that hides the error but loses correctness: exact SQL, output, and verdict
- A measured default_transaction_isolation before/after
- Raw PostgreSQL server-log evidence
- A 10-run load-harness proof with an invariant check
- A manual-grounded production interpretation of the lab result
Card required. Cancel before day 7 and you are not charged.
Runbook to fix this
Runbooks for this incident
Full step-by-step fixes for the condition behind this error: the diagnosis, the exact SQL, and output captured in the lab.
- Build a safe job queue with SKIP LOCKEDSeveral queue workers all fight over the same top rows and serialize.Pro
- Catch a high transaction rollback rateA retry loop quietly rolls back four transactions in ten and everything looks fine.Pro
- Guarantee exactly-once settlements with idempotency keysA retried or double-clicked payout writes two settlements and finance has to claw one back.Pro
Connected
Everything this error touches
Every page this SQLSTATE connects to: the concept that explains it, the runbooks that fix it, the parameters you tune to prevent it, and the sibling errors it travels with. All real cross-references. Jump straight in, or open the full interactive map.
Understand the concept
Fix it — runbooks
Set sane statement, lock, and idle timeoutsStop idle-in-transaction sessions from holding locksBuild a safe job queue with SKIP LOCKEDCatch a high transaction rollback rateResolve row-level lock contention from SELECT FOR UPDATEGuarantee exactly-once settlements with idempotency keyshot_standby_feedback and the bloat it buysRead replication lag as three numbersTune to prevent it
Part of these pathways
Related errors
Verification
- Last verified
- 2026-07-14 (Docker lab, PostgreSQL 16.14)
- Verification scope
- Verified against PostgreSQL 16.14 in an isolated lab environment
- Audit status
- reviewed
Went further?
Pro unlocks the second lab proof
Free page stops the bleeding. Pro adds the operational test, SQLSTATE audit, and deeper evidence, same error, more certainty.