Transactions and isolation
The two engines pick different default isolation levels, which means the same code has different anomalies. Lost updates, gap locks, deadlock retries, and why a long transaction is an operational problem.
A transaction is a unit of work that either all happens or none of it does.
BEGIN; UPDATE account SET cents = cents - 1000 WHERE id = 1; UPDATE account SET cents = cents + 1000 WHERE id = 2;COMMIT;(account is the textbook example rather than one of our four tables — the two-sided
transfer is the shortest thing that shows why atomicity is not optional.)
Both engines run in autocommit by default: a statement outside an explicit
transaction is its own transaction. So the code above without BEGIN is two separate
transactions, and a crash between them loses the money. Most connection libraries and
ORMs manage this for you — which means the first thing to know about your stack is
where the transaction boundary actually is.
ROLLBACK undoes everything since BEGIN. A SAVEPOINT lets you undo part of it,
which is how an ORM implements a nested transaction.
What isolation is protecting you from
When transactions overlap, four things can go wrong. The names are worth knowing because they are how the levels are defined:
| Anomaly | What happens |
|---|---|
| Dirty read | you read a row another transaction has written but not committed |
| Non-repeatable read | you read the same row twice in one transaction and get different values |
| Phantom read | you run the same query twice and the second time new rows match |
| Lost update | two transactions read a value, both compute from it, and the second write silently discards the first |
And the levels, in increasing strictness:
| Level | Dirty | Non-repeatable | Phantom |
|---|---|---|---|
READ UNCOMMITTED |
possible | possible | possible |
READ COMMITTED |
prevented | possible | possible |
REPEATABLE READ |
prevented | prevented | possible by the standard |
SERIALIZABLE |
prevented | prevented | prevented |
The defaults are different, and that matters
| PostgreSQL | MySQL / InnoDB | |
|---|---|---|
| Default level | READ COMMITTED |
REPEATABLE READ |
READ UNCOMMITTED |
accepted, behaves as READ COMMITTED |
actually permits dirty reads |
REPEATABLE READ |
snapshot isolation; no phantoms; can fail with a serialization error | snapshot for reads, plus gap locks; no phantoms in practice |
SERIALIZABLE |
true serializability, detects conflicts and aborts | implemented by turning plain SELECT into SELECT ... FOR SHARE |
Two consequences for code that has to run on both:
A SELECT repeated in one transaction behaves differently. In PostgreSQL’s default
it sees a fresh snapshot each statement, so a row can change under you. In MySQL’s
default it sees the snapshot from the first read, so it cannot. A report that runs
several queries and expects them to agree is correct by accident on MySQL and wrong by
default on PostgreSQL.
PostgreSQL’s REPEATABLE READ and SERIALIZABLE can abort your transaction. You
get could not serialize access due to concurrent update — SQLSTATE 40001 — and the
transaction must be retried from the beginning. This is not an error to log and move
on from; it is a normal outcome that the caller has to handle.
Lost updates, and the two ways to prevent them
This is the concurrency bug that actually appears in applications.
-- Two requests run this at the same time. Both read 10, both write 11.-- One increment is gone, and nothing failed.SELECT quantity FROM order_item WHERE order_id = 1 AND product_id = 5; -- 10UPDATE order_item SET quantity = 11 WHERE order_id = 1 AND product_id = 5;Three fixes:
-- 1. Do the arithmetic in the database. Correct at every isolation level.UPDATE order_item SET quantity = quantity + 1 WHERE order_id = 1 AND product_id = 5;
-- 2. Pessimistic lock: take the row and make the other transaction wait.BEGIN; SELECT quantity FROM order_item WHERE order_id = 1 AND product_id = 5 FOR UPDATE; -- ... compute in the application ... UPDATE order_item SET quantity = :new WHERE order_id = 1 AND product_id = 5;COMMIT;
-- 3. Optimistic lock: a version column, and check you are updating what you read.UPDATE order_item SET quantity = :new, version = version + 1 WHERE order_id = 1 AND product_id = 5 AND version = :version_i_read;-- 0 rows affected means somebody else got there first. Re-read and retry.Option 1 whenever the new value is a function of the old one. Option 3 scales better than option 2 under contention, because nobody waits — but you must handle the retry.
Locking reads, and the two clauses that make queues work
SELECT ... FOR UPDATE; -- exclusive: nobody else may read-for-update or writeSELECT ... FOR SHARE; -- shared: others may also read, none may writeBy default a locking read waits for whoever holds the row. Both engines let you change that:
SELECT * FROM "order" WHERE status = 'new'ORDER BY placed_at LIMIT 1FOR UPDATE SKIP LOCKED; -- skip rows someone else has; take the next free one
SELECT ... FOR UPDATE NOWAIT; -- fail immediately instead of waitingSKIP LOCKED is how you build a work queue in SQL, and it is supported by PostgreSQL
9.5+ and MySQL 8.0+. Without it, ten workers polling the same table all block behind
the same row and you have a queue of one.
Gap locks: MySQL’s extra behaviour
In REPEATABLE READ, InnoDB does not just lock the rows it finds — it locks the gaps
between index values, to stop another transaction inserting a row that would change the
result. These are next-key locks.
-- InnoDB locks the range, so a concurrent INSERT of placed_at = '2026-01-15' waitsSELECT * FROM `order` WHERE placed_at BETWEEN '2026-01-01' AND '2026-01-31' FOR UPDATE;This prevents phantoms, and it also causes deadlocks that surprise people, because
transactions block each other over rows that do not exist. If a MySQL workload deadlocks
in ways that make no sense from the row-level reasoning, gap locks are the first place
to look. READ COMMITTED disables most of them, at the cost of the guarantee.
PostgreSQL has no gap locks. Its REPEATABLE READ gets the same protection from
snapshots, and SERIALIZABLE detects the conflict at commit time instead of preventing
it with a lock.
Deadlocks are normal
Two transactions each holding what the other wants. The engine detects the cycle, kills one, and returns an error:
| Error | |
|---|---|
| PostgreSQL | SQLSTATE 40P01, deadlock detected |
| MySQL | error 1213, Deadlock found when trying to get lock |
A deadlock is not a bug to eliminate; it is a condition to handle. Retry the whole transaction — retrying the statement is meaningless because the transaction is already rolled back.
Reduce the frequency by having every transaction touch rows in the same order. Most application deadlocks are two code paths that update the same two tables in opposite sequences.
Long transactions are an operational problem
A transaction left open for minutes costs more than the lock it holds.
- PostgreSQL:
VACUUMcannot remove row versions that an open transaction might still need, so dead rows accumulate — table bloat. A single forgotten transaction can grow a table for hours. Watchpg_stat_activityforstate = 'idle in transaction'. - MySQL: the undo log grows for the same reason, and the history list length with it.
Keep transactions short and do not hold one open across a network call to another service. That is the pattern that turns a slow third party into a database incident.
One more difference: DDL
| PostgreSQL | MySQL | |
|---|---|---|
CREATE TABLE inside a transaction |
transactional — rolls back cleanly | implicitly commits the current transaction |
| Migration failure halfway | the whole migration rolls back | earlier statements are already committed |
This is why PostgreSQL migrations can be written as one atomic step and MySQL migrations cannot. In MySQL, each statement must be independently safe to have applied on its own — which is the same discipline the next lesson recommends anyway.
What to take away
- PostgreSQL defaults to
READ COMMITTED, MySQL toREPEATABLE READ. The same code has different anomalies. - PostgreSQL’s stricter levels abort transactions with
40001. Handle the retry. - Prevent lost updates by computing in the database,
FOR UPDATE, or a version column. SKIP LOCKEDis what makes a SQL work queue possible.- MySQL’s gap locks prevent phantoms and cause surprising deadlocks.
- Retry the transaction on deadlock, and order your writes consistently to reduce them.
- MySQL DDL commits implicitly; a half-applied migration stays applied.
Discussion
Loading comments…