Concurrency bugs in database code have a signature: the logic is obviously correct, the tests pass, and then twice a month a customer is charged twice or an inventory count goes negative. Nobody can reproduce it. The code reads a row, makes a decision, and writes - which is correct when one person does it, and wrong when two people do it at the same millisecond.
Transactions are the tool for this, and most developers use BEGIN and COMMIT without ever choosing an isolation level, which means they are running on their engine's default and inheriting whichever anomalies that default permits. This covers what those anomalies are, what each level actually prevents, where PostgreSQL and MySQL differ in ways that will surprise you, how deadlocks happen, and how to write a retry loop that works.
ACID, honestly
| Property | What it means | The asterisk |
|---|---|---|
| Atomicity | All the writes in a transaction commit, or none do | Solid. This is the part that works as advertised |
| Consistency | The database moves from one valid state to another | Mostly your job - it means *your* constraints hold, and the database only enforces the ones you declared |
| Isolation | Concurrent transactions do not interfere | Configurable, and the default is weak. This is the whole article |
| Durability | Committed data survives a crash | Depends on fsync settings, and on a single node. Async replication can lose a committed write on failover |
Isolation is the interesting one because it is the only property you tune, and because the levels are described by which anomalies they permit rather than by what they guarantee. That framing is a large part of why they are confusing.
MVCC, in one paragraph
PostgreSQL, MySQL/InnoDB and Oracle all use multiversion concurrency control. A write creates a new version of a row rather than overwriting the old one, so readers see a consistent snapshot without taking locks. Readers never block writers and writers never block readers. What isolation level controls is *when* your snapshot is taken - per statement, or once per transaction.
The anomalies, with examples
Dirty read
Reading data another transaction has written but not committed. If that transaction rolls back, you acted on data that never existed. No mainstream engine allows this at its default level, and PostgreSQL does not implement Read Uncommitted at all - it silently treats it as Read Committed.
Non-repeatable read
-- Session A -- Session B
BEGIN;
SELECT balance FROM accounts
WHERE id = 1; -- 100
UPDATE accounts SET balance = 200
WHERE id = 1;
COMMIT;
SELECT balance FROM accounts
WHERE id = 1; -- 200 under Read Committed
-- 100 under Repeatable Read
COMMIT;Phantom read
The same *range* query returns different rows because another transaction inserted into that range. Sum a set of rows, then count them, and the two disagree.
Lost update
The most common of these in real code, and the one that costs money. Two transactions read the same value, both compute a new one from it, and both write - the second silently overwrites the first.
-- Both sessions run this application logic concurrently
BEGIN;
SELECT stock FROM products WHERE id = 9; -- both read 10
-- application computes 10 - 1 = 9
UPDATE products SET stock = 9 WHERE id = 9;
COMMIT;
-- Two units sold. Stock is 9, not 8.Write skew
The subtle one. Two transactions read an overlapping set of rows, each makes a decision that is valid against the snapshot it read, and each writes a *different* row - so nothing conflicts, and the combined result violates a constraint neither transaction could see being broken.
-- Rule: at least one doctor must remain on call.
-- Andy and Brad both try to go off call at the same moment.
-- Session A -- Session B
BEGIN; BEGIN;
SELECT count(*) FROM doctors
WHERE oncall = true; -- 2
SELECT count(*) FROM doctors
WHERE oncall = true; -- 2
-- 2 > 1, so it is safe for me to leave
UPDATE doctors SET oncall = false
WHERE name = 'Andy';
-- 2 > 1, so it is safe for me to leave
UPDATE doctors SET oncall = false
WHERE name = 'Brad';
COMMIT; COMMIT;
-- Nobody is on call. Both transactions were individually correct.The isolation levels
| Level | Dirty read | Non-repeatable | Phantom | Lost update | Write skew |
|---|---|---|---|---|---|
| Read Uncommitted | Possible* | Possible | Possible | Possible | Possible |
| Read Committed | No | Possible | Possible | Possible | Possible |
| Repeatable Read | No | No | No, in both PG and InnoDB | Prevented differently per engine | Possible |
| Serializable | No | No | No | No | No |
Read Committed
Each *statement* sees a snapshot taken when that statement began. PostgreSQL's default, and correct for the large majority of OLTP work where each statement is self-contained. Its weakness is exactly the check-then-act pattern: the row you read in statement one can change before statement two.
Repeatable Read
The snapshot is taken once per transaction and every statement sees it. PostgreSQL's documentation is precise about a detail worth knowing: the snapshot is taken at the first non-transaction-control statement, not at BEGIN.
Both engines exceed the standard here, and they do it differently. PostgreSQL's implementation is snapshot isolation, which prevents phantoms as a side effect of the snapshot. InnoDB uses gap locks and next-key locks, which lock the ranges between index entries - genuine phantom prevention, at the cost of lock waits on inserts into those ranges that surprise people.
Serializable
The guarantee is that the outcome is equivalent to *some* serial execution of the concurrent transactions. PostgreSQL has implemented this properly since 9.1 using Serializable Snapshot Isolation, the first production implementation of the technique.
SSI takes no additional locks. Instead it tracks read/write dependencies between transactions and, at commit time, aborts one if it detects a pattern that could not have arisen from serial execution. The published evaluation found performance only slightly below snapshot isolation and substantially better than traditional two-phase locking on read-heavy workloads.
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- … your work …
COMMIT;
-- On conflict:
-- ERROR: could not serialize access due to read/write dependencies
-- among transactions
-- SQLSTATE: 40001Where PostgreSQL and MySQL differ
If you write code targeting both, or you moved from one to the other, this section is the one that catches people.
| PostgreSQL | MySQL / InnoDB | |
|---|---|---|
| Default level | Read Committed | Repeatable Read |
| Read Uncommitted | Not implemented; behaves as Read Committed | Implemented |
| Phantom prevention at RR | Via the snapshot | Via gap and next-key locks |
| Writing a row another tx changed, at RR | Aborts with 40001 | Does not abort - the write may silently not apply |
| Serializable | SSI, no extra locks, aborts at commit | Effectively locking-based; converts plain SELECT to locking reads |
| Deadlock error | 40P01 deadlock_detected | 1213 ER_LOCK_DEADLOCK |
| Lock wait timeout | lock_timeout, disabled by default | innodb_lock_wait_timeout, 50 seconds by default |
-- The difference that surprises people most, at Repeatable Read
-- Session A -- Session B
BEGIN; BEGIN;
UPDATE list SET x = x - 1;
SELECT * FROM list; -- sees old snapshot
DELETE FROM list WHERE x = 4; -- blocks
COMMIT;
-- PostgreSQL: ERROR 40001,
-- could not serialize access
-- due to concurrent update
-- MySQL: no error, but the row is
-- not deleted - it still looks
-- present to this transactionFixing the lost update
Four approaches, in increasing order of cost. Pick the cheapest one that actually covers your case.
1. Do the arithmetic in the database
-- Not this: read, compute in the app, write back
SELECT stock FROM products WHERE id = 9;
UPDATE products SET stock = 9 WHERE id = 9;
-- This: one atomic statement, with the guard in the WHERE clause
UPDATE products
SET stock = stock - 1
WHERE id = 9 AND stock >= 1
RETURNING stock;
-- Zero rows returned means the guard failed. Check the row count.UPDATE takes a row lock for its duration, so the read-modify-write is atomic by construction. The WHERE guard turns "check then act" into one operation.2. Pessimistic locking
BEGIN;
SELECT stock FROM products WHERE id = 9 FOR UPDATE; -- row lock held to commit
-- concurrent transactions block here
UPDATE products SET stock = $1 WHERE id = 9;
COMMIT;| Clause | Effect |
|---|---|
FOR UPDATE | Exclusive row lock. Other FOR UPDATE/FOR SHARE readers block |
FOR NO KEY UPDATE | Weaker - allows concurrent foreign key checks. Postgres-specific |
FOR SHARE | Shared lock. Others may read but not write |
FOR UPDATE NOWAIT | Fail immediately rather than waiting. Good for interactive paths |
FOR UPDATE SKIP LOCKED | Skip locked rows entirely. The basis of every database job queue |
3. Optimistic locking
-- Add a version column
ALTER TABLE products ADD COLUMN version integer NOT NULL DEFAULT 0;
-- Read it with the row
SELECT stock, version FROM products WHERE id = 9; -- stock 10, version 7
-- Write only if nobody else has
UPDATE products
SET stock = 9, version = 8
WHERE id = 9 AND version = 7;
-- 0 rows updated = someone else won. Re-read and retry, or tell the user.4. Constraints, which are not optional
-- Uniqueness enforced in application code has a race window.
-- A constraint does not.
ALTER TABLE customers ADD CONSTRAINT customers_email_key UNIQUE (email);
-- Non-overlapping bookings, enforced by the database
ALTER TABLE room_bookings
ADD CONSTRAINT no_double_booking
EXCLUDE USING gist (room_id WITH =, period WITH &&);SELECT to check then an INSERT has a window between them; two sessions can both pass the check. A unique constraint has no window - one of them gets an error and you handle it.Fixing write skew
Write skew cannot be fixed by locking the rows you are writing, because the transactions write different rows. You have to materialise the conflict.
-- Option 1: Serializable, and let SSI detect it
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE oncall = true;
UPDATE doctors SET oncall = false WHERE name = 'Andy';
COMMIT; -- one of the two transactions gets 40001
-- Option 2: lock the rows you READ, not just the ones you write
BEGIN;
SELECT count(*) FROM doctors WHERE oncall = true FOR UPDATE;
UPDATE doctors SET oncall = false WHERE name = 'Andy';
COMMIT; -- the second transaction now blocks on the same rows
-- Option 3: give the invariant a row of its own to contend on
BEGIN;
UPDATE shift_invariants SET oncall_count = oncall_count - 1
WHERE shift_id = $1 AND oncall_count > 1;
-- 0 rows means the rule would be violated. Abort.
COMMIT;Deadlocks
A deadlock is two transactions each holding a lock the other needs. Neither can proceed, so the engine detects the cycle and kills one. This is not a bug in the database - it is the database doing exactly the right thing with an application that acquired locks in inconsistent orders.
-- Session A -- Session B
BEGIN; BEGIN;
UPDATE accounts SET balance =
balance - 100 WHERE id = 1;
UPDATE accounts SET balance =
balance - 50 WHERE id = 2;
UPDATE accounts SET balance =
balance + 100 WHERE id = 2; -- waits for B
UPDATE accounts SET balance =
balance + 50 WHERE id = 1; -- waits for A
-- Deadlock. One transaction is aborted:
-- PostgreSQL: ERROR 40P01 deadlock detected
-- MySQL: ERROR 1213 Deadlock found when trying to get lockPreventing them
- Acquire locks in a consistent order. Sort the IDs before locking.
ORDER BY idon yourSELECT … FOR UPDATEcosts nothing and eliminates the most common deadlock shape entirely. - Keep transactions short. The window for a cycle is the time locks are held. Every millisecond of unnecessary work inside a transaction widens it.
- Do not hold locks across network calls. An HTTP request inside a transaction turns a 2ms lock into a 2-second one.
- Touch fewer rows. A batch update over ten thousand rows is a large lock surface. Chunk it.
- Beware index-order surprises in InnoDB. Gap locks mean an
INSERTcan conflict with a range another transaction locked, in ways not obvious from the SQL. - Set
lock_timeout. PostgreSQL disables it by default, so a transaction can wait indefinitely. A bounded wait that fails cleanly is better than one that hangs a connection.
-- Consistent lock ordering, in one statement
SELECT * FROM accounts
WHERE id = ANY($1)
ORDER BY id
FOR UPDATE;
-- Bound the wait
SET lock_timeout = '3s';
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '60s';idle_in_transaction_session_timeout in particular protects the whole database, since an open transaction blocks vacuum cluster-wide.Diagnosing them
-- PostgreSQL: log every deadlock with both queries involved
SET log_lock_waits = on;
SET deadlock_timeout = '1s'; -- how long to wait before running detection
-- What is blocking what, right now
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));
-- MySQL: the most recent deadlock, in full
SHOW ENGINE INNODB STATUS;deadlock_timeout is not how long a deadlock lasts - it is how long PostgreSQL waits on a lock before bothering to check for a cycle. Detection itself is fast.The retry loop
If you use Serializable, or Repeatable Read in PostgreSQL, or you run any concurrent workload at all, transactions will fail for reasons that are not your fault. Retrying them is part of the design, not error handling bolted on afterwards.
const RETRYABLE = new Set([
'40001', // serialization_failure
'40P01', // deadlock_detected
])
async function withRetry<T>(
fn: (tx: Tx) => Promise<T>,
maxAttempts = 5,
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await db.transaction({ isolationLevel: 'serializable' }, fn)
} catch (err) {
if (!RETRYABLE.has(err.code) || attempt >= maxAttempts - 1) throw err
// Full jitter, so retrying transactions do not collide again
const base = Math.min(10 * 2 ** attempt, 500)
await sleep(Math.random() * base)
metrics.increment('tx.retry', { code: err.code })
}
}
}- Retry the whole transaction, from the beginning. The snapshot is invalid; re-running only the failed statement inside the same transaction does nothing.
- The function must be safe to run more than once. Side effects that are not database writes - emails, payment calls, queue publishes - must move outside the transaction, or the retry sends them twice.
- Jitter the backoff. Two transactions that deadlocked will deadlock again if they both retry immediately.
- Cap the attempts and surface the failure. A loop that retries forever converts a contention problem into an outage.
- Instrument the retry rate. A rising rate is early warning of a hot row or a lock-ordering bug, and it is invisible unless you count it.
Long transactions do damage you will not see immediately
An open transaction is not free even when it is doing nothing. In PostgreSQL's MVCC design, vacuum cannot remove any row version that might still be visible to the oldest running transaction - so one idle transaction left open in a session pool holds back cleanup across the entire database.
- Bloat accumulates on tables the transaction never touched, because the visibility horizon is global.
- Replication lag grows, since replicas apply the same constraint.
- Locks are held for the duration, widening every deadlock window.
- Connections are consumed and cannot be reused by the pool.
- In extreme cases, transaction ID wraparound becomes a real risk, and that ends with the database refusing writes.
-- Find them before they find you
SELECT pid, state, now() - xact_start AS duration, left(query, 80)
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'active')
AND xact_start < now() - interval '1 minute'
ORDER BY xact_start;idle in transaction is the state to alert on. It means an application opened a transaction and then went off to do something else - usually an HTTP call, sometimes a bug in a connection pool wrapper.Where transactions stop helping
A transaction is a guarantee within one database. The moment your operation spans a second system - another service, a payment provider, an email - you are outside its scope and no isolation level helps.
| Situation | What to use instead |
|---|---|
| Write to your DB and publish a message | Transactional outbox - write the message to a table in the same transaction |
| Two services must both succeed | A saga with compensating actions, plus idempotency at each step |
| Call a payment provider mid-transaction | Never. Reserve inside the transaction, commit, call outside, reconcile |
| Cross-database consistency | Two-phase commit if you must, but usually a redesign of the boundary is better |
| Read-your-own-writes across a replica | Route reads back to the primary after a write, or use a causality token |
The design principle worth carrying: keep the transaction as small as the set of rows that must be consistent with each other, and handle everything beyond that boundary with idempotency and reconciliation rather than isolation.
Choosing a level, practically
| Workload | Level | Why |
|---|---|---|
| Single-statement reads and writes | Read Committed | Each statement is already atomic. Nothing stronger buys anything |
| A multi-step report that must be self-consistent | Repeatable Read | One snapshot, so the numbers in different queries agree |
| Check-then-act on one row | Read Committed plus FOR UPDATE, or a guarded UPDATE | Cheaper and more predictable than raising the level |
| Invariants across rows - counts, totals, at-least-one | Serializable | The only level that catches write skew, and worth the retry loop |
| Financial ledgers and balances | Serializable, or explicit locking with a guard | The failure mode is money. Pay for correctness |
| Bulk background processing | Read Committed with SKIP LOCKED | Workers should not contend at all |
Mistakes that keep recurring
| Mistake | Consequence |
|---|---|
| Read-modify-write across two statements | Lost updates under any level below Serializable |
| Assuming Repeatable Read prevents everything | Write skew still happens, and it is the anomaly that breaks invariants |
| Using Serializable without a retry loop | Transactions fail at commit and the error reaches the user |
| Retrying only the failed statement | The snapshot is already invalid; the retry is meaningless |
| Locking rows in inconsistent orders | Deadlocks that look random and are entirely deterministic |
| HTTP calls inside a transaction | Locks held for seconds; bloat, lag and deadlocks follow |
No idle_in_transaction_session_timeout | One leaked transaction blocks vacuum for the whole database |
| Application-level uniqueness checks | A race window a constraint would not have |
| Side effects inside a retryable transaction | Duplicate emails and duplicate charges on every retry |
| Assuming MySQL and PostgreSQL behave the same at RR | Silently different outcomes on the same code |
| Batch updates over huge row counts in one transaction | Long lock holds, replication lag, and deadlock probability that scales with batch size |
Verdict
The default isolation level on both major engines permits anomalies that break ordinary application logic, and almost nobody changes it - which is fine, because the answer is usually not a higher level. It is writing the operation so that the concurrency problem cannot arise: one guarded UPDATE instead of a read and a write, a unique constraint instead of a check, FOR UPDATE on the rows the decision depends on.
Raise the level when the invariant genuinely spans rows. Write skew is the specific thing Serializable buys you, and it is the anomaly most likely to be silently corrupting data in a system whose authors believed Repeatable Read was strong enough. SSI makes that affordable in PostgreSQL; the retry loop is the price.
Ask what happens if two copies of this transaction run at exactly the same instant. If you cannot answer, the code is not finished.
Three habits cover most of it. Do the arithmetic in the database rather than in the application. Sort your IDs before locking them. And keep transactions short enough that nothing slow happens inside one - no HTTP, no user input, no waiting on anything that is not the database.
Sources
- PostgreSQL transaction isolation - the authoritative description of each level's actual behaviour
- PostgreSQL explicit locking - row lock modes, deadlock detection, advisory locks
- Serializable Snapshot Isolation in PostgreSQL - Ports and Grittner, the paper behind the implementation
- MySQL transaction isolation levels - InnoDB's semantics and defaults
- InnoDB locking - gap locks, next-key locks and why inserts block
- A Critique of ANSI SQL Isolation Levels - Berenson et al., where snapshot isolation and its anomalies were named



