Transactions and isolation levels
BEGIN, COMMIT and ROLLBACK, what ACID actually promises, the read phenomena isolation levels prevent, and how to recognise a deadlock before your users report it.
Transactions and ACID
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- or ROLLBACK to undo every statement in the block
-- savepoints let you undo part of a transaction
BEGIN;
INSERT INTO audit (note) VALUES ('start');
SAVEPOINT after_audit;
UPDATE accounts SET balance = -1 WHERE id = 1; -- violates a CHECK
ROLLBACK TO SAVEPOINT after_audit; -- the audit row survives
COMMIT;| Property | Means | Commonly lost to |
|---|---|---|
| Atomicity | Every statement commits, or none does | A crash midway through a non-transactional batch |
| Consistency | Constraints hold before and after | Deferred checks that never run, or validation done only in the application |
| Isolation | Concurrent transactions do not see each other's half-finished work | Reading uncommitted rows, or read-modify-write without a lock |
| Durability | Committed data survives a crash | Disabling fsync for speed, or a replica that has not caught up |
Every single statement you write is already a transaction unless you opened one — the implicit commit is why an UPDATE without a WHERE cannot be talked back.
Isolation levels and read phenomena
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | No | Possible | Possible |
| REPEATABLE READ | No | No | Possible in the standard; InnoDB prevents most |
| SERIALIZABLE | No | No | No |
- A dirty read sees another transaction's uncommitted change — the one phenomenon every engine forbids by default.
- A non-repeatable read is reading the same row twice and getting different values because someone committed in between.
- A phantom read is running the same range query twice and getting new rows the second time.
- Defaults differ: PostgreSQL and Oracle are READ COMMITTED, MySQL InnoDB is REPEATABLE READ, SQLite is SERIALIZABLE.
-- per transaction, not globally
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts WHERE id = 1; -- the same answer even after another commit
COMMIT;
-- SERIALIZABLE turns anomalies into errors, so it needs a retry loop
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE counters SET n = n + 1 WHERE id = 1;
COMMIT; -- may fail with a serialization error: catch it and run the block again⚠️
Stronger isolation is not a free upgrade: it converts silent wrong answers into explicit errors that your code must retry. Any
SERIALIZABLE transaction, and any read-modify-write without an atomic UPDATE, needs a bounded retry loop or it will fail under real concurrency.Locking and deadlocks
-- transaction A -- transaction B
BEGIN; BEGIN;
UPDATE accounts SET balance = 0
WHERE id = 1; UPDATE accounts SET balance = 0
WHERE id = 2;
UPDATE accounts SET balance = 0
WHERE id = 1; -- waits for A
UPDATE accounts SET balance = 0
WHERE id = 2; -- now neither can proceed: deadlock
-- take the row you are about to change
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- queue workers: claim rows nobody else holds
SELECT id FROM jobs WHERE status = 'queued'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;- Acquire locks in a consistent order — most deadlocks are two transactions disagreeing about the order in which to take the same rows.
- Keep transactions short: never wait on a network call, a file upload or a user click inside one, because every lock is held for the whole wait.
- Lock only the rows you will change; a wide
FOR UPDATEblocks unrelated work and is the usual cause of sudden timeouts. SKIP LOCKEDis the standard way to build a queue without two workers taking the same job.
FAQ
Which isolation level should I use?
Start with your engine's default. Raise it for the specific transactions that read data and then write based on it, and add a retry loop at the same time — higher isolation reports conflicts as errors rather than hiding them.
How do I deal with a long-running transaction?
Find it in the engine's activity view (
pg_stat_activity, SHOW PROCESSLIST, sys.dm_exec_sessions) and terminate it. Prevention is better: set a statement_timeout and an idle_in_transaction_session_timeout so nothing holds locks indefinitely.Related
Query tuning and execution plans Views, functions, procedures and triggers
Last refreshed 2026-09-18.