DevgainsDevgainsDevgains
All articles

Database Transaction Isolation Levels Explained: Read Committed to Serializable

·10 min read

Transaction isolation levels decide what one transaction is allowed to see while other transactions are running at the same time. Pick the wrong level and two concurrent requests can read each other's half-finished work, double-spend a balance, or oversell the last item in stock — bugs that never show up in single-user testing and only surface under production load. This guide is a supporting deep-dive in the Devgains database cluster. It sits alongside Postgres VACUUM, which explains the MVCC machinery that makes most of these isolation levels possible, and the Postgres performance pillar, which covers what happens once your queries are correct and fast.

Quick answer: what are transaction isolation levels?

Transaction isolation levels are a contract between your application and the database about how much concurrent transactions can interfere with each other. The SQL standard defines four levels, from weakest to strongest:

  • Read Uncommitted — can see other transactions' uncommitted changes.
  • Read Committed — only sees committed data, but a value can change between two reads.
  • Repeatable Read — every read in the transaction sees the same snapshot.
  • Serializable — transactions behave as if they ran one at a time, in some order.

Higher levels prevent more anomalies but cost more in locking, retries, or aborted transactions. The job is to pick the weakest level that is still correct for the work that transaction does.

Why isolation levels matter

Databases run thousands of transactions concurrently. Isolation is the "I" in ACID, and it's the property that stops that concurrency from corrupting your data. Without it, a classic bug looks like this: two requests read a wallet balance of $100 at the same time, both subtract $100, and both write $0 — the account was debited twice but only dropped once. That's a lost update, and the isolation level you choose determines whether the database catches it for you or silently lets it through.

The catch is that stronger isolation is not free. Serializable transactions may block each other or abort with a serialization error you have to retry. So isolation is a deliberate trade-off between correctness and throughput, and understanding it is what lets you make that trade on purpose instead of discovering it in an incident.

The three read phenomena

The SQL standard defines isolation levels by which anomalies they forbid. There are three you need to know:

  • Dirty read — you read a row another transaction has modified but not yet committed. If that transaction rolls back, you acted on data that never existed.
  • Non-repeatable read — you read a row, another transaction commits an UPDATE to it, and when you read it again in the same transaction the value has changed.
  • Phantom read — you run a range query (WHERE status = 'pending'), another transaction commits a new row that matches, and re-running the query returns an extra "phantom" row that wasn't there before.

A fourth, the lost update, isn't in the original standard table but is the one that bites real applications most often: two transactions read-modify-write the same row and one overwrites the other.

The four isolation levels

Here is how the standard levels map to the anomalies they prevent. This is the table worth committing to memory.

Isolation levelDirty readNon-repeatable readPhantom read
Read UncommittedPossible¹PossiblePossible
Read CommittedPreventedPossiblePossible
Repeatable ReadPreventedPreventedPossible²
SerializablePreventedPreventedPrevented

¹ In PostgreSQL, Read Uncommitted behaves exactly like Read Committed — dirty reads never happen, because MVCC never exposes uncommitted row versions. ² The standard permits phantoms at Repeatable Read, but PostgreSQL's snapshot-based Repeatable Read prevents them too. This is why the same level name behaves differently across engines — always check your database's docs.

The critical lesson: isolation levels are defined by the standard, but implemented by each database. MySQL's InnoDB defaults to Repeatable Read; PostgreSQL and SQL Server default to Read Committed. The level name is a floor of guarantees, not a precise spec.

How it works under the hood: MVCC vs locking

There are two broad strategies databases use to deliver isolation.

Multi-Version Concurrency Control (MVCC) — used by PostgreSQL and MySQL InnoDB — keeps multiple versions of each row. When a transaction starts a statement (Read Committed) or starts the transaction (Repeatable Read), it takes a snapshot: a consistent view of the data as of that moment. Readers never block writers and writers never block readers, because each transaction reads from its own snapshot. This is the same dead-tuple machinery described in the VACUUM guide — old row versions stick around precisely so older snapshots can still see them.

Pessimistic locking — used more heavily by SQL Server's default and by any database under Serializable-via-locking — acquires shared and exclusive locks so conflicting transactions wait. It's simpler to reason about but serializes more work and can deadlock.

PostgreSQL's Serializable is special: it uses Serializable Snapshot Isolation (SSI), which runs optimistically like Repeatable Read but tracks read/write dependencies between transactions and aborts one if it detects a pattern that could violate serializability. That's why Serializable code must always be prepared to retry.

Step-by-step: choosing and setting an isolation level

Setting the level is one line of SQL. The judgment is knowing when to raise it.

1. Set it per transaction. Most databases let you set isolation on a single transaction, which is safer than changing the global default:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
 
-- Book the last seat only if none is taken yet.
SELECT count(*) FROM bookings WHERE seat_id = 42;
INSERT INTO bookings (seat_id, user_id) VALUES (42, 1001);
 
COMMIT;

At Read Committed, two users could both run the SELECT, both see zero bookings, and both INSERT — double-booking the seat. At Serializable, PostgreSQL detects the write-skew conflict and aborts one transaction with a serialization failure.

