Isolation is the I in ACID and the least understood of the four letters. The textbook answer is that concurrent transactions should behave as if they ran one after another, which is serializability. Almost nobody runs at that level. Databases default to weaker levels because they are faster and block less, and those weaker levels allow specific, nameable anomalies. The trouble is that the names of the levels do not mean the same thing across engines: REPEATABLE READ in PostgreSQL and in MySQL are different guarantees, and Oracle's SERIALIZABLE is not serializable.
This article is for the engineer who needs to pick a level and write code that is correct under it. It covers the anomalies that matter, the three mechanisms engines use, what the level names map to in the major engines, two-session scripts you can paste into two terminals to watch anomalies happen, and the retry loop every application running above READ COMMITTED needs. The broader engine internals, logging and recovery are covered in transactional database architecture; version storage is covered in MVCC.
Isolation is a list of forbidden anomalies
The SQL standard defines its four levels by three phenomena: dirty reads, non-repeatable reads and phantoms. Berenson and colleagues showed in 1995 that this is incomplete, because a level that forbids all three can still produce non-serializable results. A practical list has seven entries:
| Anomaly | What happens | Business symptom |
|---|---|---|
| Dirty write | T2 overwrites T1's uncommitted change | Rows mixing two transactions' writes |
| Dirty read | T2 reads T1's uncommitted change, T1 rolls back | Decisions based on data that never existed |
| Non-repeatable read | T1 reads a row twice and sees T2's committed change | Report totals that do not add up |
| Phantom | T1 reruns a range query and sees rows T2 inserted | Double-booked slot despite a check |
| Lost update | T1 and T2 both read, modify and write; one write vanishes | Balance missing a deposit |
| Read skew | T1 reads x before and y after T2 changes both | Transfer that appears to create money |
| Write skew | T1 and T2 read overlapping data and write different rows, breaking a rule that spans both | Zero doctors on call |
Dirty writes are forbidden by every real engine at every level. The rest are what you choose between. Write skew is the one to remember, because snapshot isolation allows it and snapshot isolation is what many engines give you when you ask for something that sounds strong.
Three mechanisms
Two-phase locking. Transactions acquire locks as they go and release them only at the end. Reads take shared locks, writes take exclusive locks, and serializable locking also locks ranges so that inserts into a scanned range wait. It is serializable when done fully, and its costs are blocking and deadlocks: readers wait for writers and vice versa, and cycles in the wait-for graph must be broken by killing a victim, as described in deadlock detection.
Snapshot isolation. Each transaction reads from a consistent snapshot taken at its start, using multi-version storage, so reads never block and are never blocked. Writers still lock the rows they change, and if two concurrent transactions update the same row, the second to commit, or to try to update, fails. That first-committer-wins rule prevents lost updates on a single row, but not write skew, because two transactions writing different rows never conflict.
Serializable snapshot isolation. SSI, from Cahill, Rohm and Fekete, keeps snapshot reads and adds bookkeeping: it records what each transaction read, and notices when a transaction both has a read-write dependency on one concurrent transaction and is the source of another. That structure, a pivot, is necessary for any serialization anomaly under snapshot isolation, so aborting one transaction when it appears is enough to guarantee serializability. It never makes readers wait, and it sometimes aborts transactions that would have been fine.
What the names mean in each engine
| Requested level | PostgreSQL | MySQL InnoDB | Oracle | SQL Server |
|---|---|---|---|---|
| READ UNCOMMITTED | Behaves as READ COMMITTED | Dirty reads possible | Not offered | Dirty reads possible (locking) |
| READ COMMITTED | Default; new snapshot per statement | Fresh snapshot per read, no gap locks | Default; statement-level snapshot | Default; locking, or statement snapshots if READ_COMMITTED_SNAPSHOT is on |
| REPEATABLE READ | Snapshot isolation; no phantoms; write skew possible | Default; snapshot for plain reads, current rows plus next-key locks for writes and locking reads | Not offered | Locking; phantoms possible |
| SNAPSHOT | (use REPEATABLE READ) | Not offered | (use SERIALIZABLE) | Snapshot isolation; requires ALLOW_SNAPSHOT_ISOLATION |
| SERIALIZABLE | SSI; truly serializable | Locking: plain SELECTs become SELECT ... FOR SHARE when autocommit is off | Snapshot isolation; write skew possible | Locking with key-range locks; serializable |
Three traps stand out. PostgreSQL's REPEATABLE READ is stronger than the standard asks, because it forbids phantoms, but it is still snapshot isolation and allows write skew. MySQL's REPEATABLE READ mixes two views in one transaction: plain SELECTs see the snapshot from the first read, while UPDATE, DELETE and locking reads act on the latest committed rows. And Oracle's SERIALIZABLE, which reports failures as ORA-08177, is snapshot isolation, so write skew is possible there too. Distributed SQL databases add their own mappings; read the vendor's isolation page before assuming anything, and test.
Reproduce a lost update
Open two psql sessions against a PostgreSQL database at the default READ COMMITTED level. The application pattern here, read a value, compute in the application, write it back, is extremely common:
CREATE TABLE account (id int PRIMARY KEY, balance int);
INSERT INTO account VALUES (1, 100);
-- Session A -- Session B
BEGIN; BEGIN;
SELECT balance FROM account WHERE id = 1; SELECT balance FROM account WHERE id = 1;
-- app computes 100 + 10 -- app computes 100 + 20
UPDATE account SET balance = 110 WHERE id = 1;
UPDATE account SET balance = 120 WHERE id = 1;
-- blocks until A commits
COMMIT;
-- proceeds, overwrites 110
COMMIT;
SELECT balance FROM account WHERE id = 1; -- 120: the +10 is lostRerun it with BEGIN ISOLATION LEVEL REPEATABLE READ in both sessions. Session B's UPDATE now fails when A commits, with could not serialize access due to concurrent update and SQLSTATE 40001, and the application must retry B from the beginning. Under MySQL InnoDB's default REPEATABLE READ the same script loses the update, because B's UPDATE writes an absolute value computed from a stale snapshot read. Three fixes work everywhere: write the update relative to the current row (SET balance = balance + 20), lock the row when reading it (SELECT ... FOR UPDATE), or add a version column and make the UPDATE conditional on it.
Reproduce write skew
The classic example: a hospital requires at least one doctor on call. Alice and Bob are both on call and both feel unwell. Each transaction checks that someone else is on call, then takes itself off:
CREATE TABLE oncall (doctor text PRIMARY KEY, on_call boolean);
INSERT INTO oncall VALUES ('alice', true), ('bob', true);
-- Session A (Alice) -- Session B (Bob)
BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE on_call; SELECT count(*) FROM oncall WHERE on_call;
-- 2, so it is safe to leave -- 2, so it is safe to leave
UPDATE oncall SET on_call = false UPDATE oncall SET on_call = false
WHERE doctor = 'alice'; WHERE doctor = 'bob';
COMMIT; COMMIT;
-- both succeed: nobody is on callThe two transactions updated different rows, so first-committer-wins never fired. Change both to ISOLATION LEVEL SERIALIZABLE and one of the commits fails with could not serialize access due to read/write dependencies among transactions, SQLSTATE 40001. PostgreSQL's SSI saw that each transaction read data the other then wrote. Without SERIALIZABLE, the fix is to materialise the conflict: lock the rows the rule depends on with SELECT ... FOR UPDATE, or lock a single row that represents the shift, so the two transactions collide on something.
The retry loop is part of the contract
Above READ COMMITTED, and at any level with locking, the database will sometimes abort a transaction that did nothing wrong. That is the price of not blocking forever. The application must catch those errors and rerun the whole transaction, including its reads, because the decision it made was based on data that is now stale. Retrying only the failed statement is a bug.
import random, time
import psycopg
RETRYABLE = {"40001", "40P01"} # serialization_failure, deadlock_detected
def run_serializable(conn, work, max_attempts=5):
for attempt in range(1, max_attempts + 1):
try:
with conn.transaction():
conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
return work(conn) # all reads and writes happen inside
except psycopg.Error as e:
if e.sqlstate not in RETRYABLE or attempt == max_attempts:
raise
time.sleep(min(0.5, 0.01 * 2 ** attempt) * random.random())
def go_off_call(conn, doctor):
n = conn.execute("SELECT count(*) FROM oncall WHERE on_call").fetchone()[0]
if n < 2:
raise ValueError("cannot leave: last doctor on call")
conn.execute("UPDATE oncall SET on_call = false WHERE doctor = %s", (doctor,))The work function must be safe to run more than once, so no emails, HTTP calls or other side effects inside it; collect them and perform them after the commit, or write them to an outbox table in the same transaction. On MySQL the retryable errors are deadlocks (error 1213, SQLSTATE 40001) and, if you choose, lock wait timeouts (error 1205); on SQL Server, deadlock victims (1205) and snapshot update conflicts (3960); on Oracle, ORA-08177. Cap attempts and alert when the retry rate climbs, because a rising rate means contention that code changes, not retries, must fix.
Choosing a level per transaction
Isolation can be set per transaction, so choose per workload instead of one global setting:
- Single-statement writes such as
UPDATE ... SET x = x + 1are safe at READ COMMITTED in every engine above, because the statement reads and writes the current row atomically. - Read-modify-write on one row: READ COMMITTED with
SELECT ... FOR UPDATE, or an optimistic version check. Both are portable. - Rules spanning several rows, like uniqueness across a range, capacity limits or on-call rules: PostgreSQL SERIALIZABLE with a retry loop, or explicit locks on the rows the rule depends on. Do not rely on REPEATABLE READ or Oracle SERIALIZABLE here.
- Consistent reports and exports: a snapshot level, REPEATABLE READ in PostgreSQL or MySQL, so every query sees one point in time. In PostgreSQL,
SERIALIZABLE READ ONLY DEFERRABLEwaits for a safe snapshot and then runs without any risk of serialization failure. - Constraints beat isolation wherever they can express the rule. A unique index or exclusion constraint is enforced at every level and cannot be forgotten by a new code path.
Failure modes and operations
- Long transactions. An open snapshot pins old row versions, which blocks vacuum in PostgreSQL and grows the undo history in InnoDB. Set statement and idle-in-transaction timeouts, and keep reporting queries on a replica.
- Retry storms. A hot row under SERIALIZABLE turns contention into abort-and-retry loops. Watch the rate of 40001 errors per endpoint and redesign hot rows, for example by splitting a counter into several rows.
- SSI memory and false positives. PostgreSQL tracks reads with SIReadLock predicate locks, visible in pg_locks. They never block, but they consume shared memory and are promoted to coarser locks when there are too many, which raises false-positive aborts. Index the columns your serializable queries filter on, so the locks stay fine-grained, and raise
max_pred_locks_per_transactionif promotions are common. - Lock waits masquerading as slowness. Under locking levels, latency spikes are often waits. Sample lock views during incidents before tuning queries.
- Silent level changes. Connection pools, ORMs and database-level settings such as SQL Server's READ_COMMITTED_SNAPSHOT change what the default means. Log the effective isolation level at connection start in each service.
What to do next
- Find out which level each service actually runs at, from the connection settings, not from assumptions.
- Search your code for read-then-write patterns and convert them to relative updates, FOR UPDATE, or version checks.
- List invariants that span rows, and enforce each with a constraint, SERIALIZABLE, or explicit locks.
- Wrap every transaction above READ COMMITTED in a retry loop keyed on SQLSTATE 40001 and deadlock codes, with no side effects inside.
- Run the lost-update and write-skew scripts above against your own engine and version, and keep them as regression tests.
- Alert on serialization-failure and deadlock rates, and on the age of the oldest open transaction.