← Back to the library
BackendHow it works · Deep dive · 16 min

Transactions and isolation levels, from ACID to write skew

What can still go wrong when two transactions run at once, and which isolation level, lock or version check prevents it?

A transaction makes a group of writes all-or-nothing, but isolation decides what concurrent transactions can see of each other. Below Serializable, some anomalies (lost updates, write skew) are allowed, so correctness depends on choosing the level, an explicit lock or an optimistic check on purpose.

THE MENTAL MODEL

Each transaction works from a snapshot: a photo of committed data taken at the start of each statement (Read Committed) or at the first statement of the transaction (Repeatable Read). Reads from the photo never block writers, which is what MVCC buys you. The trouble is a read-decide-write sequence: you decide based on the photo, but by the time you write, someone else has committed a different reality. Isolation levels differ in which of those stale decisions the database detects (and aborts with a serialization error you must retry) and which it lets through silently. When the level does not catch it, you serialise the decision yourself with a row lock, a conditional UPDATE or a version column.

HOW IT FITS TOGETHER

A lost update under Read CommittedBoth requests read 100 and write back their own result. Neither sees an error; the first withdrawal disappears. Repeatable Read would abort B with 40001; FOR UPDATE would make B wait and read 70.
  1. Request A (-30) → PostgreSQLBEGIN; SELECT balance WHERE id = 1
  2. PostgreSQL → Request A (-30)balance = 100
  3. Request B (-50) → PostgreSQLBEGIN; SELECT balance WHERE id = 1
  4. PostgreSQL → Request B (-50)balance = 100Plain SELECT takes no row lock, so both read the same committed value.
  5. Request A (-30) → PostgreSQLUPDATE SET balance = 70; COMMITThe application computed 100 - 30 in memory.
  6. PostgreSQL → Request A (-30)COMMIT ok, balance = 70
  7. Request B (-50) → PostgreSQLUPDATE SET balance = 50; COMMITUnder Read Committed this statement takes a fresh snapshot, sees A's committed row and simply overwrites it with the stale result.
  8. PostgreSQL → Request B (-50)COMMIT ok, balance = 50 (A is lost)Fix: UPDATE ... SET balance = balance - 50, SELECT ... FOR UPDATE, a version check, or Repeatable Read with retry.
Isolation level vs anomalyActual engine behaviour, which is stricter than the SQL standard's minimums. "Error" means the database aborts one transaction with SQLSTATE 40001 and you must retry it.
Dirty readNon-repeatable readPhantomLost updateWrite skew
PG Read Committed (default)PreventedPossiblePossiblePossible in read-then-write codePossible
PG Repeatable ReadPreventedPreventedPrevented (snapshot)Prevented: error on concurrent updatePossible
PG Serializable (SSI)PreventedPreventedPreventedPrevented: errorPrevented: error on rw-dependency cycle
MySQL Repeatable Read (default)PreventedPrevented for plain SELECTsPrevented: snapshot for plain reads, next-key locks for locking readsPossible: UPDATE reads the latest committed rowPossible
MySQL SerializablePreventedPreventedPreventedPrevented via shared locks; expect deadlocksPrevented via shared locks; expect deadlocks

KEY TERMS

