Locking and isolation levels in Postgres
Published 18 September 2026
This started as a refresher on the four SQL isolation levels and drifted, the way these things do, into row locking and then into the one problem everybody uses to explain row locking: how a movie ticket site avoids selling seat 42 twice.
The two halves are the same subject from opposite ends. Isolation levels are the declarative knob — you tell Postgres how much interference you're willing to tolerate and it decides what to do. Row locks are the imperative knob — you tell Postgres exactly which rows nobody else may touch until you commit. Most real systems use a low isolation level and a few carefully placed locks, and understanding why means understanding both.
The anomalies come first¶
Isolation levels are defined by what they forbid, so the anomalies are the actual vocabulary. There are five worth knowing.
A dirty read is reading a row another transaction wrote but hasn't committed. If that transaction rolls back, you've read a value that never really existed.
A non-repeatable read is reading the same row twice in one transaction and getting two different values, because somebody updated and committed in between.
A phantom read is running the same query twice and getting a different set of rows, because somebody inserted or deleted rows matching your predicate.
A lost update is two transactions reading the same row, each computing a new value from what they read, and one write silently clobbering the other. Classic shape: read balance 500, compute 400, write 400 — twice, concurrently, and 200 of debits turn into 100.
Write skew is the subtle one. Two transactions read overlapping data, write to different rows based on what they read, and the combination breaks an invariant that neither transaction could see on its own. Nobody overwrote anybody. The database is still wrong.
The first three are the anomalies the SQL standard uses to define isolation levels. The last two are the ones that actually cause production incidents, which is most of the point of this post.
The four levels¶
Read Uncommitted¶
Prevents nothing. Dirty reads, non-repeatable reads, phantoms are all allowed.
In Postgres this level does not exist in any meaningful sense — asking for it gets you Read Committed. Postgres is MVCC all the way down and never exposes uncommitted row versions to another snapshot, so there is simply no machinery to read dirty data with. You can set the level and Postgres will accept it, but nothing changes.
Read Committed¶
Prevents dirty reads. Allows non-repeatable reads and phantoms. This is the default in Postgres, Oracle and SQL Server.
The mechanism is worth stating precisely because it explains a lot of surprising behaviour: under Read Committed, each statement takes a fresh snapshot. So:
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 500
-- another transaction updates it to 400 and commits
SELECT balance FROM accounts WHERE id = 1; -- 400
COMMIT;
Both reads are of committed data. Neither is wrong. But the transaction saw two different truths, and any logic that assumed the first value still held is now running on a stale premise.
Repeatable Read¶
Prevents dirty reads and non-repeatable reads. The standard says phantoms are still allowed.
Postgres implements this as snapshot isolation: one snapshot taken at the first statement, held for the whole transaction. That happens to prevent phantoms too, which is stronger than the standard demands. MySQL's InnoDB also blocks most phantoms here, via gap locking. So "Repeatable Read" is one of those names that means genuinely different things across engines — check the engine's docs, not the name.
The cost of a stable snapshot is that writes can now fail:
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
If another transaction modified a row you're updating after your snapshot was taken, Postgres aborts you rather than let you write on top of a version you never saw. Any code running at Repeatable Read or above needs a retry loop. This is the part people forget when they bump the level "for safety" and then get paged.
Serializable¶
Prevents everything above, plus write skew. The guarantee is that the outcome is equivalent to some serial, one-at-a-time execution of the concurrent transactions.
The example that makes write skew concrete: two doctors are on call, and the rule is that at least one must remain on call. Doctor A's transaction reads the roster, sees B is on call, and takes A off. Doctor B's transaction does exactly the same thing at the same moment, seeing A on call. Both commit. Nobody is on call.
Each transaction read a valid state and made a locally consistent write, to a different row. Repeatable Read does not catch this — snapshot isolation has nothing to complain about, since no row was updated twice. Only Serializable does.
Postgres implements Serializable with SSI, Serializable Snapshot Isolation. Instead of
locking reads upfront the way textbook two-phase locking does, it tracks read/write
dependencies between concurrent transactions and aborts one when it spots a pattern that
couldn't have arisen from any serial order. You get serializability with concurrency close
to snapshot isolation, and you pay for it in occasional 40001 serialization failures that
your application has to retry. CockroachDB takes a similar approach.
One consequence that trips people up: because SSI reasons about what your transactions read, Serializable is only correct if every transaction in the workload runs at Serializable. One transaction at Read Committed writing behind SSI's back can break the guarantee for everyone else.
What isolation level do payment systems use?¶
The instinct is that anything touching money must run at Serializable. The real answer is that it depends on the operation, and that mature systems mix levels rather than picking one globally — with a strong bias toward designing the transaction so that correctness doesn't depend on the database catching a race for them.
Append-only ledger plus a row lock¶
The dominant pattern for straightforward debit and credit is to not have a mutable balance
at all. Instead of UPDATE accounts SET balance = balance - 100, you append immutable
postings to a ledger and derive the balance with a sum():
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- lock the debited account
-- application checks sufficient funds against the derived balance
INSERT INTO ledger_entries (account_id, amount, transfer_id) VALUES (1, -100, $t);
INSERT INTO ledger_entries (account_id, amount, transfer_id) VALUES (2, +100, $t);
COMMIT;
This is correct at Read Committed. The FOR UPDATE serialises access to the account being
debited by hand, so the read-then-write race is gone before the isolation level ever gets a
say. The invariants that matter — debits equal credits, balance never goes negative — are
enforced by the lock, the double-entry shape and a CHECK constraint, not by hoping
Serializable notices a write-skew case on your behalf. That's how real ledgers are built,
and it's why "what isolation level does your bank use" is usually the wrong question.
A materialised balance column can still exist as a cache, because with an append-only ledger it's always rebuildable. Where it's the source of truth instead, the equivalent safe form is a single atomic statement that does the check and the write together:
Zero rows updated means insufficient funds. There's no window between the check and the write for anyone to slip into, at any isolation level.
Serializable where the invariant spans rows¶
Row locks stop working as a strategy once the invariant isn't about one row. Fraud limits,
daily transfer caps, an overdraft rule across a customer's accounts — these read several
rows and the constraint holds over the combination. That's write skew, and Read Committed
plus FOR UPDATE only saves you if you remember to lock every row involved, which is
exactly the kind of discipline that decays as a codebase grows.
This is where Serializable earns its cost, and specifically SSI rather than 2PL-based Serializable. SSI lets transactions proceed optimistically and aborts on a detected dependency cycle, so you get the guarantee without the throughput collapse of locking every row you read. CockroachDB goes furthest here and defaults to Serializable for everything, on the argument that debugging an isolation anomaly in a distributed ledger is worse than paying for retries.
What actual systems do¶
Traditional RDBMS-backed banking cores — mainframe-era systems, including plenty of the cores still running in Indian banks — lean hard on explicit locking at Read Committed. Serializable's abort-and-retry behaviour was historically expensive, and a retry loop is not a natural thing to express in a COBOL-era codebase.
Modern fintech and distributed-ledger stacks lean the other way: Serializable via SSI, with idempotency keys at the API layer as a second line of defence. The retry cost is real but tolerable, and the engineering argument is that an anomaly you find in production is more expensive than a transaction you retry.
Idempotency is not an isolation level¶
This is the part people conflate, and it's a common bug class. Isolation levels protect you from concurrent transactions interfering with each other. They do nothing about the same logical request being submitted twice by a client retrying after a network timeout — that's one transaction, then later another identical one, and every isolation level in the standard will happily run both.
The fix is an idempotency key with a unique index on it, so the second attempt collides with a constraint instead of moving money again. Plenty of teams have tightened the isolation level, declared the concurrency bug fixed, and gone on double-charging customers through exactly this gap.
It generalises past retries, too: money moves between systems, not just between rows. A card payment crosses an acquirer, a network and an issuer, and no database transaction spans those. That correctness comes from idempotency keys, two-phase authorise-then-capture flows, reconciliation against settlement files, and sagas with compensating entries. The isolation level of one database is a small part of a much larger story.
The one-line version¶
Serializable, via SSI where available, for anything whose invariant spans multiple rows. Explicit row locking at Read Committed for straightforward transfers where you can lock exactly the rows involved. Idempotency keys as an orthogonal and non-optional layer on top of both.
Row locking in Postgres¶
Now the imperative side. Row-level locks are automatic on writes: every UPDATE and
DELETE locks the rows it touches, and you can take the same locks explicitly with
SELECT ... FOR .... Plain SELECT takes no row locks at all and is never blocked by them,
which is MVCC's headline feature — readers don't block writers and writers don't block
readers.
The four modes, strongest to weakest:
FOR UPDATE blocks any other transaction from updating, deleting, or locking that row until
you commit or roll back. This is the one you want when you're about to change a row based on
what you just read.
FOR NO KEY UPDATE is the same but allows concurrent foreign-key checks. Postgres takes
this automatically for UPDATEs that don't touch key columns.
FOR SHARE lets several transactions hold the row against modification at once. Everyone
can read, nobody can write.
FOR KEY SHARE is the weakest, taken automatically to keep a row alive for foreign-key
references. It blocks DELETE and key updates and nothing else.
The race it exists to solve¶
Without a lock, two transactions can both read "available" before either writes:
BEGIN;
SELECT status FROM seats WHERE id = 42; -- 'available'
-- the other transaction runs the same SELECT right here, also sees 'available'
UPDATE seats SET status = 'booked' WHERE id = 42;
COMMIT;
Both commit, both believe they booked it, one customer arrives at the cinema to find somebody in their seat. That's a lost update.
FOR UPDATE closes it:
BEGIN;
SELECT status FROM seats WHERE id = 42 FOR UPDATE; -- locks the row
UPDATE seats SET status = 'booked' WHERE id = 42;
COMMIT; -- lock released here
The second transaction blocks on its SELECT ... FOR UPDATE until the first commits. It
doesn't error — it waits, and then, under Read Committed, re-reads the row at its new
version and correctly sees 'booked'. That re-read is special: Read Committed normally
gives a statement a fixed snapshot, but a blocked locking statement re-evaluates the row
after the lock is granted, precisely so you don't act on the version you were waiting on.
NOWAIT and SKIP LOCKED¶
Waiting is often the wrong behaviour, so there are two escape hatches:
SELECT ... FOR UPDATE NOWAIT; -- error immediately if the row is locked
SELECT ... FOR UPDATE SKIP LOCKED; -- silently ignore locked rows
NOWAIT turns a queue into a fast failure, which is what you want when a user is staring at
a spinner and a retry is cheaper than a held connection.
SKIP LOCKED is the more interesting one. It changes "give me row 42" into "give me any row
that nobody else is working on", which is exactly the shape of a work queue or a
general-admission ticket pool:
SELECT id FROM seats
WHERE showing_id = 900 AND tier = 'premium' AND status = 'available'
ORDER BY row_label, seat_number
LIMIT 2
FOR UPDATE SKIP LOCKED;
Fifty concurrent requests asking for "two premium seats" each get a different pair instantly instead of all fifty serialising on whichever row sorted first. This one clause is the difference between a booking system that survives a Friday 6pm release and one that doesn't.
Note that SKIP LOCKED only makes sense when the rows are interchangeable. If the user
picked seat 42 specifically, skipping it and handing them seat 43 is a bug, not an
optimisation.
Deadlocks¶
If A locks row 1 and then asks for row 2 while B locks row 2 and then asks for row 1,
neither can proceed. Postgres notices after deadlock_timeout (1s by default), picks a
victim and aborts it with 40P01.
The fix is boring and works: always acquire row locks in a consistent order. For a multi-seat booking, sort the seat ids before locking them.
Every code path that locks the same family of rows has to agree on the order. This is the
kind of rule that belongs in a comment on the query, because the next person to add a
FOR UPDATE won't know it exists otherwise.
Putting it together: seat booking¶
Seat inventory is the canonical hard concurrency problem — supply is fixed, demand arrives in a spike the second a popular show opens, and double-booking is a visible business failure rather than a log line.
The naive design is to open a transaction when the user clicks a seat, FOR UPDATE it, and
hold that until payment completes. It's correct and it's unusable: payment takes seconds at
best and minutes at worst, and for that whole window you're holding a database transaction
and a connection per in-flight customer. A few thousand concurrent checkouts and you're out
of connections, with a pile of long-running transactions blocking vacuum for good measure.
So real systems separate the lock from the hold. The lock lives for microseconds inside a transaction. The hold is a row of data with an expiry.
CREATE TABLE seats (
id bigserial PRIMARY KEY,
showing_id bigint NOT NULL REFERENCES showings(id),
row_label text NOT NULL,
seat_number int NOT NULL,
status text NOT NULL DEFAULT 'available', -- available | held | booked
held_by uuid,
held_until timestamptz,
booking_id bigint REFERENCES bookings(id),
UNIQUE (showing_id, row_label, seat_number)
);
CREATE INDEX ON seats (showing_id, status);
Hold, in one short transaction:
BEGIN;
SELECT id, status, held_until
FROM seats
WHERE id = ANY($seat_ids)
ORDER BY id
FOR UPDATE;
UPDATE seats
SET status = 'held', held_by = $session, held_until = now() + interval '8 minutes'
WHERE id = ANY($seat_ids)
AND (status = 'available'
OR (status = 'held' AND held_until < now()));
COMMIT;
The UPDATE returns a row count. If it's less than the number of seats requested, somebody
else got there first and you roll back and tell the user. The transaction runs in
milliseconds and the row locks are gone by the time the payment page renders.
That held_until < now() predicate is the whole trick: an expired hold is treated as
available by the next writer, so you don't strictly need a reaper job for correctness.
You still want one, so the seat map shows the seat as free before somebody tries to take it,
but the invariant doesn't depend on the job running.
Confirm, after the payment provider says yes:
BEGIN;
SELECT id FROM seats
WHERE id = ANY($seat_ids) ORDER BY id
FOR UPDATE;
UPDATE seats
SET status = 'booked', booking_id = $booking, held_by = NULL, held_until = NULL
WHERE id = ANY($seat_ids)
AND status = 'held'
AND held_by = $session
AND held_until >= now();
COMMIT;
Notice that confirm re-checks the hold rather than trusting it. If the user sat on the
payment page past the expiry and somebody else took the seat, this UPDATE matches zero
rows and you refund instead of double-booking. The check belongs in the WHERE clause, not
in application code between two statements, because only then is it atomic with the write.
A few things that hang off this skeleton:
Idempotency at the payment boundary. Locking stops double-booking inside the database.
It does nothing about your payment webhook being delivered twice. A unique index on a
provider-supplied idempotency key in the bookings table turns the second delivery into a
constraint violation you can swallow, rather than a second booking.
Advisory locks for coarser coordination. pg_advisory_xact_lock(showing_id) gives you a
lock on a logical thing rather than a row, which is occasionally the right tool for
"serialise all seat-map mutations for this one showing". It's a blunt instrument — you've
just serialised an entire auditorium — but for something like a best-available allocator
that has to reason about the whole map, it's simpler than getting the row-lock ordering
right. Use the transaction-scoped variant so it releases on commit; the session-scoped one
leaks if your code path can return early.
Replicas are for display only. Row locks exist on the primary and nowhere else. Reading the seat map from a replica is fine and is what you want for the browse path, but it's eventually consistent and can't be trusted at the moment of booking. Every hold and confirm goes to the primary.
The queue in front of the database. For a genuine spike — a blockbuster opening at
midnight — the real answer is often not a better lock but a virtual waiting room that admits
a bounded number of users into the checkout flow at all. SKIP LOCKED scales a long way,
but nothing scales like not letting 200,000 people hit the same auditorium's rows at once.
What I'd remember¶
- Read Committed takes a snapshot per statement; Repeatable Read takes one per transaction. Almost every surprising behaviour follows from that one sentence.
- Postgres has no real Read Uncommitted, and its Repeatable Read prevents phantoms. Never trust a level's name across engines.
- Dirty reads are a solved problem. Lost updates and write skew are not, and they happen at Read Committed and Repeatable Read.
- Reach for an atomic
UPDATE ... WHEREor aSELECT ... FOR UPDATEbefore reaching for a higher isolation level. Locking a row is cheap and local; raising the level changes the failure mode of every query in the transaction. - Anything at Repeatable Read or above needs a retry loop for
40001. If you don't have one, you haven't finished adopting the level. SKIP LOCKEDwhen rows are interchangeable,NOWAITwhen the user is waiting, plainFOR UPDATEwhen you need that specific row.- Lock for microseconds, hold for minutes. Never span a payment call with a transaction.
- Sort before you lock, or you're writing a deadlock.
- Isolation levels stop concurrent transactions from interfering. They do nothing about a client retrying the same request. That's an idempotency key, and it isn't optional.