Isolation Levels in SQL: The Setting Most Developers Never Touch

by Arif Ikhsanudin, Backend Developer

The bug that only shows up under concurrency

Your order processing system tallies revenue for a billing cycle. It reads total revenue, calculates a tax amount, then writes the invoice. Under load, two concurrent invoice generation jobs read the same revenue total, both calculate independently, and both write. One overwrites the other. You've lost a billing record and neither job reported an error.

This is a non-repeatable read combined with a lost update — classic concurrency anomalies that isolation levels are designed to prevent. The default isolation level in most databases (READ COMMITTED) does not protect against them. Understanding what your isolation level guarantees — and what it doesn't — determines whether this class of bug can affect you.

The four isolation levels

SQL defines four isolation levels, each preventing a different class of anomaly:

READ UNCOMMITTED: a transaction can read rows modified by uncommitted transactions ("dirty reads"). Almost never correct. Not even fully implemented in PostgreSQL (reads committed data regardless).

READ COMMITTED: a transaction only sees rows committed before each statement executes. This is the PostgreSQL default and the MySQL InnoDB default. Prevents dirty reads, but allows non-repeatable reads and phantom reads.

REPEATABLE READ: a transaction sees a consistent snapshot of the database as of when the transaction started. Prevents dirty reads and non-repeatable reads. MySQL's InnoDB also prevents phantom reads at this level (via gap locks). PostgreSQL prevents phantom reads through MVCC snapshots.

SERIALIZABLE: the highest isolation level. Transactions appear to execute serially, even if they run concurrently. Prevents all anomalies. In PostgreSQL this is implemented via Serializable Snapshot Isolation (SSI), which detects and aborts transactions that would have produced non-serializable results.

What the anomalies actually look like

Dirty read (prevented by all levels ≥ READ COMMITTED):

T1: UPDATE accounts SET balance = 1000 WHERE id = 1
T2: SELECT balance FROM accounts WHERE id = 1  -- sees 1000
T1: ROLLBACK  -- T2 read data that never existed

Non-repeatable read (prevented by REPEATABLE READ and above):

T1: SELECT balance FROM accounts WHERE id = 1  -- reads 500
T2: UPDATE accounts SET balance = 1000 WHERE id = 1; COMMIT
T1: SELECT balance FROM accounts WHERE id = 1  -- reads 1000 (changed!)
-- T1 got different results for the same query within the same transaction

Phantom read (prevented by SERIALIZABLE):

T1: SELECT COUNT(*) FROM orders WHERE status = 'pending'  -- returns 10
T2: INSERT INTO orders (status) VALUES ('pending'); COMMIT
T1: SELECT COUNT(*) FROM orders WHERE status = 'pending'  -- returns 11
-- T1 sees a "phantom" row that didn't exist at transaction start

Write skew (only prevented by SERIALIZABLE):

-- Two doctors are on call. At least one must be on call at all times.
T1: SELECT COUNT(*) FROM doctors WHERE on_call = true  -- sees 2
T2: SELECT COUNT(*) FROM doctors WHERE on_call = true  -- sees 2
T1: UPDATE doctors SET on_call = false WHERE id = 1    -- goes off call (1 remaining)
T2: UPDATE doctors SET on_call = false WHERE id = 2    -- goes off call (0 remaining!)
-- Both transactions checked the constraint. Both passed. Both violated it.

Write skew is the subtlest anomaly and can only be prevented by SERIALIZABLE isolation or explicit SELECT FOR UPDATE to lock the rows being checked.

Choosing the right level

READ COMMITTED is correct when:

  • Each operation is independent and doesn't depend on a consistent view across multiple reads within the same transaction
  • You're doing simple CRUD without multi-step read-then-write logic

REPEATABLE READ is correct when:

  • A transaction reads data multiple times and requires consistency between reads
  • You're generating reports within a transaction that must see a consistent snapshot

SERIALIZABLE is correct when:

  • Your transactions implement "check a condition, then write based on that condition" (the doctor on-call pattern, inventory deduction, balance checks)
  • Correctness is more important than throughput

Setting isolation level per transaction in PostgreSQL:

BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ... your transaction ...
COMMIT;

Or for a single session:

SET default_transaction_isolation = 'repeatable read';

The performance cost

Higher isolation levels cost more:

  • REPEATABLE READ in PostgreSQL: no additional locking cost (MVCC snapshot is taken once at transaction start instead of per-statement). Very low overhead.
  • SERIALIZABLE in PostgreSQL (SSI): tracks read/write dependencies to detect conflicts. Overhead is low for non-conflicting transactions, but serialization failures (transactions aborted due to detected conflicts) require retry logic.

In MySQL InnoDB, REPEATABLE READ uses gap locks and next-key locks, which can increase lock contention compared to READ COMMITTED. This is a meaningful consideration on high-write tables.

The practical default question

Most applications use READ COMMITTED by default and work around its limitations with SELECT FOR UPDATE on specific sensitive operations. This is a reasonable approach when you know which operations need stronger isolation. If you're building a financial system or any system with complex multi-step consistency invariants, evaluate SERIALIZABLE — and build retry logic, because serialization failures are possible and must be handled.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Citadel and CME Group Pay Chicago's Backend Developers More Than Most Startups Can Afford

Chicago has world-class backend engineering talent. The financial firms that employ most of it have built compensation structures specifically designed to keep it.

Read more

Why Developers Seem Slow When Building “Simple Features”

“It’s just a small feature, right?” That phrase is deceptively dangerous—it often leads to frustration when progress feels slow.

Read more

Event-Driven vs Request-Driven Architecture — Which One to Pick and When

Event-driven architecture solves temporal decoupling and fan-out elegantly, but it trades synchronous clarity for eventual consistency — a trade-off that only makes sense in specific structural contexts.

Read more

What It Actually Costs to Hire a Senior Backend Developer in Sydney

You budgeted $160K for a senior backend hire. Then you saw what they actually cost once super, recruiter fees, and three months of low output were factored in.

Read more