ACID
Atomicity: all writes commit or none do. Consistency: constraints (FK, UNIQUE, CHECK) hold at commit. Isolation: concurrent transactions do not see each other's partial work, to a degree set by the isolation level. Durability: once COMMIT returns, the data survives a crash (WAL fsync). Isolation is the only one with a dial, and the default is not the strongest setting.
MVCC
Updates create a new row version instead of overwriting; each transaction sees the versions committed before its snapshot. Readers never block writers and writers never block readers. Writers still block writers on the same row, and old versions accumulate until VACUUM (PostgreSQL) or purge (InnoDB undo logs) can remove them, which a long-open transaction prevents.
The anomalies
Dirty read: seeing uncommitted data. Non-repeatable read: the same row read twice gives different values. Phantom: the same predicate query returns a different set of rows. Lost update: two read-modify-write cycles and one overwrites the other. Write skew: two transactions read an overlapping set, each writes a different row, and together they break an invariant neither broke alone (the last two doctors both go off call).
PostgreSQL levels
Read Committed (default) takes a new snapshot per statement; an UPDATE that hits a concurrently changed row waits, then re-evaluates its WHERE on the new version. Repeatable Read is snapshot isolation: one snapshot per transaction, and writing a row someone else changed since raises 40001 "could not serialize access due to concurrent update". Read Uncommitted behaves as Read Committed.
Serializable (SSI) and retries
PostgreSQL's Serializable is Repeatable Read plus predicate-lock tracking (SIReadLock) that never blocks but aborts one transaction when read/write dependencies form a cycle. Failures always use SQLSTATE 40001 and may surface at COMMIT, so the whole transaction, including its reads, must be retried. Keep transactions short, mark read-only ones READ ONLY, and prefer index scans: sequential scans take relation-level predicate locks and raise the failure rate.
Explicit row locks
SELECT ... FOR UPDATE locks the returned rows until the transaction ends, so a competing FOR UPDATE or UPDATE waits. FOR NO KEY UPDATE is weaker and does not block inserts that reference the row by foreign key. NOWAIT errors instead of waiting; SKIP LOCKED silently omits locked rows, which is wrong for general reads but ideal for job queues.
Optimistic concurrency
Store a version (integer or updated_at) with each row, send it to the client, and write with UPDATE ... WHERE id = $1 AND version = $2. Zero rows updated means someone else won; return 409 or 412 and let the user or code re-read. It holds no locks across user think time and maps to HTTP ETag / If-Match.

IN YOUR STACK

SQL · PostgreSQL 18 Server

-- Transfer: lock both rows, always in id order, so two opposite
-- transfers cannot deadlock (A locks 1 then 2; B also locks 1 then 2).
BEGIN;
SELECT id, balance
FROM accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR UPDATE;
-- application checks the from-account balance here, then:
UPDATE accounts SET balance = balance - $3 WHERE id = $1;
UPDATE accounts SET balance = balance + $3 WHERE id = $2;
COMMIT;

-- Often no explicit lock is needed: make the check part of the write.
UPDATE accounts
SET balance = balance - $2
WHERE id = $1 AND balance >= $2
RETURNING balance;            -- zero rows = insufficient funds

-- Fail fast instead of queuing behind a long holder.
SELECT * FROM accounts WHERE id = $1 FOR UPDATE NOWAIT;  -- 55P03 if locked
  • Row locks last until COMMIT or ROLLBACK. Never hold them across an HTTP call to another service.
  • At Repeatable Read or Serializable, FOR UPDATE on a row changed after your snapshot raises 40001 instead of waiting and reading the new value.
  • Set lock_timeout so a stuck holder produces an error rather than an ever-growing queue of blocked requests.
PostgreSQL 18 documentation (opens in new tab)

TypeScript · node-postgres Server

import { Pool } from "pg";
const pool = new Pool();

export class VersionConflict extends Error {}

// The client sent back the version it loaded (e.g. in an If-Match header).
export async function saveDocument(id: string, body: string, expectedVersion: number) {
  const { rows } = await pool.query<{ version: number }>(
    `UPDATE documents
       SET body = $2, version = version + 1, updated_at = now()
     WHERE id = $1 AND version = $3
     RETURNING version`,
    [id, body, expectedVersion],
  );
  if (rows.length === 0) throw new VersionConflict(`document ${id} changed`); // -> 409 / 412
  return rows[0].version; // new ETag
}
  • A single statement needs no explicit transaction, so pool.query is fine here. Multi-statement transactions must use one client from pool.connect().
  • Zero rows can also mean the row was deleted; re-read to tell a conflict from a 404.
  • ORMs have this built in: JPA @Version, Rails lock_version, Entity Framework concurrency tokens.
node-postgres documentation (opens in new tab)

TypeScript · node-postgres Server

import { Pool, type PoolClient } from "pg";
const pool = new Pool();
const RETRYABLE = new Set(["40001", "40P01"]); // serialization failure, deadlock

