SaaS architecture
could not serialize access due to concurrent update
PostgreSQL error 40001 'could not serialize access due to concurrent update', measured on 17: when it fires, why SKIP LOCKED does not avoid it, and the retry.
ERROR: could not serialize access due to concurrent update is PostgreSQL refusing to let a REPEATABLE READ or SERIALIZABLE transaction change a row that another transaction changed and committed after the first one took its snapshot. It is SQLSTATE 40001, it is not a bug, and the documented answer is to roll back and run the whole transaction again. Under the default READ COMMITTED the same situation raises no error; the second update overwrites the first one, or matches no row.
We reproduced it on PostgreSQL 17.11 with two sessions and a table of 1,000 jobs, and tried each variant: updates, SELECT … FOR UPDATE, the SKIP LOCKED queue pattern, SERIALIZABLE, and a retry loop under contention.
What does “could not serialize access due to concurrent update” mean?
The transaction asked to update, delete or lock a row whose newest version it is not allowed to see. In REPEATABLE READ a transaction works from the snapshot it took at its first statement. When it then tries to update a row that another transaction has changed and committed since that moment (actually updated or deleted it, not just locked it), its snapshot still shows the old version: updating that would undo the other change, and applying the update to the newer version would mean acting on data the transaction cannot see. So PostgreSQL rolls it back. The documentation: “a repeatable read transaction cannot modify or lock rows changed by other transactions after the repeatable read transaction began.”
REPEATABLE READ
- Session A
BEGIN ISOLATION LEVEL REPEATABLE READ;reads job 1:new.A's snapshot is taken here, at the first query - Session BSets job 1 to
takenand commits.Job 1:taken - Session ASets job 1 to
done: ERROR 40001, could not serialize access due to concurrent update.A's transaction is aborted; the next statement got25P02untilROLLBACK
READ COMMITTED, the default
- Session A
BEGIN;reads job 1:new.A snapshot per statement - Session BSets job 1 to
takenand commits.Job 1:taken - Session ASets job 1 to
done: UPDATE 1, no error.Job 1:done. B's change is gone
PostgreSQL 17.11. With … where id = 1 and status = 'new', the READ COMMITTED update reported UPDATE 0 and the job stayed taken; the same conditional update in REPEATABLE READ still raised 40001.
Three details decide whether you get it:
- The snapshot starts at the first query, not at
BEGIN. When session A only ranBEGINbefore B committed, and then read job 1 (it sawtaken) or updated it directly, the update succeeded. The documentation says the same: the snapshot is “as of the start of the first non-transaction-control statement in the transaction”. - It waits for an open change. When B had updated job 1 but not yet committed, A’s update did not return for as long as we watched (2 seconds). B committed: A got 40001. B rolled back instead: A’s update succeeded with
UPDATE 1. - Only statements that change or lock rows can fail this way. A
REPEATABLE READtransaction that only read kept seeingnewafter B’s commit and committed without error; the documentation: “read-only transactions will never have serialization conflicts”.
Does SELECT … FOR UPDATE SKIP LOCKED avoid it?
No, not in REPEATABLE READ. This matters for job queues, where FOR UPDATE SKIP LOCKED is the usual way for workers to claim work. In REPEATABLE READ, after B had claimed job 1 and committed, A’s select … where status = 'new' order by id limit 1 for update skip locked raised could not serialize access due to concurrent update instead of skipping to job 2. SKIP LOCKED skips rows that are locked right now; job 1 was no longer locked, it had been changed. Only while B’s transaction was still open did the same query skip job 1 and hand A job 2. Plain SELECT … FOR UPDATE on a row B had changed and committed raised the same error.
In READ COMMITTED the same queue query handed A job 2, without an error. That is one reason queue workers usually run in READ COMMITTED, with the claim condition (status = 'new') in the update or the locking query. How we built and measured such a queue per tenant is in job queues per tenant in PostgreSQL.
Should you retry on 40001?
Yes, and the whole transaction, not the failed statement. The documentation is explicit: “It is important to retry the complete transaction, including all logic that decides which SQL to issue and/or which values to use. Therefore, PostgreSQL does not offer an automatic retry facility…”. On the retry the transaction takes a new snapshot that includes the other change.
We ran two workers in parallel, each doing 200 transactions in REPEATABLE READ that read one of five jobs, chosen at random, and then incremented its counter:
| Workers ran | 40001 errors | Increments that arrived, of 400 |
|---|---|---|
REPEATABLE READ, no retry | 35 | 365 |
REPEATABLE READ, retry the whole transaction | 44 | 400 |
READ COMMITTED | 0 | 400 |
Without a retry, every 40001 was a lost increment. With a retry, all 400 arrived, after 44 failed attempts. These numbers are from one run each, with a pause of 5 ms between the read and the update to make the transactions overlap; they will differ per run. The message gave no hint here; in our runs, PostgreSQL attached HINT: The transaction might succeed if retried. only to the SERIALIZABLE variant below. The documentation also warns that “multiple retries may be needed” under heavy contention, so cap the number of attempts.
READ COMMITTED had no errors and lost nothing in this test because attempts = attempts + 1 reads the current value at the moment of the update. An update that writes a value the transaction read or computed earlier would overwrite the other worker’s change without an error, the way status = 'done' replaced taken in the figure.
could not serialize access due to read/write dependencies among transactions
That is the SERIALIZABLE variant, and it can fire even when the two transactions never update the same row. Two accounts held 100 each, with the rule that the total must not go below zero. Two transactions each read the total (200), each took 150 from a different account, and committed. In SERIALIZABLE the second COMMIT failed:
ERROR: 40001: 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.
The balances ended at −50 and 100. In REPEATABLE READ both transactions committed and the balances ended at −50 and −50: a total of −100, after each had read a total of 200. What SERIALIZABLE costs in failed transactions when it guards a counter, measured under load, is in invoice numbers per tenant without gaps.
What we did not measure
could not serialize access due to concurrent delete, the variant for a row another transaction deleted. Retry strategies with backoff, and how specific ORMs report and retry 40001. Higher numbers of workers.
Sources: PostgreSQL 17 documentation on transaction isolation and serialization failure handling, read on 16 October 2026. All measurements are our own, on PostgreSQL 17.11 in a container, on 16 October 2026.