DeepConcepts

Postgres / mvcc / isolation / ssi

Retrying a 40001: Everything the Second Attempt Must Forget

The misconception

That handling a serialization failure means catching the error and re-running the failed statement, and that once you have a retry loop the transaction is safe. Neither half holds. The statement did not fail, the transaction did — the connection is in a failed transaction block and will answer 25P02 to everything until you roll back. And a retry that carries a value read during the failed attempt into the new one reproduces exactly the anomaly the abort prevented, silently, with no error at any point: the model in this lesson loses 148 increments that way while reporting a perfect success rate. Meanwhile the loop itself is not free — on 2 contended rows, doubling from 8 clients to 16 buys 6% more completed work (342 to 363) while attempts per success go from 3.93 to 7.23; drop the backoff as well and the same 16 clients burn 106.9 seconds of server time inside an 8-second window and abandon 600 operations.

17 min

A retry loop that re-runs the failed transaction and re-reads everything finishes 342 operations and loses none. The same loop, changed in one place so that the second attempt reuses the value the first attempt already read, finishes exactly the same 342 operations — and 148 of them are wrong. No error is raised. No log line appears. The success rate is identical.

SQLSTATE 40001 is the serialization failure: the code Postgres returns when it will not let a transaction commit because the result would not correspond to any serial order of the transactions involved. You get it at REPEATABLE READ and SERIALIZABLE, described in isolation levels, and the documentation's instruction is one sentence long:

"When an application receives this error message, it should abort the current transaction and retry the whole transaction from the beginning. The second time through, the transaction will see the previously-committed change as part of its initial view of the database, so there is no logical conflict in using the new version of the row as the starting point for the new transaction's update."

Read the second sentence again, because it is the load-bearing one. The retry is safe because it gets a new initial view of the database. That is the entire mechanism. A retry that carries any value forward from the failed attempt has kept a piece of the old view, and the guarantee is void for exactly that value — quietly, because nothing checks.

The panel below is a workload plus a retry loop. Eight clients each run a read-modify-write transaction against a small number of hot rows: read a counter, spend some time in application code, write the counter back at one more than what they read. The retry policy is yours. So is the isolation level, and so is the one detail that decides whether the loop is correct.

Start at the defaults and read the two big numbers. Then untick the retry re-reads.

how the loop is written

Eight seconds of simulated wall clock, replayed from t = 0 whenever you change anything. "Hot rows" is how many distinct rows the clients spread themselves across: 1 means every transaction fights every other one, 16 means they mostly miss each other.

operations completed
silently wrong
40001 concurrent update
40001 read/write deps
gave up after retries
attempts per success
server-seconds wasted
p99 latency, ms
duplicate emails

Where the eight seconds went

One bar per client session, scaled to the busiest. operations that committed · a session that completed nothing.

A model of the two detectors, not a benchmark. Time is simulated and the absolute counts are only meaningful against each other. The write-conflict rule is exact: a transaction using a transaction-wide snapshot that tries to write a row committed after that snapshot gets 40001, which is the TM_Updated branch of nodeLockRows.c. The read/write-dependency rule is the dangerous structure from predicate.cTin →rw→ Tpivot →rw→ Tout with Tout committing first — checked as each edge is added rather than only at commit. Real Postgres tracks those edges at page and predicate granularity and can promote them under memory pressure, which produces conflicts this row-granular model never sees.

At the defaults — REPEATABLE READ, eight clients, two hot rows, three retries with exponential backoff — the loop completes 342 operations, absorbs 995 serialization failures, gives up on 192, and burns 3.93 attempts for every success. The counter and the number of successes agree exactly: nothing was lost.

Now untick the retry re-reads. The throughput is unchanged, 342. The error count is unchanged, 995. The silently wrong readout goes from 0 to 148: 148 increments that a client believed it had applied are not in the counter. Every one of those requests returned success. This is the whole lesson in one checkbox, and it is a checkbox because that is how the bug looks in real code — a variable read before the try block instead of inside it.

