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 on Supabase
- Select the First Row per Group in Supabase Postgres
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 (Production 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.
Supabase RLS Not Working: Debug & Fix Policies (2026)
Master RLS debugging techniques. Learn how to identify, diagnose, and fix Row Level Security policy issues that block data access in production.
Browse by Topic
Find stories that matter to you.