2. Handle serialization failures with a retry loop. Serializable and Repeatable Read can fail with SQLSTATE 40001. This is expected, not a bug — retry the whole transaction:

async function runSerializable<T>(
  db: Pool,
  work: (tx: PoolClient) => Promise<T>,
): Promise<T> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const tx = await db.connect();
    try {
      await tx.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
      const result = await work(tx);
      await tx.query("COMMIT");
      return result;
    } catch (err: any) {
      await tx.query("ROLLBACK");
      // 40001 = serialization_failure, 40P01 = deadlock_detected
      if (err.code === "40001" || err.code === "40P01") continue;
      throw err;
    } finally {
      tx.release();
    }
  }
  throw new Error("Transaction failed after 5 serialization retries");
}

The transaction body must be idempotent and side-effect-free until commit — never send an email or charge a card mid-transaction, because a retry would repeat it. Push those side effects behind a durable outbox instead, as the outbox pattern describes.

3. For a single hot row, prefer explicit row locks over raising the whole level. When the conflict is one row (a counter, a balance), SELECT ... FOR UPDATE at Read Committed is cheaper than Serializable:

BEGIN;
SELECT balance FROM accounts WHERE id = 7 FOR UPDATE;  -- locks the row
UPDATE accounts SET balance = balance - 100 WHERE id = 7;
COMMIT;

The second transaction blocks on FOR UPDATE until the first commits, then reads the fresh balance — no lost update, no full-transaction retry.

Best practices

  • Default to Read Committed; raise deliberately. It's the right level for the vast majority of OLTP work and the default in Postgres and SQL Server for good reason.
  • Use Serializable for invariants across rows — "no double booking", "sum of splits equals the total", write-skew cases a single row lock can't cover.
  • Always retry on 40001. Serializable without a retry loop is a latent outage.
  • Keep transactions short. Long transactions hold snapshots open, block VACUUM, and widen the window for conflicts.
  • Never do I/O inside a transaction. No HTTP calls, no email — they can't be rolled back and they stretch the transaction.
  • Use FOR UPDATE for point contention. Cheaper than escalating the whole transaction's isolation.

Common mistakes

  • Assuming the level name means the same thing everywhere. Postgres Repeatable Read blocks phantoms; the standard doesn't require it; MySQL's does but with different locking behavior. Read your engine's docs.
  • Read-modify-write at Read Committed without a lock. This is the lost-update bug. Add FOR UPDATE, do the update in one SQL statement, or use an optimistic version column.
  • Turning on Serializable and forgetting the retry loop. The first serialization failure becomes a 500 in production.
  • Doing non-idempotent side effects inside a retryable transaction. A retry double-fires them — a classic double-charge, closely related to why idempotency keys exist.
  • Long-running transactions "to be safe". They don't add safety; they add lock contention and table bloat.

Takeaways

  • Isolation levels are the contract for how much concurrent transactions can see of each other's work.
  • The standard defines them by which anomalies they forbid: dirty reads, non-repeatable reads, and phantoms.
  • Read Committed is the sane default; Serializable buys full correctness at the price of retries.
  • PostgreSQL delivers isolation with MVCC snapshots, and Serializable via SSI, which aborts conflicting transactions rather than blocking them.
  • Pick the weakest level that is still correct, keep transactions short, and always retry serialization failures.

FAQ

What are transaction isolation levels? They are the rules that define how and when the changes made by one transaction become visible to other concurrent transactions. The SQL standard defines four — Read Uncommitted, Read Committed, Repeatable Read, and Serializable — ordered from the weakest guarantees to the strongest.

What is the difference between Read Committed and Repeatable Read? Read Committed takes a fresh snapshot for each statement, so a row can change between two reads in the same transaction. Repeatable Read takes one snapshot at the start of the transaction, so every read sees the same data no matter what other transactions commit.

What is a phantom read? A phantom read happens when a transaction re-runs a range query and finds new rows that a concurrent transaction inserted and committed in between. Serializable prevents phantoms; PostgreSQL's Repeatable Read does too, though the SQL standard doesn't require it.

Which isolation level should I use? Use Read Committed by default — it's correct for most workloads and cheap. Raise to Serializable only for transactions that enforce an invariant across multiple rows, and pair it with a retry loop for serialization failures.

Is Serializable slow? Serializable adds overhead from conflict detection and forces you to retry aborted transactions, so it has lower throughput under contention. For most applications the cost is acceptable on the few transactions that truly need it — not as a global default.

Conclusion

Isolation levels are one of the few database settings where the default is usually right and the exceptions are the whole game. Read Committed handles almost everything; the skill is recognizing the transaction that guards a cross-row invariant and reaching for Serializable — with a retry loop — or a targeted FOR UPDATE when it's a single hot row. Get that judgment right and concurrency bugs stop being 3 a.m. surprises. From here, the Postgres performance pillar shows how the right indexes, connection pooling, and clean tables keep those correct transactions fast under real load.

References

10 min read

Read next