export async function serializable<T>(work: (c: PoolClient) => Promise<T>, attempts = 5): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    const client = await pool.connect(); // one client for the whole transaction
    try {
      await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
      const result = await work(client);
      await client.query("COMMIT"); // SSI conflicts can surface here too
      return result;
    } catch (err) {
      await client.query("ROLLBACK").catch(() => {});
      const code = (err as { code?: string }).code;
      if (!code || !RETRYABLE.has(code) || attempt === attempts) throw err;
      await new Promise((r) => setTimeout(r, 2 ** attempt * 10 + Math.random() * 25));
    } finally {
      client.release();
    }
  }
}

// Write skew guard: at least one doctor must remain on call.
await serializable(async (c) => {
  const { rows } = await c.query("SELECT count(*)::int AS n FROM shifts WHERE day = $1 AND on_call", [day]);
  if (rows[0].n < 2) throw new Error("last doctor on call");
  await c.query("UPDATE shifts SET on_call = false WHERE day = $1 AND doctor_id = $2", [day, doctorId]);
});
  • The work callback may run several times: keep side effects (emails, payment calls, queue publishes) out of it or behind an outbox.
  • Under Repeatable Read this example would let two doctors go off call at once; only Serializable or an explicit lock on the day's rows prevents it.
node-postgres documentation (opens in new tab)

SQL · PostgreSQL 18 Server

-- Claim up to 10 due jobs that no other worker holds, in one short transaction.
WITH next AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending' AND run_at <= now()
  ORDER BY run_at
  LIMIT 10
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs AS j
SET status = 'running', locked_by = $1, locked_at = now(), attempts = j.attempts + 1
FROM next
WHERE j.id = next.id
RETURNING j.id, j.payload, j.attempts;

-- Process outside the transaction, then mark done (idempotently).
UPDATE jobs SET status = 'done' WHERE id = $1 AND locked_by = $2;

-- Reaper: requeue jobs whose worker died mid-flight.
UPDATE jobs SET status = 'pending', locked_by = NULL
WHERE status = 'running' AND locked_at < now() - interval '10 minutes';
  • Claim-then-commit keeps row locks short; the status column, not the lock, marks ownership while the job runs. Holding the lock for the whole job instead makes a crash release it automatically, at the cost of a long transaction.
  • Index the claim path, for example a partial index on (run_at) WHERE status = 'pending'.
  • Delivery is at least once (the reaper can requeue a slow job), so handlers must be idempotent.
PostgreSQL 18 documentation (opens in new tab)

TypeScript · Prisma ORM 7 Server

import { Prisma, PrismaClient } from "../prisma/generated/client";
const prisma = new PrismaClient({ adapter }); // driver adapter, e.g. @prisma/adapter-pg

export async function bookSeat(eventId: string, userId: string) {
  for (let attempt = 1; attempt <= 5; attempt++) {
    try {
      return await prisma.$transaction(
        async (tx) => {
          const taken = await tx.booking.count({ where: { eventId } });
          const event = await tx.event.findUniqueOrThrow({ where: { id: eventId } });
          if (taken >= event.capacity) throw new Error("sold out");
          return tx.booking.create({ data: { eventId, userId } });
        },
        {
          isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
          maxWait: 2000, // wait for a connection (default 2000 ms)
          timeout: 5000, // max transaction run time (default 5000 ms)
        },
      );
    } catch (e) {
      const conflict = e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2034";
      if (!conflict || attempt === 5) throw e; // P2034: write conflict or deadlock
    }
  }
  throw new Error("unreachable");
}
  • Without isolationLevel the database default applies (Read Committed on PostgreSQL), where this count-then-insert can overbook.
  • Prisma ORM 8 (in release candidates) replaces $transaction with db.transaction(async (tx) => ...) and drops the isolationLevel, timeout and maxWait options; set the level with SET TRANSACTION ISOLATION LEVEL as the first statement and retry on sqlState 40001 / 40P01.
Prisma ORM 7 documentation (opens in new tab)