Then set the isolation level to READ COMMITTED. Throughput quadruples to 1,555, the error count drops to zero, the retry loop never runs, and 1,205 increments are lost. The fastest configuration is the wrongest one, and it is the default isolation level. Serialization failures are not a cost you are paying for nothing — they are the sound of a conflict being detected instead of being applied.

What the session looks like after the error

The first thing to fix is smaller than the retry logic. When a statement raises 40001, the transaction is over — not paused, not partially failed. The connection is in a failed transaction block, and every command you send it now answers with SQLSTATE 25P02, in_failed_sql_transaction, whose message text is one of the most-searched strings in Postgres: current transaction is aborted, commands ignored until end of transaction block.

Click through what a session does after the error. Each line is one statement sent to the server. Watch the prompt as well as the output: psql writes postgres=# outside a transaction, postgres=*# inside one, and postgres=!# inside one that has failed. That exclamation mark is the state your driver is in and your code cannot see.

outcome
result
psql transcript

The savepoint option is the one that catches people, because savepoints rescue a session from every other error. They do not help here, and the reason is not that Postgres refuses — it is that a savepoint does not give you a new snapshot. At REPEATABLE READ and SERIALIZABLE the snapshot belongs to the top-level transaction and is taken once. Roll back to a savepoint and you are still reasoning from the same frozen view of the database, so the statement that conflicted will conflict again. Only ROLLBACK followed by a new BEGIN produces the "new initial view of the database" the documentation's justification rests on.

This is also why the retry has to sit outside your transaction helper rather than inside it. If your framework's with transaction(): block catches the error, retries the body, and only then commits, it has retried inside a transaction that is already dead. The loop must own the BEGIN.

Two errors, one SQLSTATE

40001 arrives with one of two message texts, and they mean different things. Grep your logs for both, separately, because the fix differs.

could not serialize access due to concurrent update is a write conflict. Your transaction tried to modify a row version that was superseded after your snapshot was taken. It comes from the TM_Updated branch of the executor, guarded by IsolationUsesXactSnapshot() — true at REPEATABLE READ and SERIALIZABLE, false at READ COMMITTED, which is why the same workload produces zero errors and silent data loss one level down. Two transactions had to touch the same row. The fix is to touch fewer rows in common: shard the counter, move the write later in the transaction, or hold the row explicitly with SELECT FOR UPDATE and wait instead of failing.

could not serialize access due to read/write dependencies among transactions is a Serializable Snapshot Isolation abort — SSI, the algorithm that makes SERIALIZABLE non-blocking by tracking which transactions read what and aborting cycles after the fact. It only happens at SERIALIZABLE, and the two transactions involved need not have touched a single row in common. Switch the panel's scenario to the on-call roster and its level to SERIALIZABLE to produce them: each transaction reads two rows and writes a different one, so no write conflict exists to detect.

The rule is written out in the comment above OnConflict_CheckForSerializationFailure in src/backend/storage/lmgr/predicate.c:

/*
 * A serialization failure can only occur if there is a dangerous structure
 * in the dependency graph:
 *
 *		Tin ------> Tpivot ------> Tout
 *			  rw			 rw
 *
 * Furthermore, Tout must commit first.
 */

An rw edge from A to B means A read something B wrote, and A could not see the write — A comes before B in any serial order that could explain A's reads. Two of those in a row, pointing through the same transaction, with the far end already committed, is an anomaly that has already happened; the middle one is the pivot, and one of the three must be rolled back.

Two consequences the panel makes visible. First, the check runs "as we are about to add a RW-edge to the dependency graph" — not at commit — so a SERIALIZABLE transaction can be aborted in the middle of a statement it has not finished, before it ever sends COMMIT. Second, because the abort is about the graph rather than about rows, the transaction that dies is often not the one that did anything unusual. Every one of these errors carries an errdetail naming which of predicate.c's nine reason codes fired — for example Reason code: Canceled on identification as a pivot, during conflict out checking. — and an errhint that reads, verbatim, The transaction might succeed if retried. Log the detail line, not just the message: the message is the same for all nine, and the reason code is the only thing that tells you which position in the graph your transaction occupied.

