Transactions & Isolation
How a database makes a group of changes all-or-nothing, what goes wrong when two transactions run at once (dirty reads, phantoms, lost updates, write skew), what each isolation level really prevents in PostgreSQL and MySQL, and how MVCC, locks and retries make it work.
The 30-second version
The classic example is a bank transfer. Moving $50 from Alice to Bob takes two changes to the Database: subtract 50 from Alice, add 50 to Bob. Two things can go wrong between them:
- A crash. The server dies, the network drops, or your code throws right after the first change. Now Alice has lost $50 and Bob never got it.
- Someone else, at the same time. A second transfer, or a report adding up all balances, runs in the middle of yours and sees (or overwrites) the half-finished state.
Try it. First run the transfer without a transaction and crash it halfway. Then switch to BEGIN … COMMIT and crash it at the same spot:
1BEGIN;2UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- Alice3UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- Bob4COMMIT;
In SQL (the language for talking to databases; §02 covers just enough of it) a transaction looks like this. BEGIN starts it. COMMIT saves everything. ROLLBACK throws everything away, and a crash before COMMIT counts as a rollback:
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- Alice
UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- Bob
COMMIT;The crash part is easy. The "at the same time" part is the real topic.
Making changes all-or-nothing is something the database just does for you. The harder question is how much transactions running at the same moment can see of, and interfere with, each other. Databases let you choose with a setting called the Isolation level, and the default is looser than most people assume. Here's a preview of what's coming:
| You'll learn | Section |
|---|---|
| What the four letters of ACID really promise | §03 |
| Writing transactions correctly in Go and JavaScript (and the classic bugs) | §04 |
| Six ways concurrent transactions go wrong, step by step | §05 |
| What each isolation level actually prevents in PostgreSQL and MySQL | §06 |
| How the database does it: row versions (MVCC) and locks | §07–§08 |
| Retrying, and running transactions in production | §09–§10 |
Just enough SQL
If you've used databases before, skip to §03. Otherwise, this is all the SQL you need for the rest of the page.
One table, three columns
Here is the table used throughout this page, in PostgreSQL (the database we'll use for every real example):
CREATE TABLE accounts (
id int PRIMARY KEY, -- unique number per row
owner text NOT NULL, -- must always have a value
balance int NOT NULL CHECK (balance >= 0) -- a rule: never negative
);
INSERT INTO accounts (id, owner, balance)
VALUES (1, 'alice', 100), (2, 'bob', 100); -- add two rowsReading it back with SELECT (this is real output from psql, Postgres's command-line client):
postgres=# SELECT id, owner, balance FROM accounts;
id | owner | balance
----+-------+---------
1 | alice | 100
2 | bob | 100
(2 rows)Changing one row with UPDATE:
UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- reply: UPDATE 1Your app talks to the database over a connection
Your Go or Node program isn't inside the database. It opens a network connection to the database server and sends SQL over it, one statement at a time, waiting for each reply. Because opening a connection is slow, programs keep a few open and reuse them. That set of reusable connections is a Connection pool. It matters a lot for transactions (§04).
One more default to know: if you send a statement without BEGIN first, the database treats it as a tiny transaction of its own and commits it right away. That's called Autocommit, and it's why the first version of the transfer in §01 could stop halfway with one change saved and the other missing.
ACID, one letter at a time
| Letter | Promise | Breaks without it |
|---|---|---|
| Atomicity | All of the transaction's changes happen, or none do | A crash after "debit Alice" loses $50 |
| Consistency | Each transaction takes the data from one valid state to another | A balance goes negative; money appears from nowhere |
| Isolation | Transactions running at the same time don't trip over each other | A report sees the transfer half-done; two deposits, one lost |
| Durability | Once COMMIT returns, the change survives a crash | The server says "done", loses power, and the change is gone |
A — Atomicity: all or nothing
Failure it prevents: the half-done transfer from §01.
If anything goes wrong before COMMIT — a crash, a lost connection, an error, or your own ROLLBACK — the database undoes every change the transaction made. You never have to write "undo" code yourself.
In PostgreSQL, an error also poisons the rest of the transaction: after one statement fails, every further statement is refused until you roll back. This is real output from psql. Its prompt changes from =# to =*# inside a transaction, and to =!# once the transaction has failed:
postgres=# BEGIN;
BEGIN
postgres=*# UPDATE accounts SET balance = balance - 500 WHERE id = 1;
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (1, alice, -450).
postgres=!# SELECT * FROM accounts;
ERROR: current transaction is aborted, commands ignored until end of transaction block
postgres=!# COMMIT;
ROLLBACK(Alice had 50 after §02's UPDATE, so 50 − 500 = −450.) Notice the last line: you typed COMMIT, and Postgres answered ROLLBACK. A failed transaction can't be committed.
C — Consistency: the rules still hold afterwards
Failure it prevents: data that breaks your rules, like a negative balance or an order pointing at a customer who doesn't exist.
Consistency is the odd letter out: it's mostly your job. The database helps in one way. It enforces any constraints you declare — NOT NULL, UNIQUE, CHECK, foreign keys — on every single write, whichever code path the write came from:
postgres=# UPDATE accounts SET balance = balance - 500 WHERE id = 1;
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (1, alice, -450).But the database can't know rules you never told it. The bank's real Invariant is "the total amount of money never changes". Watch this transfer to a mistyped account id:
postgres=# BEGIN;
BEGIN
postgres=*# UPDATE accounts SET balance = balance - 30 WHERE id = 1;
UPDATE 1
postgres=*# UPDATE accounts SET balance = balance + 30 WHERE id = 99; -- no such account
UPDATE 0
postgres=*# COMMIT;
COMMITUPDATE 0 isn't an error, so the transaction committed and $30 vanished. Atomicity only helps if your code notices the problem and rolls back. That's why §04's code checks how many rows each UPDATE changed.
I — Isolation: as if you were alone
Failure it prevents: another transaction seeing or overwriting your half-finished work.
The ideal is that every transaction behaves as if it ran alone, one after another, even when hundreds run at the same moment. Perfect isolation is expensive, so databases offer weaker settings, called isolation levels, that allow some interference in exchange for speed. Which interference each level allows is the subject of §05 and §06, and it's where most real bugs live.
D — Durability: committed means committed
Failure it prevents: the database replies "COMMIT", then the power goes out, and the change is gone.
Writing to a disk is slow, so operating systems normally keep "written" data in memory and save it to disk a bit later. A power cut loses whatever was still in memory. To be durable, the database writes every change into a Write-ahead log — an append-only file — and calls fsync to force it onto the disk before it tells you the commit succeeded:
Appending to one file and syncing it is much faster than rewriting table files all over the disk. That's why nearly every database works this way. After a crash, the database replays the log: changes that have a commit record come back, and changes without one are discarded. So the WAL serves atomicity too.
BEGIN, COMMIT, ROLLBACK in Go and JavaScript
Why one connection? The database remembers "this connection is inside a transaction". BEGIN sent on connection #1 means nothing to connection #2. So the code has to make sure that BEGIN, every statement, and COMMIT or ROLLBACK all travel down the same connection.
Setup: the pool
In both languages, the thing you create at startup is a pool of connections, not one connection:
package main
import (
"context"
"database/sql"
"log"
_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver
)
func main() {
db, err := sql.Open("pgx", "postgres://app:secret@localhost:5432/shop")
if err != nil {
log.Fatal(err)
}
defer db.Close() // db is a POOL of connections, not one connection
ctx := context.Background()
if err := transfer(ctx, db, 1, 2, 30); err != nil {
log.Println("transfer failed:", err)
}
}The safe shape
Here's the transfer from §01, written properly. It also fixes the "UPDATE 0 isn't an error" hole from §03 by checking how many rows each update changed. Together with the setup above, each tab is a complete program you can run (in Go as two files in one package; in JS, paste both into one file).
package main
import (
"context"
"database/sql"
"fmt"
)
func transfer(ctx context.Context, db *sql.DB, from, to, amount int) error {
tx, err := db.BeginTx(ctx, nil) // BEGIN — tx is pinned to ONE connection
if err != nil {
return err
}
defer tx.Rollback() // safety net: undoes everything unless Commit ran first
if err := move(ctx, tx, from, -amount); err != nil {
return err
}
if err := move(ctx, tx, to, amount); err != nil {
return err
}
return tx.Commit() // COMMIT
}
func move(ctx context.Context, tx *sql.Tx, id, delta int) error {
res, err := tx.ExecContext(ctx, // ← tx, not db
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", delta, id)
if err != nil {
return err // e.g. the CHECK (balance >= 0) constraint failed
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("account %d not found", id) // UPDATE 0 → roll back
}
return nil
}Running both against a real Postgres: transferring 30 works, a transfer to account 99 fails with account 99 not found, and a transfer of 500 fails with the constraint error from §03 (SQLSTATE 23514). In both failure cases the balances are unchanged, because the debit was rolled back.
The bug: a transaction on the pool
This version looks almost the same. It's broken:
await pool.query("BEGIN");
await pool.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, from]);
// … something throws …
await pool.query("ROLLBACK");const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE …", [amount, from]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
} finally {
client.release();
}pool.query() borrows some free connection, runs one statement, and hands the connection straight back. Under load, the three calls can land on three different connections. We ran the broken version against a real Postgres with one other request using the pool at the same time:
server says: WARNING there is no transaction in progress
[ { id: 1, balance: 90 }, { id: 2, balance: 100 } ]Step through what happened, connection by connection, then flip to the fixed version:
1try {2 await pool.query("BEGIN");3 await somethingSlow(); // any await: other requests use the pool4 await pool.query(5 "UPDATE accounts SET balance = balance - 10 WHERE id = 1");6 throw new Error("crash before the credit");7} catch (err) {8 await pool.query("ROLLBACK");9}
In short:
BEGINran on connection #1, which went back to the pool still inside an open transaction. The other request then borrowed it, so its queries ran inside our transaction.- The
UPDATEran on connection #2 in autocommit mode, so it was committed immediately: Alice lost $10. ROLLBACKlanded on a connection with no transaction, so Postgres just printed a warning. Nothing was undone.
It's the worst kind of bug: in development, with one user, all three calls usually get the same idle connection, and the code seems to work.
Six ways concurrent transactions go wrong
An Anomaly isn't a database bug. Each one is a specific interference that a given isolation level allows. To reason about them, you need a picture of two sessions running side by side. Here are the six you'll meet in practice, as real interleavings. Except for the first, the outcomes were recorded on PostgreSQL 16 at its default level, READ COMMITTED.
The scenarios use the accounts table from §02, plus one more for the last scenario, a hospital's on-call list:
CREATE TABLE doctors (
name text PRIMARY KEY,
on_call boolean NOT NULL -- true = currently on call
);
INSERT INTO doctors VALUES ('alice', true), ('bob', true);1A: BEGIN;2A: UPDATE accounts SET balance = 0 WHERE id = 1;3B: SELECT balance FROM accounts WHERE id = 1;4A: ROLLBACK;
A: run in session A, B: in session B, in exactly this order. For this one we imagine a database that allows dirty reads (MySQL at READ UNCOMMITTED does).The six, in one table
| Anomaly | What happens | Everyday version |
|---|---|---|
| Dirty read | B reads A's change before A commits; A then rolls back | Acting on a rumour that was retracted |
| Non-repeatable read | B reads the same row twice and gets two values | The price changed between "add to cart" and "checkout" |
| Phantom read | B runs the same search twice and new rows appear | A report header says "2 accounts", the list below shows 3 |
| Lost update | A and B both read, both write; one write erases the other | Two deposits, one disappears |
| Read skew | B reads two related rows from two different moments | An audit total off by exactly one transfer |
| Write skew | A and B check the same rule, then change different rows; together they break it | Both on-call doctors go off call |
How they relate
The first three are about reading. They're the three the 1992 SQL standard defines its isolation levels with (§06).
- A dirty read sees data that isn't committed at all.
- A non-repeatable read sees two different committed versions of the same row.
- A phantom sees a different set of rows for the same search, because rows were inserted or deleted.
Read skew is the multi-row version of a non-repeatable read. No single row was read twice, but the two rows came from different moments, so their combination is nonsense.
The last two are about writing based on something you read. That's the pattern behind most real money-losing bugs.
- A lost update is two writers on the same row. You met it in Race conditions §05, along with three fixes: do the arithmetic in one
UPDATE … SET x = x + 10, use a version column, or lock withSELECT … FOR UPDATE. - Write skew is the sneaky cousin. The writers touch different rows, so nothing ever overwrites anything, and locking the rows you write can't catch it. What connects the two transactions is the rows they both read.
Isolation levels: the standard vs reality
What the SQL standard says
The 1992 SQL standard defines four levels by the three reading anomalies each must prevent. Stricter levels prevent more:
| Level | Dirty read | Non-repeatable read | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | possible | possible | possible |
| READ COMMITTED | ✗ prevented | possible | possible |
| REPEATABLE READ | ✗ | ✗ | possible |
| SERIALIZABLE | ✗ | ✗ | ✗ |
SERIALIZABLE has a stronger definition than "prevents those three": the result must be the same as if the transactions had run one at a time, in some order. That rules out every anomaly, including the three the table doesn't mention.
The standard sets a minimum. A database may prevent more than a level requires. It may also implement each level its own way. So "REPEATABLE READ" in PostgreSQL and "REPEATABLE READ" in MySQL behave differently, and you need to know which one you're on.
The matrix
Switch between the standard, PostgreSQL and MySQL. Click any cell. On PostgreSQL, press replay to run that exact interleaving from §05 at that level. Every output you see came from a real PostgreSQL 16 server.
| READ UNCOMMITTED | READ COMMITTED (default) | REPEATABLE READ | SERIALIZABLE | |
|---|---|---|---|---|
| Dirty read | ||||
| Non-repeatable read | ||||
| Phantom read | ||||
| Lost update | ||||
| Read skew | ||||
| Write skew |
What PostgreSQL actually does
- Default: READ COMMITTED. Each statement takes a fresh Snapshot: a frozen view of all data committed at the moment the statement started. So you never see uncommitted data, but two statements in the same transaction can see different data. That allows non-repeatable reads, phantoms, read skew, lost updates and write skew.
- READ UNCOMMITTED is just READ COMMITTED. Postgres accepts the name, but it never shows uncommitted data.
- REPEATABLE READ is Snapshot isolation. The whole transaction uses one snapshot, taken at its first statement (not at
BEGIN). Re-reads and re-searches return the same data, so phantoms are prevented too, which is stricter than the standard requires. If you try to update or delete a row that someone else changed and committed after your snapshot, you getERROR: could not serialize access due to concurrent update(SQLSTATE40001). That's how it stops lost updates. It still allows write skew, because write skew never touches the same row twice. - SERIALIZABLE is snapshot isolation plus tracking of who read what. If a group of transactions couldn't have produced their results one at a time, one of them fails with
40001. §09 shows how.
What MySQL (InnoDB) does
These come from the MySQL 8 manual; we didn't run MySQL for this page.
- Default: REPEATABLE READ. Plain
SELECTs read from one snapshot taken at the first read, much like Postgres. - But locking reads see the latest data.
SELECT … FOR UPDATE(read and lock rows, covered in §08),UPDATEandDELETEread the current committed rows, not your snapshot. They also take gap locks, which stop other transactions from inserting into the range you touched. That's how InnoDB prevents phantoms for locking reads. Mixing plain and locking reads in one transaction can still surprise you. - Lost updates are not detected at REPEATABLE READ. The deposit race from §05 silently loses an update in MySQL's default level. It fails with an error in Postgres's.
- READ UNCOMMITTED really allows dirty reads. Almost nobody wants this.
- SERIALIZABLE uses locks. Every plain
SELECTbecomesSELECT … FOR SHARE(a read lock: others can read but not change those rows until you finish, §08), so readers block writers. Conflicts usually end in a Deadlock — two transactions each waiting for the other's lock — and MySQL aborts one with error 1213. You retry it.
Choosing and setting the level
Set it per transaction, at the start:
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead, // or sql.LevelSerializable
ReadOnly: true, // optional: refuse writes in this tx
})| Situation | Reasonable choice (PostgreSQL) |
|---|---|
| Simple single-statement writes, and reads that don't need to agree with each other | READ COMMITTED (the default) |
| Reports, exports, backups, any multi-row read that must add up | REPEATABLE READ, often with ReadOnly |
| Read-then-write on one row (counters, balances) | Atomic UPDATE … SET x = x + 1, or FOR UPDATE, at READ COMMITTED |
| "Check a rule across rows, then write" (write skew) | SERIALIZABLE + retries, or explicit locks (§08) |
| You can't list every invariant and want the database to guard all of them | SERIALIZABLE everywhere + a retry wrapper |
MVCC: readers and writers don't block each other
UPDATE, so each transaction can read the version that matches its own snapshot.That's MVCC, and it's how PostgreSQL, MySQL/InnoDB, Oracle and most modern databases implement the snapshots from §06. They differ in where the old versions live. PostgreSQL writes each new version next to the old one in the table. InnoDB and Oracle update the row in place but keep the old value in an undo log (§03), and rebuild old versions from it on demand. This section uses PostgreSQL, where you can see the versions directly.
Every row version says who created it and who replaced it
In PostgreSQL, each writing transaction gets a number, its transaction ID, and the numbers count up. Every row version carries two hidden columns:
xmin: the transaction that created this version.xmax: the transaction that deleted or replaced it, or0if nobody has.
You can select them like normal columns. Real output, before and after one UPDATE (blank lines and row counts trimmed):
postgres=# SELECT xmin, xmax, id, balance FROM accounts WHERE id = 1;
xmin | xmax | id | balance
------+------+----+---------
788 | 0 | 1 | 100
postgres=# UPDATE accounts SET balance = 50 WHERE id = 1;
postgres=# SELECT xmin, xmax, id, balance FROM accounts WHERE id = 1;
xmin | xmax | id | balance
------+------+----+---------
790 | 0 | 1 | 50 ← a different physical version, created by tx 790The old version (xmin 788) is still in the table file, now stamped xmax 790. A query just doesn't show it to you any more.
The visibility rule
A Snapshot is essentially a list: "these transactions had committed when I started; everything else is invisible to me". A row version is visible to you if:
- the transaction that created it (
xmin) had committed in your snapshot — or is your own transaction — and - the transaction that replaced it (
xmax) had not committed in your snapshot (or there isn't one), and isn't your own transaction.
The "your own" parts are why, inside a transaction, you see your own uncommitted changes (Alice at 50 in §01) while nobody else does.
That's the whole trick. A writer creates a new version that nobody else can see until it commits. A reader keeps seeing the version that matches its snapshot. Nobody has to wait. Play with it:
Things to try:
- Uncommitted changes are invisible. Press UPDATE: the new version is orange, and everyone still reads the green one. Then ROLLBACK. Nothing gets copied back. The new version is simply ignored from now on, which is why rollback is instant in Postgres.
- Readers don't block writers. Start the reader, then UPDATE + COMMIT twice. The reader's SELECT still returns its old value, and the writer never waited.
- Old snapshots hold back clean-up. Start the reader, then UPDATE + COMMIT twice, then press VACUUM. A dashed horizon line appears at the reader's start. VACUUM keeps every version replaced right of that line — even ones the reader can't see — because PostgreSQL only checks "was this replaced after the oldest open snapshot began?", not who needs what. Commit the reader and VACUUM again.
VACUUM: taking out the trash
Every UPDATE and DELETE leaves a dead version behind. VACUUM finds versions that no running transaction can see any more and frees their space for reuse. You rarely run it by hand: Autovacuum runs it in the background once enough rows have changed.
Here's the "old snapshot" experiment run for real. Session B opened a REPEATABLE READ transaction and read Alice's row. Then session A updated that row three times. B still sees the original version (xmax 794 means "replaced by 794, but 794 isn't in my snapshot"):
-- session B, after A's three updates (trimmed):
xmin | xmax | balance
793 | 794 | 100
-- session A sees the newest version:
xmin | xmax | balance
796 | 0 | 70Now A runs VACUUM (VERBOSE) accounts while B is still open (output trimmed to the two relevant lines):
tuples: 0 removed, 5 remain, 3 are dead but not yet removable
removable cutoff: 794, which was 3 XIDs old when operation ended(XID = transaction ID. The "removable cutoff" is the horizon from the widget: B's snapshot was taken before transaction 794 even started, so nothing replaced by 794 or later may go. That covers all three old versions, including two that B can't even see.)
"Dead but not yet removable": B's snapshot might still need them. The server's activity view shows why. B is idle in transaction, holding an old snapshot (backend_xmin is the oldest transaction ID it might still need):
postgres=# SELECT pid, state, backend_xmin FROM pg_stat_activity WHERE pid = 21765;
pid | state | backend_xmin
-------+---------------------+--------------
21765 | idle in transaction | 794After B commits, the same VACUUM reports tuples: 3 removed, 2 remain, 0 are dead but not yet removable.
Locks: FOR UPDATE, FOR SHARE, SKIP LOCKED
Every UPDATE or DELETE locks the rows it changes until the transaction ends. A second transaction that wants to change the same row waits until the first commits or rolls back. Plain SELECTs never wait for these locks; they just read the right version (§07).
You can also take a Row lock while reading, which is how you stop someone else from changing the rows your decision depends on. Race conditions §05 introduced SELECT … FOR UPDATE for the lost-update problem. Here's the full toolkit.
| Clause | Locks the rows so that others… | Typical use |
|---|---|---|
FOR UPDATE | can't update, delete or lock them; plain reads still work | "I'm about to change these rows" |
FOR SHARE | can't update or delete them, but can also FOR SHARE them | "Nobody change these while I rely on them" |
… NOWAIT | — but if the rows are already locked, fail at once instead of waiting | User-facing actions: "someone else is editing" |
… SKIP LOCKED | — but if a row is already locked, skip it and take the next | Job queues with many workers |
All locks are released at COMMIT or ROLLBACK. There's no "unlock" statement.
Fixing write skew with FOR UPDATE
The doctors from §05 broke the rule because both transactions read the same rows but wrote different ones. Lock the rows you read, and the second transaction has to wait for the first:
BEGIN;
SELECT name FROM doctors WHERE on_call FOR UPDATE; -- lock every on-call doctor
-- app: if fewer than 2 rows came back, ROLLBACK and refuse
UPDATE doctors SET on_call = false WHERE name = 'alice';
COMMIT;We ran it in two sessions at READ COMMITTED. Session A's SELECT … FOR UPDATE returned alice and bob. Session B's identical SELECT waited. When A committed, B's SELECT finished and returned only bob, because at READ COMMITTED Postgres re-checks the WHERE against the newest committed version of each row it waited for. B saw one doctor and refused. ✓
A job queue with SKIP LOCKED
A Job queue in a table is a classic use. Many workers repeatedly grab "the oldest queued job". With plain FOR UPDATE, they all go for the same first row and line up behind each other. SKIP LOCKED lets each worker skip rows that another worker has already locked:
FOR UPDATE, every worker asks for the same first queued row, so workers 2 and 3 wait in line behind worker 1. With SKIP LOCKED, each worker skips rows someone else has locked and takes the next one.You don't even need an explicit transaction. One statement finds a job, locks it, marks it running and returns it. As a single statement, it's atomic on its own:
UPDATE jobs SET status = 'running', started_at = now()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED -- skip jobs other workers are claiming right now
)
RETURNING id, payload; -- hand back the claimed row// claimSQL is the UPDATE … RETURNING statement above, as a string.
// claimJob returns ok == false when the queue is empty.
func claimJob(ctx context.Context, db *sql.DB) (id int, payload string, ok bool, err error) {
err = db.QueryRowContext(ctx, claimSQL).Scan(&id, &payload)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", false, nil // nothing queued (or all locked by other workers)
}
if err != nil {
return 0, "", false, err
}
return id, payload, true, nil
}We ran 8 workers in parallel against 50 jobs with this query: 50 claims, 50 different jobs, no duplicates. The worker then runs the job outside any transaction and marks it done afterwards. If a worker crashes mid-job, the row stays running. So add a reaper that re-queues jobs whose started_at is too old, and make jobs safe to run twice.
Don't wait forever: NOWAIT and lock_timeout
By default, a transaction waits for a lock as long as it takes. Two ways to bound that, both real Postgres output:
-- another session holds job 1 locked
postgres=# BEGIN;
BEGIN
postgres=*# SELECT id FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
ERROR: could not obtain lock on row in relation "jobs"
postgres=!# ROLLBACK;
ROLLBACK
postgres=# BEGIN; -- a new transaction
BEGIN
postgres=*# SET LOCAL lock_timeout = '200ms';
SET
postgres=*# SELECT id FROM jobs WHERE id = 1 FOR UPDATE;
ERROR: canceling statement due to lock timeout
postgres=!# ROLLBACK;
ROLLBACKBoth errors have SQLSTATE 55P03, and both abort the transaction, so you must roll back. lock_timeout is a good safety net to set for a whole service, a second or two for web requests. SET LOCAL changes a setting for the current transaction only.
Deadlocks
Two transactions that lock the same rows in opposite orders wait for each other forever: a Deadlock. Postgres detects this after about a second and kills one of them with ERROR: deadlock detected (40P01). The fixes are to lock rows in a consistent order (for example ORDER BY id FOR UPDATE) and to retry. Race conditions §10 walks through it.
Or don't lock at all: optimistic concurrency
Locks are pessimistic: they assume a conflict will happen, and make others wait. Optimistic locking assumes conflicts are rare. Add a version column, and make the write succeed only if nobody changed the row since you read it:
UPDATE docs SET body = $1, version = version + 1
WHERE id = $2 AND version = $3; -- $3 = the version you read; 0 rows = someone beat youIt holds no lock while the user is thinking. That's why it's the standard choice for "edit form open for five minutes", where a FOR UPDATE held that long would be a disaster. Details and retry code are in Race conditions §05.
| Approach | Best when | Cost |
|---|---|---|
| Atomic single statement | The change fits in one SQL statement | None. Use it first |
FOR UPDATE / FOR SHARE | Conflicts are common; the transaction is short | Others wait; deadlocks possible |
| Version column | Conflicts are rare; long "think time" between read and write | A retry or a "please reload" message |
| SERIALIZABLE | Many invariants, hard to list which rows to lock | Retries on conflict (§09) |
SERIALIZABLE in practice: retry the whole thing
That's a good deal. With locks (§08), you have to spot every write-skew shape in your code and lock exactly the right rows. Miss one, and the bug ships. With SERIALIZABLE, every transaction is protected, including the ones you didn't think about. The cost is a retry loop, which you write once.
How PostgreSQL decides (SSI, simplified)
PostgreSQL implements SERIALIZABLE as SSI. Transactions run on snapshots exactly as at REPEATABLE READ, so readers never wait. (Two writers on the same row still take turns on the row lock, as in §08.) In addition, the database quietly records what each transaction read, including the searches it ran, not just the rows it got back.
Then it watches for one pattern. "A read something that B later changed, and B read something that A changed" means neither order works:
- If A ran first, B should have seen A's change.
- If B ran first, A should have seen B's.
That's exactly the doctors from §05. A counted the doctors (including Bob's row), and B changed Bob's row. B counted too (including Alice's row), and A changed Alice's. No one-at-a-time order produces "both went off call", so Postgres aborts one of them:
Real output, at B's COMMIT:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.("Pivot" is Postgres's name for the transaction in the middle of the dangerous read/write pattern. You can ignore the reason code; the SQLSTATE is what matters.) The SQLSTATE is 40001, the same code as the REPEATABLE READ error from §06. The hint is literal advice: retry it.
The retry: run everything again, from BEGIN
40001. Its whole transaction is rolled back, so it waits a short random time and runs everything again from BEGIN — this time reading A's committed result.The abort rolls back the whole transaction, so retrying just the failed statement makes no sense. Its earlier reads came from a snapshot that's now stale. The unit of retry is "the function that does BEGIN … COMMIT". The cleanest design is a helper that owns BEGIN and COMMIT, so callers can't get it wrong:
import (
"context"
"database/sql"
"errors"
"math/rand/v2"
"time"
"github.com/jackc/pgx/v5/pgconn" // pgx's error type, *pgconn.PgError
)
// inTx runs fn inside a SERIALIZABLE transaction, retrying on 40001 / 40P01.
func inTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
opts := &sql.TxOptions{Isolation: sql.LevelSerializable}
for attempt := 1; ; attempt++ {
err := runOnce(ctx, db, opts, fn)
var pgErr *pgconn.PgError
retryable := errors.As(err, &pgErr) &&
(pgErr.Code == "40001" || pgErr.Code == "40P01")
if !retryable || attempt == 5 {
return err // nil on success, or a non-retryable error
}
wait := time.Duration(attempt*20+rand.IntN(20)) * time.Millisecond
select {
case <-time.After(wait): // back off with jitter, then run it ALL again
case <-ctx.Done():
return ctx.Err()
}
}
}
func runOnce(ctx context.Context, db *sql.DB, opts *sql.TxOptions, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return err
}
defer tx.Rollback()
if err := fn(tx); err != nil {
return err
}
return tx.Commit() // ← a 40001 can come from COMMIT too
}And the doctors, written against it. The business rule is a plain if. The helper makes the race impossible:
var ErrLastDoctor = errors.New("you're the last doctor on call")
func goOffCall(ctx context.Context, db *sql.DB, name string) error {
return inTx(ctx, db, func(tx *sql.Tx) error {
var n int
err := tx.QueryRowContext(ctx,
"SELECT count(*) FROM doctors WHERE on_call").Scan(&n)
if err != nil {
return err
}
if n < 2 {
return ErrLastDoctor // not retryable: inTx returns it as is
}
_, err = tx.ExecContext(ctx,
"UPDATE doctors SET on_call = false WHERE name = $1", name)
return err
})
}We ran both against Postgres with Alice and Bob going off call at the same time. One succeeded, and the other got "you're the last doctor on call", sometimes on its first attempt, sometimes after a 40001 and a retry. Six JS requests racing each other needed 10 attempts in total, and exactly one doctor stayed on call every time.
What makes a retry safe
The function you pass in may run several times. Only the database work gets rolled back between attempts. Everything else happens again.
await inTx(pool, async (tx) => {
const order = await placeOrder(tx, cart);
await sendReceiptEmail(order); // sent again on every retry
await chargeCard(order); // charged again on every retry!
});const order = await inTx(pool, (tx) => placeOrder(tx, cart));
await chargeCard(order); // once, after the commit succeeded
await sendReceiptEmail(order);- No external calls inside. HTTP requests, emails, payments and queue messages can't be rolled back. Do them after the commit, or record them in an outbox table inside the transaction (§10). If they must be retried, make them Idempotent with an idempotency key.
- No in-memory changes that pile up.
total += xorresults.push(row)on a variable outside the function gets applied once per attempt. - Don't retry other errors. A constraint violation (
23505,23514) or "insufficient funds" fails the same way every time. - Cap the attempts, and use Jitter. Without the random part, two transactions that collided once retry in lockstep and collide again.
Production: short transactions, pools, savepoints, outbox
Do the arithmetic: why short matters
Each open transaction holds three things:
- A pooled connection. Say your Connection pool has 10 connections and each request keeps one inside a transaction for 2 seconds. Then the service handles at most 10 / 2 s = 5 requests per second. Request number 11 waits for a free connection, then the request after it, and so on. (Worker pools explains why that queue grows so fast.)
- Its row locks. If every checkout locks the same hot row (a popular product's stock) for 2 seconds, that row allows at most one checkout every 2 seconds, however many servers you run.
- Its snapshot. As §07 showed, an open snapshot stops VACUUM from cleaning up anywhere in the database.
The same transaction at 20 ms instead of 2 s gives 500 requests per second from the same pool. The length of your transactions sets your throughput ceiling.
Never wait on the outside world inside a transaction
await client.query("BEGIN");
const { rows } = await client.query(
"SELECT * FROM orders WHERE id = $1 FOR UPDATE", [id]);
const payment = await paymentApi.charge(rows[0]); // 300 ms… or 30 s
await client.query(
"UPDATE orders SET status = 'paid' WHERE id = $1", [id]);
await client.query("COMMIT");// 1. call the API with no transaction open (use an idempotency key)
const payment = await paymentApi.charge(order, { idempotencyKey: order.id });
// 2. a short transaction to record the result (inTx from §09
// does BEGIN/COMMIT/ROLLBACK/release for you)
await inTx(pool, (tx) => tx.query(
"UPDATE orders SET status = 'paid', payment_id = $2 WHERE id = $1 AND status = 'pending'",
[order.id, payment.id]));On the left, the payment provider's speed now sets your database's lock times and pool usage. When it has a bad day, so does every endpoint that touches orders. The same goes for waiting on a user ("hold the seat while they type their card number") and for slow computation. Do the slow part with no transaction open, then use a short transaction to record the result. A conditional WHERE status = 'pending' stops a double record. The AND status = … trick is the same check-and-act-in-one-statement idea as Race conditions §05.
Guard rails: timeouts the database enforces
Bugs will leave transactions open anyway: a missing release(), an exception path that skips ROLLBACK, a debugger paused on a breakpoint. PostgreSQL can enforce limits for you, per role or per database:
ALTER ROLE app SET idle_in_transaction_session_timeout = '30s'; -- kill forgotten transactions
ALTER ROLE app SET statement_timeout = '5s'; -- kill runaway queries
ALTER ROLE app SET lock_timeout = '2s'; -- don't queue forever on a lockWhen a session sits Idle in transaction past the limit, Postgres ends it. Real output in psql, with the limit set to 1 s (trimmed to the error):
FATAL: terminating connection due to idle-in-transaction timeout
server closed the connection unexpectedlyYour app sees a broken connection, and the transaction is rolled back. That's much better than six hours of growing bloat. To find the offenders, ask the server:
SELECT pid, now() - xact_start AS open_for, state, left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY open_for DESC;Savepoints: undo part of a transaction
A Savepoint is a bookmark inside a transaction. Rolling back to it undoes only the work since the bookmark. It's also the only way to recover from an error in Postgres without losing the whole transaction (remember §03: one error poisons the rest). Real output:
postgres=# BEGIN;
BEGIN
postgres=*# INSERT INTO doctors VALUES ('dave', true);
INSERT 0 1
postgres=*# SAVEPOINT before_import;
SAVEPOINT
postgres=*# INSERT INTO doctors VALUES ('alice', true);
ERROR: duplicate key value violates unique constraint "doctors_pkey"
DETAIL: Key (name)=(alice) already exists.
postgres=!# ROLLBACK TO SAVEPOINT before_import;
ROLLBACK
postgres=*# INSERT INTO doctors VALUES ('carol', true);
INSERT 0 1
postgres=*# COMMIT;
COMMIT -- dave and carol were saved; the failed alice insert was notMany ORMs implement "nested transactions" this way: the inner "transaction" is really a savepoint. Use them sparingly. Each savepoint is a subtransaction inside Postgres, and transactions that create very many of them (dozens or more) are a known performance cliff.
Changing the database and telling the world: the outbox
A common need: save an order, and publish an "order paid" message to a message broker. Those are two systems, so no single transaction covers both. Publish first and then crash, and the world hears about an order that doesn't exist. Commit first and then crash, and nobody ever hears about it.
The Outbox pattern fixes this with a transaction you already have. Write the message into an outbox table in the same transaction as the change. A separate relay process reads the outbox and publishes:
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 42;
INSERT INTO outbox (topic, payload) VALUES ('order.paid', '{"id": 42}');
COMMIT; -- both rows, or neitherThe relay may publish a message twice (if it crashes after publishing, before marking the row sent), so consumers must handle duplicates. Behavioral patterns §10 has an interactive crash demo of exactly this. The relay itself is a job queue, and FOR UPDATE SKIP LOCKED from §08 is a natural fit for it.
Where transactions end
Everything on this page is about one database. Once a business operation spans several databases or services (charge the card in the payments service, reserve stock in the inventory service), there's no shared BEGIN and COMMIT.
- Two-phase commit (2PC) makes several databases commit together, by having a coordinator ask each one to "prepare" and then to "commit". Postgres supports the building blocks (
PREPARE TRANSACTION), though they're switched off by default (max_prepared_transactions = 0). But 2PC is slow, holds locks across the network, and gets stuck if the coordinator dies mid-way. Most teams avoid it. - Sagas split the operation into local transactions, each with a "compensating" undo step (refund the card if the stock reservation fails). It's a different model: other users can see the in-between states. See Saga.
Pitfalls gallery
Running the transaction on the pool
pool.query("BEGIN"), then pool.query("UPDATE …"): each call may use a different connection. The UPDATE autocommits, the ROLLBACK lands nowhere, and a connection goes back to the pool with an open transaction (§04). Always const client = await pool.connect() and use client for every statement.
Go: one stray db. inside the transaction
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
tx.ExecContext(ctx, debitSQL, from)
audit(ctx, db, "transfer") // ← runs on db: commits on its own
return tx.Commit()tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// … every statement, including helpers, uses tx …
if err := audit(ctx, tx, "transfer"); err != nil {
return err
}
return tx.Commit()The left version also ignores errors from BeginTx and ExecContext. If the debit fails, it still commits whatever ran. Make helpers accept a *sql.Tx, or an interface with ExecContext/QueryContext that both *sql.DB and *sql.Tx satisfy.
“It's in a transaction, so it's safe from races”
At PostgreSQL's default level (READ COMMITTED) and MySQL's (REPEATABLE READ), read-then-write inside BEGIN … COMMIT still loses updates, and check-then-write still allows write skew (§05). A transaction gives you atomicity for free. Protection from concurrency depends on the isolation level, locks, or writing the change as one atomic statement.
Raising the isolation level without a retry loop
Switching to REPEATABLE READ or SERIALIZABLE turns races into 40001 errors. Without a retry wrapper (§09), users see random failures under load, and it'll look like the database got flaky. Add the wrapper in the same change that raises the level.
Retrying the statement, or retrying the side effects
After a 40001 the whole transaction is gone, so retry from BEGIN, re-reading everything. And keep emails, payments, HTTP calls and in-memory accumulators out of the retried function, or they happen once per attempt.
Catching an error and carrying on (25P02)
In PostgreSQL, once any statement in a transaction fails, every later statement fails too, until you roll back:
ERROR: current transaction is aborted, commands ignored until end of transaction blockSo a try { await client.query(insert) } catch {} inside a transaction, meant to "ignore duplicates", breaks everything after it. Either use a savepoint around the risky statement (§10), or avoid the error in SQL: INSERT … ON CONFLICT DO NOTHING skips duplicate rows instead of failing.
Ignoring “UPDATE 0”
An UPDATE or DELETE that matches no rows succeeds. A transfer to a mistyped account id commits a debit with no credit (§03). Check RowsAffected() / rowCount whenever "exactly one row" is part of your logic, and roll back if it isn't.
Slow things inside BEGIN … COMMIT
An HTTP call, a file upload, a "please confirm" screen, or a big loop of one-row-at-a-time inserts with await between them all keep locks, a pooled connection and an old snapshot alive. Under load that becomes pool exhaustion, lock queues and table bloat (§10). Prepare everything first, then open the transaction, write, and commit.
A report at READ COMMITTED that doesn't add up
A report that runs several queries (totals, then details) at READ COMMITTED reads each query from a different moment. That's read skew (§05), so totals and details can disagree. Run it as a REPEATABLE READ, READ ONLY transaction, so every query sees one snapshot. Keep it short, or run it on a replica: a long snapshot holds back VACUUM.
Expecting FOR UPDATE to stop inserts
SELECT … WHERE room = 7 AND overlaps(…) FOR UPDATE locks the bookings that exist. It can't lock the one somebody is about to insert, so double bookings still slip through (§08). Lock a parent row that does exist (the room), enforce the rule with a constraint, or use SERIALIZABLE.
Locking rows in different orders
Transfer A→B locks A then B. Transfer B→A locks B then A. Run both at once and you have a deadlock: Postgres kills one after about a second (40P01). Lock in a fixed order, for example SELECT … WHERE id IN ($1, $2) ORDER BY id FOR UPDATE, and keep 40P01 in your retry list (Race conditions §10).
Check yourself
Commit to an answer before you click. Each explanation points back to the section that covers it. Q1 is from §04, Q2–Q4 from §05–§06, Q5 from §07, Q6 from §09 and Q7 from §06.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = db.ExecContext(ctx,
"UPDATE accounts SET balance = 0 WHERE id = 1")
if err != nil {
return err
}
return errors.New("changed my mind") // Commit never runsB: BEGIN;
B: SELECT balance FROM accounts WHERE id = 1; -- 100
A: UPDATE accounts SET balance = 50 WHERE id = 1;
B: SELECT balance FROM accounts WHERE id = 1; -- ?
B: COMMIT;A: BEGIN ISOLATION LEVEL REPEATABLE READ;
A: SELECT balance FROM accounts WHERE id = 1; -- 100
B: BEGIN ISOLATION LEVEL REPEATABLE READ;
B: SELECT balance FROM accounts WHERE id = 1; -- 100
A: UPDATE accounts SET balance = 110 WHERE id = 1;
A: COMMIT;
B: UPDATE accounts SET balance = 120 WHERE id = 1; -- ?-- both sessions: BEGIN ISOLATION LEVEL REPEATABLE READ;
A: SELECT count(*) FROM doctors WHERE on_call; -- 2
B: SELECT count(*) FROM doctors WHERE on_call; -- 2
A: UPDATE doctors SET on_call = false WHERE name = 'alice';
B: UPDATE doctors SET on_call = false WHERE name = 'bob';
A: COMMIT;
B: COMMIT; -- ?await inTx(pool, async (tx) => {
const order = await placeOrder(tx, cart);
await emailReceipt(order); // sends an email
await chargeCard(order); // calls the payment API
});Cheat sheet
BEGIN … COMMIT keeps everything; ROLLBACK, an error or a crash keeps nothing.tx. node-pg: pool.connect(), use client, release() in finally.COMMIT returns only after the log is on disk. Tables are written later.CHECK, UNIQUE, foreign keys). Check rowCount for "exactly one row".FOR UPDATE before read-then-write; SKIP LOCKED for queues; lock_timeout as a net.BEGIN, with jitter and a cap. No side effects inside the retried function.idle_in_transaction_session_timeout.What PostgreSQL prevents, by level
| READ COMMITTED (default) | REPEATABLE READ | SERIALIZABLE | |
|---|---|---|---|
| Dirty read | ✗ never | ✗ | ✗ |
| Non-repeatable read | possible | ✗ | ✗ |
| Phantom read | possible | ✗ | ✗ |
| Lost update (read, then write) | possible | ✗ error 40001 | ✗ error 40001 |
| Read skew | possible | ✗ | ✗ |
| Write skew | possible | possible | ✗ error 40001 |
(READ UNCOMMITTED behaves exactly like READ COMMITTED in Postgres.)
Which tool for which race
| Situation | Reach for |
|---|---|
| Counter, stock, balance change | UPDATE … SET x = x - 1 WHERE … AND x >= 1 (one statement) |
| Read, compute in code, write one row | SELECT … FOR UPDATE, or a version column |
| "Check across rows, then write" | SERIALIZABLE + retry, or lock a parent row |
| "Only one may exist" | UNIQUE constraint |
| Many workers, one queue table | FOR UPDATE SKIP LOCKED |
| Multi-query report that must add up | REPEATABLE READ, READ ONLY |
| Change + publish an event | Outbox table in the same transaction |
Error codes worth matching on (PostgreSQL SQLSTATE)
| Code | Meaning | Do |
|---|---|---|
40001 | Serialization failure | Retry the whole transaction |
40P01 | Deadlock detected | Retry; fix the lock order |
55P03 | Lock not available (NOWAIT, lock_timeout) | Roll back; retry later or tell the user |
25P02 | Transaction already aborted | Roll back; an earlier statement failed |
23505 / 23514 | Unique / check constraint violated | Don't retry; it's a real conflict or bad input |
Go (database/sql) | JavaScript (pg) |
|---|---|
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) | const client = await pool.connect(); await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE") |
defer tx.Rollback() | catch { await client.query("ROLLBACK") } |
tx.Commit() | await client.query("COMMIT") |
res.RowsAffected() | result.rowCount |
errors.As(err, &pgErr); pgErr.Code | err.code |
| — (database/sql manages the connection) | finally { client.release() } |