Transactions and Isolation
The concurrency contract your pipelines assume
BEGIN/COMMIT/ROLLBACK plus isolation levels (read committed, repeatable read, serializable) define what concurrent readers and writers can observe — the contract pipelines rely on.
What you will learn
- Explain BEGIN/COMMIT/ROLLBACK semantics
- Compare read committed vs serializable
- Reason about locking and the trade-offs for batch loads
What is this?
A transaction groups statements so they commit or roll back atomically. Isolation levels decide what a transaction sees of concurrent changes: READ COMMITTED sees each statement's latest committed data; REPEATABLE READ keeps one snapshot; SERIALIZABLE prevents whole classes of races. Data pipelines fight over this in incremental loads, dedup and reconcile jobs.
Why do you need it?
- Incremental loads must commit as a unit or a retry double-writes.
- Two pipelines building the same table concurrently need a defined isolation story (usually 'last writer wins' or table-level lock).
- Understanding snapshotting explains 'why did my read change mid-query'.
Mental model
Editing shared documents with track changes
Isolation levels
READ COMMITTED = every refresh shows the newest saved version. REPEATABLE READ = your copy stays frozen from when you opened it. SERIALIZABLE = like a turn-taking meeting where your edits can't surprise anyone.
How it works
- BEGIN starts the transaction; statements accumulate; COMMIT makes them durable; ROLLBACK undoes them.
- MVCC keeps multiple versions so readers never block writers.
- READ COMMITTED (default in Postgres/MySQL) re-evaluates per statement: two selects can see different snapshots.
- REPEATABLE READ freezes one snapshot for the lifetime of the transaction (Postgres).
- SERIALIZABLE adds conflict detection; failures return 'could not serialize access' and require a retry.
Commit and rollback paths.
Examples
Atomic upsert idiom
BEGIN;DELETE FROM daily_sales WHERE day = '2026-01-10';INSERT INTO daily_sales SELECT day, SUM(amount) FROM sales WHERE day = '2026-01-10' GROUP BY day;COMMIT;Delete-then-insert inside one transaction is atomic: readers see either the old or the new partition, never both.
In production
- A nightly loader wraps TRUNCATE+INSERT in a transaction so the target never exposes a half-partition.
- A reconciliation step runs at REPEATABLE READ so both SELECTs agree with each other.
- A row-locking `SELECT ... FOR UPDATE` guards a counter being incremented by concurrent workers.
Best practices
- Keep transactions short: long ones hold locks/snapshots and bloat MVCC.
- Retry SERIALIZABLE failures with a bounded backoff — they are part of the contract, not a bug.
- For bulk loads, prefer single big transaction per partition (fast) vs per-row (slow, yet safer) based on target.
- Set an explicit timeout so a stalled transaction can't pin locks forever.
Common mistakes
- Committing row-by-row in a loop, losing atomicity on error mid-batch.
- Forgetting ROLLBACK on exception, leaving a hanging transaction with locks.
- Blaming 'race condition' when the real fix is an explicit isolation level or a lock.
- Reading a fact while another session mid-commit and trusting a partial window.
Common issues
Could not serialize access due to concurrent updateSerializable conflict between two transactions touching the same rows
Retry the transaction; keep the window short; reduce contention.
Table grows faster than rows explainMVCC bloat from long-running transactions holding old versions
Commit promptly, run VACUUM/reclaim, and cap transaction duration.
Debugging tips
- pg_stat_activity shows idle-in-transaction sessions holding locks — kill them.
- When a 'serializable' error appears, wrap in a retry helper and log which transaction conflicted.
- LOCK monitoring (pg_locks) reveals who holds what — the deadlock story writes itself.
Interview checkpoint
Quick quiz
Which statement makes a transaction's writes durable?
Under READ COMMITTED, two SELECTs in one transaction always agree.