Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
databasespostgresqlmysqlsqlreliability

SQL transactions in practice: isolation levels, deadlocks, and race conditions

What each isolation level really prevents, why Repeatable Read still allows write skew, how deadlocks happen, and how to write a retry loop that works.

T

team

15 min read
A stylised illustration of a database, with a lock on it and a small footprint. The database is surrounded by a network of pipes and gears, representing the infrastructure that supports it.

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

PropertyWhat it meansThe asterisk
AtomicityAll the writes in a transaction commit, or none doSolid. This is the part that works as advertised
ConsistencyThe database moves from one valid state to anotherMostly your job - it means *your* constraints hold, and the database only enforces the ones you declared
IsolationConcurrent transactions do not interfereConfigurable, and the default is weak. This is the whole article
DurabilityCommitted data survives a crashDepends 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

sql
-- 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;
The same query, twice, in one transaction, returning different answers. Harmless for a single-statement operation, dangerous for any multi-step calculation.

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.

sql
-- 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.

sql
-- 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.
This is the canonical write skew example from the SSI literature. Note that it succeeds under Repeatable Read in both PostgreSQL and MySQL - the rows written do not overlap, so there is no update conflict to detect.

The isolation levels

LevelDirty readNon-repeatablePhantomLost updateWrite skew
Read UncommittedPossible*PossiblePossiblePossiblePossible
Read CommittedNoPossiblePossiblePossiblePossible
Repeatable ReadNoNoNo, in both PG and InnoDBPrevented differently per enginePossible
SerializableNoNoNoNoNo
*PostgreSQL does not implement Read Uncommitted; asking for it gives you Read Committed. The standard's table describes minimum protections, and both engines exceed it at Repeatable Read.

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.

sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- … your work …
COMMIT;

-- On conflict:
-- ERROR:  could not serialize access due to read/write dependencies
--         among transactions
-- SQLSTATE: 40001
The price of Serializable is that transactions can fail at commit for reasons unrelated to your code. You must retry them, which means the retry loop is not optional - it is part of the contract.

Where 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.

PostgreSQLMySQL / InnoDB
Default levelRead CommittedRepeatable Read
Read UncommittedNot implemented; behaves as Read CommittedImplemented
Phantom prevention at RRVia the snapshotVia gap and next-key locks
Writing a row another tx changed, at RRAborts with 40001Does not abort - the write may silently not apply
SerializableSSI, no extra locks, aborts at commitEffectively locking-based; converts plain SELECT to locking reads
Deadlock error40P01 deadlock_detected1213 ER_LOCK_DEADLOCK
Lock wait timeoutlock_timeout, disabled by defaultinnodb_lock_wait_timeout, 50 seconds by default
sql
-- 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 transaction
PostgreSQL refuses and tells you. MySQL proceeds and the outcome silently differs from what the transaction believed. The first behaviour is far easier to build correct software on.

Fixing 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

sql
-- 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.
The best fix, and the one people skip. A single 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

sql
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;
ClauseEffect
FOR UPDATEExclusive row lock. Other FOR UPDATE/FOR SHARE readers block
FOR NO KEY UPDATEWeaker - allows concurrent foreign key checks. Postgres-specific
FOR SHAREShared lock. Others may read but not write
FOR UPDATE NOWAITFail immediately rather than waiting. Good for interactive paths
FOR UPDATE SKIP LOCKEDSkip locked rows entirely. The basis of every database job queue

3. Optimistic locking

sql
-- 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.
No locks held across user think-time, which is what makes this the right pattern for edit forms. The trade is that the loser does the work twice, so it suits low-contention data.

4. Constraints, which are not optional

sql
-- 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 &&);
A 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.

sql
-- 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;
Option 1 is the cleanest and needs a retry loop. Option 2 works at any level and costs concurrency. Option 3 is the manual version - turn a multi-row invariant into a single-row counter, which the engine can then serialise for you.

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.

sql
-- 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 lock
A transfers to B while B transfers to A. Both are correct code. Together they deadlock, because they touched the same two rows in opposite orders.

Preventing them

  • Acquire locks in a consistent order. Sort the IDs before locking. ORDER BY id on your SELECT … FOR UPDATE costs 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 INSERT can 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.
sql
-- 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';
Those three timeouts are the difference between a slow query and a connection nobody can reclaim. idle_in_transaction_session_timeout in particular protects the whole database, since an open transaction blocks vacuum cluster-wide.

Diagnosing them

sql
-- 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.

src/db/with-retry.ts
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.
sql
-- 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.

SituationWhat to use instead
Write to your DB and publish a messageTransactional outbox - write the message to a table in the same transaction
Two services must both succeedA saga with compensating actions, plus idempotency at each step
Call a payment provider mid-transactionNever. Reserve inside the transaction, commit, call outside, reconcile
Cross-database consistencyTwo-phase commit if you must, but usually a redesign of the boundary is better
Read-your-own-writes across a replicaRoute 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

WorkloadLevelWhy
Single-statement reads and writesRead CommittedEach statement is already atomic. Nothing stronger buys anything
A multi-step report that must be self-consistentRepeatable ReadOne snapshot, so the numbers in different queries agree
Check-then-act on one rowRead Committed plus FOR UPDATE, or a guarded UPDATECheaper and more predictable than raising the level
Invariants across rows - counts, totals, at-least-oneSerializableThe only level that catches write skew, and worth the retry loop
Financial ledgers and balancesSerializable, or explicit locking with a guardThe failure mode is money. Pay for correctness
Bulk background processingRead Committed with SKIP LOCKEDWorkers should not contend at all
Set it per transaction. There is no reason your reporting query and your payment handler should run at the same level.

Mistakes that keep recurring

MistakeConsequence
Read-modify-write across two statementsLost updates under any level below Serializable
Assuming Repeatable Read prevents everythingWrite skew still happens, and it is the anomaly that breaks invariants
Using Serializable without a retry loopTransactions fail at commit and the error reaches the user
Retrying only the failed statementThe snapshot is already invalid; the retry is meaningless
Locking rows in inconsistent ordersDeadlocks that look random and are entirely deterministic
HTTP calls inside a transactionLocks held for seconds; bloat, lag and deadlocks follow
No idle_in_transaction_session_timeoutOne leaked transaction blocks vacuum for the whole database
Application-level uniqueness checksA race window a constraint would not have
Side effects inside a retryable transactionDuplicate emails and duplicate charges on every retry
Assuming MySQL and PostgreSQL behave the same at RRSilently different outcomes on the same code
Batch updates over huge row counts in one transactionLong 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.

Our review question for anything that reads then writes

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

Back to Blog
Share:

Related Posts