There is one more surprise here worth stating plainly, because it contradicts a line people remember from the documentation. Under REPEATABLE READ, read-only transactions genuinely never fail — the documentation says so outright: only updating transactions might need to be retried; read-only transactions will never have serialization conflicts, and that sentence sits in the Repeatable Read section. Under SERIALIZABLE it stops being true. A read-only transaction can be flagged doomed by another backend that found it in a dangerous structure, and it then discovers this the next time it reads a modified tuple: CheckForSerializableConflictOutNeeded tests SxactIsDoomed(MySerializableXact) before every such read and raises 40001 with Reason code: Canceled on identification as a pivot, during conflict out checking. A second path fires when the conflicting transaction has been aged out to SLRU storage, giving Canceled on conflict out to old pivot. Both happen inside a SELECT, in a transaction that has written nothing. The escape hatch is a declaration, not a retry loop:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;

Such a transaction "may block when first acquiring its snapshot, after which it is able to run without the normal overhead of a SERIALIZABLE transaction and without any risk of contributing to or being canceled by a serialization failure." It waits for a snapshot that is provably safe. For a nightly report against a SERIALIZABLE workload, that is the right answer and no retry loop is needed at all.

The loop is not free

Set the panel to eight clients and note the numbers: 342 operations, 3.93 attempts each, 39.8 server-seconds of work thrown away. Now drag client sessions to 16. Operations go to 363 — six per cent more — while attempts per success climb to 7.23 and wasted server work reaches 90.2 seconds inside an eight-second window. Set backoff to none as well and it becomes 106.9 seconds, with 600 operations abandoned instead of 481.

That shape is not specific to this model. It is what optimistic concurrency control does past its saturation point: each additional client lowers everyone's chance of finishing, so the same amount of useful work is achieved by more attempts, and the extra attempts are themselves the contention. The documentation's advice for SERIALIZABLE leads with it — "Control the number of active connections, using a connection pool if needed" — and the reason a pool helps is precisely this: it caps the number of transactions that can be in flight against the same rows.

The retry budget is a different trade. With zero retries, 1,172 operations are abandoned and p99 latency is 40 ms. With three, 192 are abandoned and p99 is 198 ms. With eight, only 12 are abandoned and p99 is 1,323 ms. Every retry you add converts a fast failure into a slow success, and there is no setting that gives you both. Choose it from the caller's timeout, not from a sense of thoroughness: a retry that completes after the HTTP request has been abandoned is pure cost, and worse, if the transaction has side effects it is a cost the customer can see.

Which is the last property of a correct loop. Tick it emails the customer before COMMIT and watch duplicate emails reach 798 at the default settings. The transaction body runs once per attempt; anything in it that is not a database write happens once per attempt too. This is a real, shipped bug — one production ERP fixed it in a patch whose description reads: the retry was "creating and sending emails until no concurrency issue is found." The rule is that the transaction body must contain only work the database can undo. Queue the email as a row and send it after the commit.

Contention itself is the variable with the most leverage, and it is not in the retry loop at all. Drag hot rows from 2 to 16 with everything else at defaults: operations go from 342 to 1,036 and attempts per success from 3.93 to 1.43. Nothing about the loop changed. The workload stopped colliding.

Where retrying does not help

Three cases, in increasing order of how much time they waste.

A conflict that will recur every time. If two long-running transactions both read a large range and both write into it, retrying reproduces the same structure with the same participants. Postgres's own hint says the transaction might succeed if retried, and that word is doing work. A loop with unbounded retries on a genuinely repeating conflict is an infinite loop with a database bill. Always cap the attempts, and log the give-ups: a rising give-up rate is the signal that the fix is schema-shaped, not loop-shaped.

A false positive from lock promotion. SSI tracks predicate locks at tuple, page and relation granularity, and when the lock table runs short it combines finer locks into coarser ones. A relation-level predicate lock conflicts with any write to that table, so transactions that never overlapped begin failing. The documentation names the levers — max_pred_locks_per_transaction, max_pred_locks_per_relation, max_pred_locks_per_page — and adds the sharper diagnostic: "A sequential scan will always necessitate a relation-level predicate lock." A SERIALIZABLE workload whose failure rate jumped after a table grew past the point where the planner stopped using an index is not a concurrency problem; it is a plan problem wearing a concurrency costume.