Python · Django 6 Server

from decimal import Decimal
from django.db import transaction
from django.db.models import F

def withdraw(account_id: int, amount: Decimal) -> None:
    with transaction.atomic():
        # Lock is taken when the queryset is evaluated (.get), held until the block exits.
        account = Account.objects.select_for_update().get(pk=account_id)
        if account.balance < amount:
            raise InsufficientFunds()
        account.balance -= amount
        account.save(update_fields=["balance"])

def withdraw_atomic(account_id: int, amount: Decimal) -> None:
    # Same guarantee in one statement, no lock held in Python.
    updated = Account.objects.filter(pk=account_id, balance__gte=amount).update(
        balance=F("balance") - amount
    )
    if updated == 0:
        raise InsufficientFunds()

def claim_jobs(limit: int = 10) -> list[Job]:
    with transaction.atomic():
        jobs = list(Job.objects.select_for_update(skip_locked=True)
                    .filter(status="pending").order_by("run_at")[:limit])
        Job.objects.filter(pk__in=[j.pk for j in jobs]).update(status="running")
        return jobs
  • Evaluating select_for_update() outside transaction.atomic() raises TransactionManagementError on PostgreSQL and MySQL; on SQLite it is silently a no-op.
  • TestCase wraps every test in a transaction, so locking bugs only show up in TransactionTestCase.
  • Options: nowait, skip_locked, of=("self", ...) and no_key=True (PostgreSQL only, FOR NO KEY UPDATE).
Django 6 documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Atomic UPDATE or constraint

Use when The decision fits in one statement: SET balance = balance - $1 WHERE balance >= $1, INSERT ... ON CONFLICT, or a UNIQUE or CHECK constraint that makes the invalid state unrepresentable.

Avoid when The decision needs data from several queries or an external call between read and write.

SELECT ... FOR UPDATE

Use when Read-decide-write on a few known rows inside a short transaction, with high contention where retries would be wasteful.

Avoid when The lock would be held across user think time or network calls, or the invariant spans rows that do not exist yet (locks cannot lock absent rows).

Optimistic version column

Use when Edits that span requests (forms, documents, admin screens) and conflicts are rare.

Avoid when A hot row is updated by many writers per second; most attempts fail and retry.

Serializable + retry loop

Use when Invariants span many rows or predicates (scheduling, quotas, write skew) and you would rather prove correctness than reason about every lock.

Avoid when The code has non-idempotent side effects (emails, payments) inside the transaction, or you have no central retry wrapper.

FOR UPDATE SKIP LOCKED queue

Use when Several workers pull jobs from a Postgres table and each job must go to exactly one worker at a time.

Avoid when You need a consistent read of all rows, strict global ordering, or very high throughput better served by a broker.

WHERE IT BITES

  • Assuming the default level is safe for read-modify-write. PostgreSQL defaults to Read Committed and MySQL to Repeatable Read, and neither stops a SELECT-then-UPDATE lost update; MySQL does not even raise an error.
  • Raising the isolation level without a retry loop. Repeatable Read and Serializable turn races into 40001 errors, which become 500s unless a central wrapper retries the whole transaction.
  • Calling external services inside a transaction. Locks and snapshots are held for the duration of the call, retries replay the side effect, and a timeout leaves the call done but the transaction rolled back. Use an outbox.
  • Leaving connections idle in transaction. They hold locks and block VACUUM; set idle_in_transaction_session_timeout and make sure every code path commits or rolls back.
  • Locking rows in inconsistent order across code paths, which produces deadlocks (40P01). Lock in a deterministic order such as primary key.
  • Mixing plain and locking reads in one MySQL Repeatable Read transaction: plain SELECTs read the snapshot while FOR UPDATE and UPDATE read the latest committed rows, so the transaction sees two different states.

CLOSE THE AI. EXPLAIN THIS.

Two admins remove the last two on-call doctors at the same moment, and both requests succeed. Name the anomaly, explain why Repeatable Read does not catch it, and give two different fixes with their trade-offs.

SOURCES

Explainer reviewed