Fix Postgres 'Could Not Serialize Access' (40001)
Postgres aborts one transaction with 40001 to prevent a serialization anomaly. The docs are explicit: apps using REPEATABLE READ or SERIALIZABLE must be prepared to retry. Here's the correct retry loop and how to reduce conflicts.
A transaction that worked in testing throws under load:
ERROR: could not serialize access due to concurrent updateOr, under SERIALIZABLE:
ERROR: could not serialize access due to read/write dependencies among transactionsBoth carry SQLSTATE 40001 (serialization_failure). This is not a bug — it's PostgreSQL deliberately aborting one transaction to prevent a data-correctness anomaly. The Postgres docs are blunt about the fix, and it's not "lower the isolation level and hope."
Why Postgres throws it#
Under REPEATABLE READ, a transaction only sees data committed before it began. If it tries to update a row that a concurrent transaction already committed a change to, Postgres can't reconcile that safely — so (from the docs) "the repeatable read transaction will be rolled back with the message could not serialize access due to concurrent update." This is first-updater-wins.
Under SERIALIZABLE, Postgres goes further with predicate locks (SSI) to detect read/write dependency cycles — anomalies like write skew that simple row conflicts miss — and aborts a transaction in the dangerous set with the "read/write dependencies" variant.
In both cases the engine sacrifices one transaction to preserve the illusion of serial execution. The error is correctness working as designed.
The fix the docs mandate: retry#
The PostgreSQL "Serialization Failure Handling" page states it directly:
"applications using these levels must be prepared to retry transactions that fail due to serialization errors."
And critically — you must retry the whole transaction:
"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, since it cannot do so with any guarantee of correctness."
So a retry loop must re-read, re-decide, and re-write every attempt — not replay the failed statement.
This pattern is illustrative (the docs intentionally ship no code) — the shape is what matters: re-run everything inside the loop, with exponential backoff and jitter.
async function withSerializableRetry(run, maxRetries = 5) {
for (let attempt = 0; ; attempt++) {
try {
await db.query('BEGIN ISOLATION LEVEL SERIALIZABLE')
const result = await run(db) // re-reads + re-computes each attempt
await db.query('COMMIT')
return result
} catch (err) {
await db.query('ROLLBACK')
if (err.code === '40001' && attempt < maxRetries) {
await sleep(2 ** attempt * 20 + Math.random() * 20) // backoff + jitter
continue
}
throw err
}
}
}The docs note 40P01 (deadlock_detected) is "also advisable to retry," and in some cases 23505 (unique_violation) and 23P01 (exclusion_violation).
When READ COMMITTED is the right answer instead#
READ COMMITTED is the Postgres default and does not throw 40001 for ordinary write conflicts — it re-evaluates against the latest committed row rather than aborting, so no retry loop is needed. People raise the level to eliminate anomalies (non-repeatable reads, lost updates, write skew) that READ COMMITTED permits — and the cost of that choice is handling 40001.
Reduce conflicts so you retry less#
- Keep transactions short. The docs' SERIALIZABLE performance guidance: "Don't put more into a single transaction than needed for integrity purposes," declare transactions
READ ONLYwhen possible, and "don't leave connections dangling 'idle in transaction' longer than necessary" (seeidle_in_transaction_session_timeout). SELECT ... FOR UPDATEpessimistically locks rows to serialize access — though SERIALIZABLE can replace explicit locks where its automatic protection suffices. Balance against blocking.- Use a connection pool and keep work outside the transaction boundary.
How this surfaces in Supabase#
Supabase runs standard PostgreSQL, so the behavior is identical wherever a transaction runs at REPEATABLE READ or SERIALIZABLE:
- A Postgres function (plpgsql) called via
rpc(), or any explicitBEGIN ... COMMIT, will raise 40001 under those levels. - The Supabase client surfaces it as a Postgres error — check
error.code === '40001'. - The retry loop belongs in application code (or an edge function / queue), because the full transaction — including the decision logic — must be replayed, which a single RPC call can't do for you.
- You're on READ COMMITTED and seeing 40001 anyway — then something explicitly set a higher isolation level (a
BEGIN ISOLATION LEVEL ..., a function, or a session default); find and reconsider it. - The "right" fix is sometimes to reduce contention (shorter txns, better locking), not to crank up retries — a retry storm on a hot row just moves the bottleneck.
Official references: Transaction Isolation, Serialization Failure Handling, Error Codes.
Related Articles#
- Debugging Supabase RLS Issues
- Fix Slow Supabase Queries
- Supabase Postgres Functions & Triggers Guide
- Zero-Downtime Postgres Migrations
Frequently Asked Questions#
What does "could not serialize access due to concurrent update" mean?#
It's SQLSTATE 40001. Under REPEATABLE READ or SERIALIZABLE, Postgres detected a conflict that would break correctness and rolled one transaction back. It's the database protecting your data — your app must retry.
How do I fix a 40001 serialization failure?#
Retry the whole transaction (re-read, re-decide, re-write) with exponential backoff. The docs say apps using these levels must be prepared to retry, and that Postgres can't retry automatically with any correctness guarantee.
Can I just avoid 40001 with READ COMMITTED?#
READ COMMITTED (the default) doesn't raise 40001 for ordinary write conflicts — but you lose the anomaly protection of the higher levels. Drop down only if you don't need those guarantees.
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Fix Supabase RLS Infinite Recursion Error (Postgres Policy Loop Explained — Production Fix 2026)
If your Supabase query returns `infinite recursion detected in policy for relation "X"`, your RLS policy is querying the same table it protects. Here's exactly why it loops, and three production-grade fixes that don't leak data.
Fix Foreign Key Constraint Violation in Supabase (23503)
23503 means a foreign key relationship is broken. Inserting a child before its parent? Insert the parent first. Can't delete a parent with children? Choose an ON DELETE action. The most common Supabase case is a profiles row referencing auth.users.
Fix Supabase Auth Session Not Persisting (Next.js 2026)
Supabase auth sessions mysteriously disappearing after page refresh? Learn the exact cause and fix it in 5 minutes with this tested solution.
Browse by Topic
Find stories that matter to you.
