Isolation Levels in PostgreSQL: A Beginner-Friendly Guide
If ACID explains why transactions matter, then isolation levels explain how they behave when multiple users hit the database at the same time.
Most developers know the names — READ COMMITTED, REPEATABLE READ, SERIALIZABLE — but they rarely understand what they actually protect you from. This article breaks them down in simple language with real examples from the Pagila Sample Database.
The Three Problems Isolation Levels Solve
Before explaining isolation levels, you need to know the “bad things” that can happen:
- Dirty reads — seeing another transaction’s uncommitted data
- Non-repeatable reads — rereading a row and getting different results
- Phantom reads — the same query returns more rows the second time
PostgreSQL already avoids dirty reads completely (thanks to MVCC). So really, you’re choosing how much protection you want from the other two problems.
Isolation Level 1: READ COMMITTED (The Default)
This is what PostgreSQL uses unless you change it. Every statement sees a fresh snapshot of the database.
The trade-off: simple and fast, but allows non-repeatable reads.
Example using Pagila’s inventory table:
-- Session A
BEGIN;
SELECT store_id FROM inventory WHERE inventory_id = 100;
-- Session B
UPDATE inventory SET store_id = 2 WHERE inventory_id = 100;
COMMIT;
-- Session A runs the SELECT again
SELECT store_id FROM inventory WHERE inventory_id = 100;
Session A sees different results each time. Nothing is “wrong” — that’s exactly what READ COMMITTED allows.
Isolation Level 2: REPEATABLE READ (Stronger Snapshot)
REPEATABLE READ freezes your view of the database for the entire transaction. That means all SELECTs see the same snapshot, even if other transactions commit changes.
Prevents: non-repeatable reads
Still allows: phantom reads
Example:
-- Session A
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM rental WHERE customer_id = 300;
-- Session B inserts a new rental
INSERT INTO rental (rental_date, inventory_id, customer_id, staff_id)
VALUES (NOW(), 1, 300, 1);
COMMIT;
-- Session A runs the SELECT again
SELECT COUNT(*) FROM rental WHERE customer_id = 300;
Session A sees the same count both times. It doesn’t see the new rental until the transaction ends.
Isolation Level 3: SERIALIZABLE (The “Make Everything Correct” Mode)
SERIALIZABLE is the strongest isolation level. It makes transactions behave as if they were run one-at-a-time in perfect order — even though they’re concurrent.
But it doesn’t use locks for everything. PostgreSQL uses predicate locking and conflict detection to keep things consistent.
Prevents: dirty reads, non-repeatable reads, phantom reads
Example: two sessions trying to insert overlapping rentals.
-- Session A
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Both sessions read the same snapshot
SELECT COUNT(*) FROM rental WHERE inventory_id = 500;
-- Session B (in another window)
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM rental WHERE inventory_id = 500;
-- Both sessions now decide to insert a rental for the same inventory_id
-- Session A
INSERT INTO rental (rental_date, inventory_id, customer_id, staff_id)
VALUES (NOW(), 500, 300, 1);
-- Session B
INSERT INTO rental (rental_date, inventory_id, customer_id, staff_id)
VALUES (NOW(), 500, 300, 1);
-- One of them commits successfully
COMMIT; -- run this in Session A
-- The other fails at commit time
COMMIT; -- run this in Session B
-- Result in Session B:
-- ERROR: could not serialize access due to read/write dependencies
-- HINT: The transaction might succeed if retried.
SERIALIZABLE doesn’t block the second transaction — it simply forces it to retry. This guarantees correctness but at the cost of occasional failures.
Quick Summary Table
| Isolation Level | Dirty Reads | Non-Repeatable Reads | Phantom Reads |
|---|---|---|---|
| Read Committed | ❌ Blocked | ✅ Allowed | ✅ Allowed |
| Repeatable Read | ❌ Blocked | ❌ Blocked | ✅ Allowed |
| Serializable | ❌ Blocked | ❌ Blocked | ❌ Blocked |
Which Isolation Level Should You Use?
Most applications should stay with READ COMMITTED. It’s fast and correct for 99% of workloads.
Use REPEATABLE READ when:
- You need stable results inside long-running transactions
- You’re running reports that must not change mid-query
Use SERIALIZABLE when:
- Correctness is more important than performance
- You can retry failed transactions in your application
- You want to prevent phantom reads entirely
Conclusion
Isolation levels let you choose the balance between performance and consistency guarantees. PostgreSQL’s MVCC makes most of the heavy lifting automatic, but understanding these levels helps you avoid subtle bugs in concurrent workloads.
If you understand these three levels, you already know more about database concurrency than most developers!