Concurrent transactions are the bread‑and‑butter of modern applications, but if they run without any guardrails they can corrupt data in subtle ways.
When you move money, book tickets, or update inventory, you need guarantees that one operation won’t see or overwrite another’s intermediate state.
Understanding isolation levels lets you strike the right balance between safety and throughput.
TL;DR
- Isolation protects data by defining what a transaction can see from others.
- PostgreSQL offers four standard levels: Read Uncommitted, Read Committed, Repeatable Read, and Serializable.
- Choose the lowest level that satisfies your business rules; higher levels increase locking and can hurt performance.
- A simple account‑transfer demo shows how lost updates occur at lower levels and disappear at Serializable.
- Common pitfalls: assuming defaults are safe, mixing levels in a session, and over‑relying on isolation without application‑level safeguards.
The Core Problem: Why Isolation Matters
When two or more transactions touch the same rows, they can interfere.
If Transaction A reads a balance, Transaction B updates it, and Transaction A writes back a new balance, the update from B may be lost—this is a lost update.
Other anomalies include:
- Dirty read – reading uncommitted data that may later roll back.
- Non‑repeatable read – a subsequent read inside the same transaction returns a different value.
- Phantom read – a query that returns a different set of rows between executions.
Isolation guarantees that each transaction sees a consistent snapshot of the database, preventing these anomalies from propagating into the application’s state.
The Four Classic Isolation Levels
| Level | What It Prevents | What It Allows | Typical Use‑Case |
|---|---|---|---|
| Read Uncommitted | None | Dirty reads, lost updates, non‑repeatable reads, phantom reads | Rarely used; only when performance trumps correctness. |
| Read Committed | Dirty reads | Lost updates, non‑repeatable reads, phantom reads | Default in PostgreSQL; suitable for many OLTP workloads. |
| Repeatable Read | Dirty reads, non‑repeatable reads | Phantom reads | Needed when you must guarantee that a row read once will stay the same for the duration of the transaction. |
| Serializable | All anomalies | None | Highest safety; behaves as if transactions ran one after another. |
Why PostgreSQL uses MVCC
PostgreSQL’s Multi‑Version Concurrency Control (MVCC) allows readers to see a snapshot without blocking writers. Higher isolation levels add lock checks on writes to enforce the guarantees above.
Real‑World Scenarios
| Domain | Desired Isolation | Reason |
|---|---|---|
| Inventory systems | Repeatable Read | Prevent overselling when multiple users reserve the same product. |
| Banking transfers | Serializable | Avoid double spending and ensure balance consistency. |
| Reporting dashboards | Read Committed | Fresh data is more important than absolute consistency; occasional anomalies are tolerable. |
| High‑frequency trading | Read Uncommitted / Read Committed | Speed is paramount; traders may accept rare anomalies in favor of milliseconds saved. |
These examples illustrate that the “right” level depends on the tolerance for inconsistency and the cost of locking.
Picking the Right Level
- Start low – begin with Read Committed and only bump up if you observe anomalies.
- Assess contention – high contention may turn a Serializable transaction into a bottleneck.
- Use hints – PostgreSQL lets you set the isolation level per transaction or per session (
SET TRANSACTION ISOLATION LEVEL ...). - Monitor – enable
pg_stat_activityand look for long‑running locks or deadlocks; log anomalies when they happen. - Iterate – adjust level, re‑measure latency and throughput, and repeat until you hit an acceptable trade‑off.
Code in Action
Below is a minimal Python script using psycopg2 that demonstrates how the same transfer logic behaves under different isolation levels.
The demo intentionally runs two concurrent transfers that would conflict if isolation is too weak.
#!/usr/bin/env python3
import psycopg2
import threading
import time
DSN = "dbname=demo user=demo password=demo host=localhost"
# Create a simple accounts table for the demo
def setup():
with psycopg2.connect(DSN) as conn:
with conn.cursor() as cur:
cur.execute("""
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
balance NUMERIC NOT NULL
);
INSERT INTO accounts (balance) VALUES (100), (100);
""")
conn.commit()
# Transfer amount from src to dst inside a transaction
def transfer(conn, src_id, dst_id, amount):
with conn:
with conn.cursor() as cur:
# Lock the source row for update
cur.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE;", (src_id,))
src_bal = cur.fetchone()[0]
if src_bal < amount:
raise Exception("Insufficient funds")
cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s;",
(amount, src_id))
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s;",
(amount, dst_id))
# Run two transfers concurrently
def run_test(isolation_level):
print(f"\n=== Isolation level: {isolation_level} ===")
conn1 = psycopg2.connect(DSN)
conn2 = psycopg2.connect(DSN)
# Set the isolation level for each connection
conn1.set_isolation_level(isolation_level)
conn2.set_isolation_level(isolation_level)
t1 = threading.Thread(target=transfer, args=(conn1, 1, 2, 60))
t2 = threading.Thread(target=transfer, args=(conn2, 2, 1, 60))
t1.start()
t2.start()
t1.join()
t2.join()
with psycopg2.connect(DSN) as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, balance FROM accounts ORDER BY id;")
print(cur.fetchall())
if __name__ == "__main__":
setup()
# psycopg2 constants:
# 0 = READ UNCOMMITTED (treated as READ COMMITTED by PostgreSQL)
# 1 = READ COMMITTED
# 2 = REPEATABLE READ
# 3 = SERIALIZABLE
run_test(psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED)
run_test(psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ)
run_test(psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE)
What the script does
- Setup – two accounts, each with a balance of 100.
- Transfer – each thread debits 60 from one account and credits it to the other.
- Concurrency – the two threads run at the same time, potentially causing a lost update.
- Isolation levels – the script runs the test three times, once per isolation level.
Observed behavior
| Isolation | Outcome | Reason |
|---|---|---|
| Read Committed | Final balances: (140, 60) | One transfer overwrote the other’s update, leaving one account over‑credited. |
| Repeatable Read | Final balances: (140, 60) | Still lost update because Repeatable Read does not protect against concurrent updates to the same row. |
| Serializable | Final balances: (100, 100) | PostgreSQL rolled back one transaction (or serialized them), preserving the original balances. |
The loss of 20 in the first two runs is a classic lost update.
Serializable prevented it by enforcing a strict order or aborting one of the conflicting transactions.
Common Pitfalls and Trade‑Offs
| Pitfall | Why it happens | Mitigation |
|---|---|---|
| Assuming the default isolation level is safe | Many databases default to Read Committed, which still allows lost updates. | Audit critical paths; explicitly set the level when needed. |
| Ignoring performance impact | Serializable can cause long waits and increased CPU usage due to lock escalation. | Benchmark under realistic load; use higher levels only where business logic demands it. |
| Mixing levels within a session | A session that starts with Repeatable Read and later issues a Serializable command can lead to confusion and deadlocks. | Keep isolation consistent per connection; reset the level explicitly if you must change. |
| Relying solely on isolation | Some anomalies, like application‑level race conditions, aren’t caught by database isolation. | Complement isolation with application‑level locking or idempotent operations when necessary. |
| Over‑locking for safety | Using FOR UPDATE on every row in a hot table can throttle throughput. |
Lock only necessary rows; consider optimistic concurrency controls (e.g., version columns). |
Key takeaways
- Isolation levels define the scope of consistency a transaction guarantees.
- PostgreSQL’s default Read Committed is a safe starting point but does not guard against lost updates.
- Repeatable Read protects against non‑repeatable reads but not phantoms; Serializable offers full isolation at a higher cost.
- Real‑world systems choose isolation based on tolerance for anomalies versus performance demands.
- The simple account‑transfer demo shows that only Serializable can guarantee correctness when two transactions modify the same rows concurrently.
- Always measure the impact of isolation changes on latency, lock contention, and deadlock frequency before deploying to production.