An error that is not retryable at all. Retry 40001 and 40P01 (deadlock detected, which needs the identical loop) and nothing else. A unique-violation 23505 will not be fixed by another attempt, and neither will 25P02, which only means your loop is already broken. Match on SQLSTATE, never on message text: the strings are translated when the server runs under a non-English locale, and code that greps for "could not serialize" fails silently on a German-locale server.

Checking it on a real system

Postgres has no counter for serialization failures, which is the first problem. The cheapest proxy is the rollback ratio:

SELECT datname, xact_commit, xact_rollback,
       round(100.0 * xact_rollback / nullif(xact_commit + xact_rollback, 0), 2) AS pct_rollback
FROM pg_stat_database
WHERE datname = current_database();

That moves for every kind of rollback, so pair it with the log. Turn the errors into countable lines and keep the two messages apart:

ALTER SYSTEM SET log_min_error_statement = 'error';
SELECT pg_reload_conf();

-- then, per hour:
--   grep -c 'could not serialize access due to concurrent update'      postgresql.log
--   grep -c 'could not serialize access due to read/write dependencies' postgresql.log
--   grep -c 'deadlock detected'                                        postgresql.log

The ratio between the first two tells you where to look. Mostly the first means specific hot rows and a schema answer. Mostly the second means your transactions read much more widely than they write, and the answer is usually a narrower SELECT or an index that turns a sequential scan into an index scan.

While a SERIALIZABLE workload is running, look at what SSI is tracking:

SELECT locktype, relation::regclass, page, tuple, count(*)
FROM pg_locks
WHERE mode = 'SIReadLock'
GROUP BY 1, 2, 3, 4
ORDER BY count(*) DESC
LIMIT 20;

Rows with locktype = 'tuple' are fine-grained tracking working as intended. Rows with locktype = 'page', and especially 'relation' with page and tuple null, mean promotion has happened on that table and your failure rate now includes transactions that never overlapped.

Finally, instrument your own loop, because the database cannot see it. Three numbers are enough: attempts per successful operation, the give-up rate, and the p99 of total operation latency including retries. The first tells you whether contention is rising. The second tells you whether the retry budget is still adequate. The third is the one your users feel, and it is the one that a bigger retry budget makes worse. A skeleton that has all the properties this lesson argues for:

def run_operation(request):
    deferred = []                       # side effects, collected not performed
    for attempt in range(MAX_ATTEMPTS):
        try:
            with connection.begin():    # BEGIN is inside the loop
                # every read happens here, on this attempt's snapshot
                n = read_counter(request.key)
                deferred = [("email", request.customer)]
                write_counter(request.key, n + 1)
            break                       # committed
        except SerializationFailure as e:      # SQLSTATE 40001 or 40P01 only
            deferred = []                      # nothing from this attempt survives
            if attempt == MAX_ATTEMPTS - 1:
                metrics.gave_up()
                raise
            time.sleep(random.uniform(0, min(BASE * 2 ** attempt, CAP)))
    metrics.attempts(attempt + 1)
    for effect in deferred:             # side effects only after the commit
        perform(effect)

Four properties, all of which the panel above will break if you remove them: BEGIN is inside the loop, so each attempt gets a fresh snapshot. Every read is inside the transaction, so nothing crosses an attempt boundary. The side-effect list is cleared on failure and performed after the commit. And the loop is bounded, with the give-up counted rather than swallowed.

A service at REPEATABLE READ retries on 40001 and reports a 99.98% success rate, with retries averaging 1.2 attempts. A reconciliation job finds that its ledger total is short by roughly 0.4% of transactions. Where do you look first?

Two directions from here. If the failures you are seeing are 40P01 rather than 40001, the detection mechanism is completely different — a timer, not a graph — and so is what you can do about it: deadlock detection. And if you would rather wait for a row than be told to try again, the other half of this trade-off is SELECT FOR UPDATE, which converts every one of these errors into a queue.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.