Transactions and isolation levels
Read committed, repeatable read, serializable, snapshot: what each protects against.
A transaction is the database promising you two things. First, the group of statements either all commit or none of them do. Second, while you are mid-transaction, other concurrent transactions will not let you see their half-finished work, and will not let you corrupt theirs. The first promise (atomicity) is easy to reason about. The second one (isolation) is a dial, and the dial has four standard positions plus a popular fifth one. Most engines default to a position that is wrong often enough to cause real bugs.
What “ACID” actually means
The four letters get repeated a lot, so worth being precise:
- Atomicity: a transaction is all-or-nothing. Crash mid-way, the engine rolls it back.
- Consistency: a transaction moves the database from one valid state to another. Constraints hold at commit time.
- Isolation: concurrent transactions do not corrupt each other. The level of isolation is the thing we tune.
- Durability: once
COMMITreturns, the change survives a power cut.
A and D are mostly free from the engine. C is mostly your schema. I is where the design work happens.
The three classical anomalies
A transaction that runs alone on the database is always correct. The hard cases come when two transactions overlap. The SQL standard names three things that can go wrong:
flowchart TB
subgraph DR["Dirty read"]
direction LR
A1["T1 writes balance=50<br/>(uncommitted)"]:::w
A2["T2 reads balance=50"]:::r
A3["T1 rolls back<br/>balance never was 50"]:::bad
A1 --> A2 --> A3
end
subgraph NRR["Non-repeatable read"]
direction LR
B1["T1 reads balance=100"]:::r
B2["T2 writes balance=80, commits"]:::w
B3["T1 reads balance=80<br/>same row, different value"]:::bad
B1 --> B2 --> B3
end
subgraph PR["Phantom read"]
direction LR
C1["T1: SELECT WHERE status='new'<br/>5 rows"]:::r
C2["T2: INSERT new row, commits"]:::w
C3["T1 repeats query<br/>6 rows now"]:::bad
C1 --> C2 --> C3
end
classDef r fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
classDef w fill:#fed7aa,stroke:#c2410c,color:#7c2d12
classDef bad fill:#fecaca,stroke:#b91c1c,color:#7f1d1d
There is a fourth one the standard does not list but you will hit in real systems: write skew, where two transactions each read the same data, decide independently that it is safe to write, and together break an invariant. Snapshot Isolation does not prevent it. Serializable does.
The four standard levels
Each level prevents more anomalies and costs more concurrency.
flowchart LR
RU["Read Uncommitted<br/>allows dirty reads"]:::r
RC["Read Committed<br/>blocks dirty reads"]:::y
RR["Repeatable Read<br/>blocks non-repeatable reads"]:::g
S["Serializable<br/>blocks phantoms,<br/>blocks write skew"]:::a
RU --> RC --> RR --> S
classDef r fill:#fecaca,stroke:#b91c1c,color:#7f1d1d
classDef y fill:#fef3c7,stroke:#a16207,color:#713f12
classDef g fill:#dcfce7,stroke:#15803d,color:#14532d
classDef a fill:#dbeafe,stroke:#1e40af,color:#1e3a8a
Read Uncommitted: you can see another transaction’s uncommitted writes. Almost no engine actually implements this differently from Read Committed today. Postgres just maps it to Read Committed.
Read Committed: you only see committed data, but two reads inside the same transaction can see different values if someone else committed in between. This is the default in Postgres, Oracle, and SQL Server.
Repeatable Read: a read returns the same value all transaction long, even if someone commits a newer one. Postgres implements this as full Snapshot Isolation: every read sees the snapshot taken at the first statement. MySQL InnoDB also defaults here.
Serializable: behaves as if every transaction ran one after another, with no overlap. Postgres uses Serializable Snapshot Isolation (SSI), which detects conflicting access patterns at commit and aborts one. Cost: more retries.
Snapshot Isolation vs Serializable: the write-skew gap
This is the case nobody warns you about. Imagine an on-call rota: at least one doctor must always be on call. Two doctors named Alice and Bob are both on. Both decide independently to take themselves off.
sequenceDiagram
autonumber
participant A as T1 (Alice)
participant DB as Database
participant B as T2 (Bob)
A->>DB: SELECT count(*) WHERE on_call=true (=2)
B->>DB: SELECT count(*) WHERE on_call=true (=2)
A->>DB: UPDATE me SET on_call=false
B->>DB: UPDATE me SET on_call=false
A->>DB: COMMIT
B->>DB: COMMIT
Note over DB: Snapshot Isolation accepts both.<br/>Now zero doctors on call. Invariant violated.
Under Snapshot Isolation (Postgres REPEATABLE READ), both commits succeed. Serializable would abort one. This is why “Postgres Repeatable Read is enough” is wrong for any workload with cross-row invariants.
How Postgres implements isolation: MVCC in one paragraph
Postgres stores multiple versions of each row. Every transaction has a transaction ID (xid). Every row version carries xmin (creating txn) and xmax (deleting/superseding txn). A snapshot is “the set of txns that had committed when I started.” A row version is visible if its xmin is in your snapshot and its xmax is not. Read Committed takes a new snapshot at the start of each statement; Repeatable Read takes one at the first statement and reuses it. No locks needed for reads. This is why Postgres reads never block writes and vice versa. MySQL InnoDB does it differently with undo logs but the user-visible behaviour is similar.
Worked example: setting the level
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- per-transaction (most common):
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true;
-- ... business logic ...
UPDATE doctors SET on_call = false WHERE id = 42;
COMMIT;
-- if SSI detected a conflict, COMMIT throws:
-- ERROR: could not serialize access due to read/write dependencies
-- application must catch this and retry the whole transaction.
-- session-wide default:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- check current setting:
SHOW transaction_isolation;
Serializable in Postgres is not free, but it is much cheaper than people expect. If your retry rate stays under a few percent, just use it for the transactions that touch invariants.
Picking a level for a real workload
- Read-mostly OLTP (a typical web app): default Read Committed is fine for 90% of statements. Wrap the few statements that update a balance or check inventory in a tighter level.
- Anything with an invariant across rows (rota, inventory, double-booking): Serializable, with a retry loop in the app.
- Long-running reports inside the same transaction: Repeatable Read so the report does not flicker.
- Batch jobs: Serializable or explicit row locks. The throughput cost is irrelevant because batches do not race users.
Common mistakes
- Assuming Read Committed is safe enough. It is the default everywhere, and it lets non-repeatable reads through. Two
SELECTs on the same row in one transaction can return different values. - Confusing Repeatable Read with Serializable. Postgres
REPEATABLE READis Snapshot Isolation and does not stop write skew. If two transactions read the same set and each decides “I am safe to write,” both commit. - Wrapping a long HTTP call inside a transaction. You hold a snapshot and possibly locks for the duration of the call. Other writers stall. Push the side effect outside the transaction.
- Forgetting to retry on serialization failures. Under Serializable,
COMMITcan fail with code40001. The app must catch it and rerun the whole transaction. No retry = silent data loss. - Mixing isolation levels in one logical operation. The connection pool hands you a session with the previous user’s
SET TRANSACTIONstill in effect. Reset it explicitly or set per-transaction. - Using
SELECT FOR UPDATE“to be safe” everywhere. That is pessimistic locking sneaking in. It serializes writers and kills throughput. Pick a level deliberately. See Optimistic vs pessimistic locking.
Quick recap
- A transaction promises atomicity and isolation. Isolation is a dial.
- Four standard levels: Read Uncommitted, Read Committed, Repeatable Read, Serializable. Postgres adds Snapshot Isolation under the Repeatable Read name.
- The default almost everywhere is Read Committed. It allows non-repeatable reads and phantoms.
- Snapshot Isolation does not prevent write skew. Serializable does.
- Use Serializable for any code path that defends an invariant, and retry on
40001.
This concept sits in Stage 2 (Storage and data) of the System Design Roadmap.
Last updated