09-04 ended with an open question: if SERIALIZABLE isn't the answer for confirming an order, what is? And with an unexplained observation: when session B tried to update a row A had already touched, B was left waiting. This lesson explains what was holding it, and with it comes the tool e-commerce systems actually use to stop two customers buying the same unit: the lock.
You'll see the implicit row locks you were already causing without knowing it; the complete SELECT ... FOR UPDATE family and the safe read-modify-write pattern, which is the correct solution to the lost update; SKIP LOCKED for sharing out a queue of orders across several warehouse operators without treading on each other; optimistic locking against pessimistic; table locks, what an ALTER TABLE locks and why CREATE INDEX CONCURRENTLY had to exist; and deadlocks, with their demonstration, their real message, how the engine resolves them and the rules for not causing them. All reproducible with two psql terminals.
Contents
- Implicit row locks
- The
SELECT ... FOR ...family - The safe read-modify-write pattern
SKIP LOCKED: the job queueNOWAIT: failing fast- Optimistic against pessimistic locking
- Table locks,
ALTER TABLEandCREATE INDEX CONCURRENTLY - Deadlocks
- Diagnosis: who's blocking whom
- The four timeouts
- Sequences,
ROLLBACKand invoice numbers - Common Mistakes and Tips
- Exercises
- Module conclusion
- Implicit row locks
You don't have to ask for a lock: every UPDATE and every DELETE locks the row it touches until the end of the transaction. Let's go back to the matcha tea (product 15, stock 40), with two terminals:
| Instant | Session A | Session B |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | UPDATE products SET stock = stock - 1 WHERE id = 15; → UPDATE 1 |
|
| t3 | BEGIN; |
|
| t4 | SELECT stock FROM products WHERE id = 15; → 40, instantly |
|
| t5 | UPDATE products SET stock = stock - 1 WHERE id = 15; ← left waiting |
|
| t6 | COMMIT; |
↓ |
| t7 | UPDATE 1 — it unblocks on its own |
|
| t8 | SELECT stock FROM products WHERE id = 15; → 38 |
|
| t9 | COMMIT; |
Four lessons in nine instants:
- t4: reading doesn't wait. That's MVCC (09-02): readers don't block against writers. B reads the old version and carries on working.
- t5: writing does wait. B's terminal hangs, with no message and no cursor. It isn't a freeze: it's in a queue.
- t7: the lock lasts until the end of A's transaction, not until the end of its
UPDATE. That's why a long transaction is a harmful transaction (09-01). - t8: it comes out as 38, not 39. On unblocking, PostgreSQL reread the already updated row and recomputed
stock - 1over 39. It's exactly what 09-04 explained about why the relative form is safe.
And if A had done a ROLLBACK at t6, B would have carried on just the same, but computing over 40 and leaving the stock at 39. In both cases, the result is correct.
- The
SELECT ... FOR ... family
SELECT ... FOR ... familySometimes you need to lock a row before writing it, because between the read and the write there's a decision to make. That's what explicit locks are for, four of them, ordered from strongest to weakest:
| Clause | What it means | Automatically acquired by |
|---|---|---|
FOR UPDATE |
"I'm going to modify or delete this row" | An UPDATE touching key columns, a DELETE |
FOR NO KEY UPDATE |
"I'm going to modify it, but not its key" | An UPDATE not touching key columns |
FOR SHARE |
"I'm going to read it and I need nobody to change it" | — |
FOR KEY SHARE |
"I need its key to carry on existing" | A foreign key check |
And the compatibility table: ✗ means the second one waits for the first.
| Arriving ↓ / already granted → | KEY SHARE |
SHARE |
NO KEY UPDATE |
UPDATE |
|---|---|---|---|---|
FOR KEY SHARE |
✓ | ✓ | ✓ | ✗ |
FOR SHARE |
✓ | ✓ | ✗ | ✗ |
FOR NO KEY UPDATE |
✓ | ✗ | ✗ | ✗ |
FOR UPDATE |
✗ | ✗ | ✗ | ✗ |
That table, which looks like bureaucracy, solves a real and very frequent problem: FOR KEY SHARE is what PostgreSQL takes when checking a foreign key. Thanks to it being compatible with FOR NO KEY UPDATE, inserting a line for order 21 (which needs to check that order 21 exists) doesn't wait for another session to update that order's status. Before PostgreSQL 9.3 it did wait, and it was a classic source of cascading locks.
In day-to-day work you'll use FOR UPDATE in 95 % of cases and FOR SHARE in the odd integrity check. The other two you'll see in diagnostics, you won't ask for them yourself.
- The safe read-modify-write pattern
This is the correct solution to 09-04's lost update, and the lesson's most important pattern:
BEGIN;
SELECT stock FROM products WHERE id = 15 FOR UPDATE; -- 1. read LOCKING → 40
-- 2. decide: here the application applies its logic (discounts, reservations, limits…)
UPDATE products SET stock = 39 WHERE id = 15; -- 3. write
COMMIT;And now both sessions at once, the same scenario that lost a sale in 09-04:
| Instant | Session A | Session B |
|---|---|---|
| t1 | BEGIN; SELECT stock FROM products WHERE id = 15 FOR UPDATE; → 40 |
|
| t2 | BEGIN; SELECT stock FROM products WHERE id = 15 FOR UPDATE; ← waits |
|
| t3 | UPDATE products SET stock = 39 WHERE id = 15; COMMIT; |
|
| t4 | it unblocks and reads → 39 | |
| t5 | UPDATE products SET stock = 38 WHERE id = 15; COMMIT; |
| id | name | stock |
|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | 38 |
Thirty-eight. The key is at t2: B's FOR UPDATE waits for A to finish, and when it does, B reads 39, not 40. The window between reading and writing has disappeared, because throughout it the row was B's.
Compare it with the other two solutions you already know, because all three are valid and they're chosen on different criteria:
Solution When Cost UPDATE ... WHERE id = 15 AND stock >= 1(09-02)The logic fits in one statement None. It's the fastest and the first option SELECT ... FOR UPDATE+UPDATEYou have to decide between reading and writing The other sessions wait. Predictable SERIALIZABLEwith a retry (09-04)The invariant spans several rows Aborts and a retry loop
Two warnings about FOR UPDATE: it can't be used with GROUP BY, DISTINCT, UNION or window functions, because the engine wouldn't know which physical rows to lock; and it locks every row the query returns, so a SELECT * FROM products FOR UPDATE with no WHERE locks the whole catalogue.
Dialect note:
SELECT ... FOR UPDATEexists in PostgreSQL, MySQL/InnoDB and Oracle with the same syntax. SQL Server doesn't have it: it uses table hints,SELECT ... WITH (UPDLOCK, ROWLOCK). And SQLite accepts it syntactically but does nothing, because it only allows one writer at a time and the problem doesn't arise for it.
SKIP LOCKED: the job queue
SKIP LOCKED: the job queueBy adding SKIP LOCKED, the query doesn't wait: it skips the locked rows and carries on with the next ones. It's exactly what's needed to share work out across several processes.
The GreenStore case: several warehouse operators prepare the paid orders —today they're 18 and 19— and none of them should pick up the same one as another.
-- Each operator runs this, in their own transaction
BEGIN;
SELECT id, customer_id, order_date
FROM orders
WHERE status = 'paid'
ORDER BY order_date
FOR UPDATE SKIP LOCKED
LIMIT 1;| Instant | Session A — operator 1 | Session B — operator 2 |
|---|---|---|
| t1 | BEGIN; + the query → takes order 18 (2026-01-27) |
|
| t2 | BEGIN; + the same query → doesn't wait: it skips 18 and takes 19 |
|
| t3 | UPDATE orders SET status = 'shipped' WHERE id = 18; COMMIT; |
|
| t4 | UPDATE orders SET status = 'shipped' WHERE id = 19; COMMIT; |
|
| t5 | BEGIN; + the query → 0 rows: there's no work left |
At t1, A's query returns the row 18 | 5 | 2026-01-27; at t2, B's returns 19 | 6 | 2026-02-09. Without SKIP LOCKED, at t2 operator 2 would have been left waiting for operator 1 only to end up picking up the same order that was already done. With it, the query becomes a work dispatcher, and it's the canonical way of implementing a task queue over a table —sending emails, generating invoices, syncing with the marketplace— with no additional infrastructure at all.
NOWAIT: failing fast
NOWAIT: failing fastThe third option: neither wait nor skip, but give up immediately. SELECT stock FROM products WHERE id = 15 FOR UPDATE NOWAIT; returns, if the row is taken, ERROR: could not obtain lock on row in relation "products".
| Modifier | If the row is locked | When to use it |
|---|---|---|
| (nothing) | Waits indefinitely | The normal case: the work has to be done |
NOWAIT |
Immediate error | A web request with a time budget: better to say "try again" in 5 ms than to hang the user for 30 seconds |
SKIP LOCKED |
Ignores that row and carries on | Work queues: any free row will do |
- Optimistic against pessimistic locking
Everything above is pessimistic locking: you assume there will be a conflict and reserve the row in advance. The alternative is to assume there won't be and check on writing.
Pessimistic (FOR UPDATE) |
Optimistic (version column) | |
|---|---|---|
| Assumes | There will be a conflict | There won't be |
| Mechanism | Locks the row from the read onwards | Checks on writing that nobody changed it |
| The other sessions | Wait | Carry on working; one of them will fail at the end |
| If there's a conflict | Nothing happens: they queued | The work is lost and has to be redone |
| Requires | A transaction open from the read onwards | Nothing: it works with no transaction open between steps |
| Ideal for | Heavy contention over few rows: the stock | Little contention and human waiting: an edit form |
Optimistic locking is implemented with a version column incremented on every write:
-- ⚠️ THIS LESSON'S EXAMPLE: the `version` column is NOT part of GreenStore's
-- canonical schema (01-06). Add it to practise and reload afterwards.
ALTER TABLE products ADD COLUMN version INTEGER NOT NULL DEFAULT 0;The cycle has three steps, and between the first and the third no open transaction is needed — which is the whole point:
SELECT stock, version FROM products WHERE id = 15; -- 1. read without locking → 40, 0
-- 2. The user thinks. It can take five minutes. Nobody waits.
UPDATE products -- 3. write ONLY if nobody has touched it
SET stock = 39, version = version + 1
WHERE id = 15
AND version = 0; -- → UPDATE 1 if nobody got there firstAnd in the session that arrives second, with the same version = 0 read, the answer is UPDATE 0.
Zero rows affected: that's the signal. There's no error, no exception, no aborted transaction: there's a counter worth 0 that your code has to check. If it's 0, somebody got there before you and you have to reread, recompute and try again — or show the user "this data has changed while you were editing".
The risk of optimism: if you don't check the number of affected rows, an
UPDATE 0goes completely unnoticed and the change is silently lost. It's the sameUPDATE N05-03 asked you to always look at, now turned into the entire mechanism. The ORMs implementing this pattern —Hibernate with@Version, Django withselect_for_updateas an alternative— throw an exception precisely so it can't be ignored.
- Table locks,
ALTER TABLE and CREATE INDEX CONCURRENTLY
ALTER TABLE and CREATE INDEX CONCURRENTLYAs well as row locks, there are eight table lock modes. You don't have to memorise them; what you have to know is which one each operation takes and which clashes with which:
| Mode (weakest to strongest) | Taken by | Blocks |
|---|---|---|
ACCESS SHARE |
SELECT |
Only ACCESS EXCLUSIVE |
ROW SHARE |
SELECT ... FOR UPDATE / FOR SHARE |
EXCLUSIVE and ACCESS EXCLUSIVE |
ROW EXCLUSIVE |
INSERT, UPDATE, DELETE, MERGE |
From SHARE upwards |
SHARE UPDATE EXCLUSIVE |
VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, some ALTER TABLEs |
Itself and anything stronger. It doesn't block reads or writes |
SHARE |
CREATE INDEX (without CONCURRENTLY) |
Writes. Reads carry on |
EXCLUSIVE |
REFRESH MATERIALIZED VIEW CONCURRENTLY |
Everything except SELECT |
ACCESS EXCLUSIVE |
Most ALTER TABLEs, DROP TABLE, TRUNCATE, REINDEX, CLUSTER, VACUUM FULL |
Absolutely everything, SELECTs included |
They can also be asked for by hand, inside a transaction — BEGIN; LOCK TABLE products IN SHARE MODE; … COMMIT; lets you do a coherent stocktake with nobody writing meanwhile.
LOCK TABLErequires privileges. The modes aboveROW EXCLUSIVEdemand write or maintenance permissions on the table;SELECTisn't enough. Privileges and roles are 11-03.
And here two of the course's promises are closed. The first is 05-06's: most ALTER TABLEs take ACCESS EXCLUSIVE, the mode that blocks everything, queries included. That's why an apparently innocuous migration can bring a site down: not because of how long it takes to run, but because it first has to wait for every in-flight transaction to finish and, while it waits, it queues everybody arriving behind it. A 10 ms ALTER TABLE behind a 40-minute report stops the shop for 40 minutes. Hence 05-06's two rules: lock_timeout always before a migration, and variants that don't rewrite the table (ADD COLUMN with a DEFAULT is instantaneous since PostgreSQL 11; ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT avoids the long lock).
The second is 08-02's: a normal CREATE INDEX takes SHARE, which blocks all the table's writes for the whole time it takes to build — minutes or hours on a large table. CREATE INDEX CONCURRENTLY only takes SHARE UPDATE EXCLUSIVE, which blocks neither reads nor writes. The price is the one 09-03 explained: it walks the table twice and waits between passes for the old transactions to finish, so it takes longer, it can't run inside a transaction and, if it fails, it leaves an invalid index that has to be dropped by hand (spotted with indisvalid = false in pg_index). In production, always CONCURRENTLY.
- Deadlocks
A deadlock happens when two transactions wait for each other: A waits for a resource B holds, and B waits for one A holds. With no outside intervention, they'd wait forever.
The textbook demonstration, with two products:
| Instant | Session A | Session B |
|---|---|---|
| t1 | BEGIN; UPDATE products SET stock = stock - 1 WHERE id = 1; → UPDATE 1 |
|
| t2 | BEGIN; UPDATE products SET stock = stock - 1 WHERE id = 15; → UPDATE 1 |
|
| t3 | UPDATE products SET stock = stock - 1 WHERE id = 15; ← waits for B |
|
| t4 | UPDATE products SET stock = stock - 1 WHERE id = 1; ← waits for A |
|
| t5 | (after ~1 second, one of the two gets the error) |
flowchart LR
A["<b>Session A</b><br/>holds the lock<br/>on product 1"] -->|"waits for<br/>product 15"| B["<b>Session B</b><br/>holds the lock<br/>on product 15"]
B -->|"waits for<br/>product 1"| A
That cycle in the wait graph is the formal definition of a deadlock, and it's what PostgreSQL looks for. The real message:
ERROR: deadlock detected DETAIL: Process 41290 waits for ShareLock on transaction 813; blocked by process 41287. Process 41287 waits for ShareLock on transaction 814; blocked by process 41290. HINT: See server log for query details. CONTEXT: while updating tuple (0,21) in relation "products"
How the engine resolves it. Every time a transaction has been waiting for longer than deadlock_timeout (1 second by default), PostgreSQL builds the wait graph and looks for cycles. If it finds one, it picks a victim and aborts its transaction with code 40P01. The other carries on and finishes normally. It isn't a setting you have to enable: it ships switched on and works by itself.
The four rules for not causing them:
- Always access resources in the same order. It's the golden rule and it solves 90 % of cases. If every transaction touching several products walks them sorted by
id, the cycle is impossible: whoever holds 1 will ask for 15, and whoever doesn't hold 1 will be waiting for it, having taken nothing. In practice:ORDER BY idin theSELECT ... FOR UPDATEpreceding the writes. - Short transactions. The less time a lock is held, the lower the probability of crossing over.
- No human interaction and no external calls inside. A user thinking with two rows locked is a deadlock factory (09-01, 09-03).
- Touch the rows in a single
UPDATEwhen you can. AnUPDATE ... WHERE id IN (1, 15)locks in a deterministic order and leaves no gap.
And as a final net: the 40P01 is retried just like the 40001, with the same exponential-backoff loop from 09-04. That's why that pattern filtered on both codes.
- Diagnosis: who's blocking whom
When something "has hung", this is the query to keep saved:
SELECT pid, pg_blocking_pids(pid) AS blocked_by, state, wait_event_type,
now() - query_start AS waiting_since,
left(query, 50) AS query_text
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;| pid | blocked_by | state | wait_event_type | waiting_since | query_text |
|---|---|---|---|---|---|
| 41290 | {41287} | active | Lock | 00:03:12 | UPDATE products SET stock = stock - 1 WHERE id |
pg_blocking_pids() is the key function: it returns directly the processes blocking a given one. With the guilty pid (41287), you look at what it's doing in pg_stat_activity —very often, nothing: idle in transaction— and, if necessary, you cut it off:
| Function | Effect |
|---|---|
SELECT pg_cancel_backend(41287); |
Cancels the query in progress. The transaction stays alive |
SELECT pg_terminate_backend(41287); |
Closes the whole connection. The transaction is undone |
For fine detail there's pg_locks, which answers "what exact mode is it asking for and on which object?" — useful above all with section 7's table locks:
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM pg_locks AS l LEFT JOIN pg_class AS c ON c.oid = l.relation
WHERE NOT l.granted;
- The four timeouts
Four parameters, four different jobs, and it's worth not confusing them:
| Parameter | What it limits | Typical value |
|---|---|---|
lock_timeout |
How long a statement waits for a lock before failing | '2s' before any migration. The most important of the four |
statement_timeout |
How long a statement can last in total | '30s' on the web application's user |
idle_in_transaction_session_timeout |
How long a transaction can stay open and idle | '5min', always (09-01) |
deadlock_timeout |
How long it waits before looking for a deadlock cycle | '1s', the default value. It isn't a limit: lowering it only makes the check run more often |
-- The mandatory preamble to any production migration
SET lock_timeout = '2s';
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(6,3);If the table is busy, the statement fails in two seconds with ERROR: canceling statement due to lock timeout instead of queueing the whole shop up behind it. You retry later and nothing has happened. Without lock_timeout, that ALTER TABLE is an outage waiting to happen.
- Sequences,
ROLLBACK and invoice numbers
ROLLBACK and invoice numbersYou've seen it three times already —in 05-02, in exercise 2 of 09-01 and in 09-03's batch— and now it's time for the why and the consequence:
nextval()isn't transactional. AROLLBACKdoesn't give the consumed value back, and that's why sequences leave gaps.
And it's a deliberate decision, not an oversight. If nextval respected transactions, it would have to lock the sequence until the COMMIT, and every session wanting to insert into that table would line up single file behind the first: every concurrent INSERT would be a bottleneck. PostgreSQL trades continuity for guaranteed uniqueness and maximum concurrency, which is the right deal for a surrogate key. And from that follows a blunt consequence:
Never use the primary key as an invoice number. A legal invoice numbering has to be consecutive and gapless; a sequence guarantees it's unique and increasing, which isn't the same. A
ROLLBACK, a failedINSERTor an upsert ending in a conflict (05-05) opens a gap, and that gap is a problem with the tax authorities, not an aesthetic detail.
The three ways of getting a gapless numbering, with their price:
| Approach | How | Price |
|---|---|---|
| A counter in a table, locked | One row per series and year; SELECT ... FOR UPDATE on it, add 1, write |
It serialises the invoices: only one at a time. Acceptable, because invoicing isn't the hot path |
| Assign on issue, not on creation | The order has its id with gaps; the invoice number is assigned in a later process, ordered and in batches |
It requires separating "order" from "invoice", which is additionally the correct thing to do accounting-wise |
| Reserve ranges | Each process takes a block of 100 numbers | The gaps come back if a block isn't used up. Only valid if the law allows it |
The first is the usual one, and its implementation is exactly section 3's pattern:
BEGIN;
SELECT last_number FROM invoice_counters WHERE series = 'A' AND year_ = 2026 FOR UPDATE;
UPDATE invoice_counters SET last_number = last_number + 1 WHERE series = 'A' AND year_ = 2026;
-- ... insert the invoice with that number ...
COMMIT;(The invoice_counters table is an example from this lesson and isn't part of GreenStore's canonical schema.) Notice what you're doing: deliberately giving up concurrency at a specific point because a legal requirement demands it. Knowing when to do that is, at bottom, what the whole module has been about.
Common Mistakes and Tips
- Believing a normal
SELECTlocks anything. It locks nothing, and that's why reading never waits. If you need the row not to change, you have to ask for it withFOR UPDATEorFOR SHARE. And reading withoutFOR UPDATEand writing afterwards is 09-04's lost update, now with no excuse. - Putting
FOR UPDATEon a query with no selectiveWHERE. You lock every row it returns; with the whole catalogue, you've stopped the shop. - Forgetting to check the number of affected rows with optimistic locking. An
UPDATE 0means "somebody got there first", and if you don't look at it the change is silently lost. - Firing an
ALTER TABLEin production with nolock_timeout. It takesACCESS EXCLUSIVE, waits for the longest transaction around and queues everybody behind it. AndCREATE INDEXwithoutCONCURRENTLYblocks writes for the whole build. - Touching several rows in a different order in each part of the code. It's the recipe for a deadlock.
ORDER BY idin theSELECT ... FOR UPDATE, always. - Lowering
deadlock_timeout"so it detects them sooner". It isn't a wait limit: it only makes the cycle check run more often and burn more CPU. And the40P01isn't a programming error: it's a temporal conflict that gets retried, just like the40001. - Using the PK as an invoice number. Sequences leave gaps by design and legal numbering doesn't allow them.
- Tip: keep the
pg_blocking_pidsquery saved. It's the first thing you run when something hangs, and it saves half an hour of guesswork. - Tip:
SKIP LOCKEDturns a table into a work queue. Before setting up messaging infrastructure, check whether this isn't enough for you. - Tip: choose pessimistic where there's real contention and optimistic where there's human waiting. Stock:
FOR UPDATE. Edit form: version column.
Exercises
With two psql terminals and the freshly reloaded database.
Exercise 1
Solve the lost update with explicit locking and compare.
- Reproduce section 3 in two sessions and check that product 15's final stock is 38. Note at which exact instant session B is left waiting and what value it reads on unblocking.
- Repeat it, changing B's
FOR UPDATEforFOR UPDATE NOWAIT. What message comes out and how long does it take? - Repeat it with
FOR UPDATE SKIP LOCKED. How many rows does B's query return and why is that a dangerous result in this particular case? - Write the three valid solutions to this problem that you already know (09-02, 09-04 and this lesson) and say which you'd choose for GreenStore's basket and why.
Exercise 2
Set up the warehouse's order-picking queue.
- Write the query that takes the oldest
paidorder without treading on anybody, and run it in two sessions at once. Check that one takes 18 and the other 19. - Add a third session with the same query. What does it return and why?
- Without
SKIP LOCKED, what would the second session have done? And what would have happened on unblocking, exactly? - Design the variant that also serves to retry orders left half-done (an operator whose process died). What has to be added to
ordersand why is that column not part of the canonical schema?
Exercise 3
Trigger a deadlock and diagnose it.
- Reproduce section 8 with products 1 and 15. Copy the full error message and note which session was the victim.
- While both are waiting (before the second), run the
pg_blocking_pidsquery from a third session and describe what you see. - Rewrite both transactions so the deadlock is impossible, without using
LOCK TABLEand without changing the isolation level. - If it still happened under load, what should the application do? State the SQLSTATE code and the exact pattern.
Solutions
Solution 1
1. The final stock is 38. B is left waiting at its own SELECT ... FOR UPDATE, not at the UPDATE: that's the change from section 1. On unblocking, after A's COMMIT, it reads 39 —the already updated value— and computes 38 from it. The window between reading and writing has disappeared.
2. The error is immediate, in milliseconds: ERROR: could not obtain lock on row in relation "products". And it's a perfectly valid response for a web request: better to return "try again" instantly than to leave the user staring at a spinner for thirty seconds.
3. B's query returns 0 rows, and that's extremely dangerous here. SKIP LOCKED is designed for "give me any free row"; but B doesn't want any product: it wants that one. An empty result would make the application believe that product 15 doesn't exist, or —worse— carry on without deducting anything. SKIP LOCKED only makes sense when the rows are interchangeable, as in a work queue.
4. The three solutions:
| Solution | Where from | When |
|---|---|---|
UPDATE ... WHERE id = 15 AND stock >= 1 and check UPDATE N |
09-02 | The logic fits in one statement |
SERIALIZABLE + retry loop |
09-04 | The invariant spans several rows |
SELECT ... FOR UPDATE + UPDATE |
This lesson | You have to decide between reading and writing |
For GreenStore's basket: the first, and if the deduction needs intermediate logic (checking reservations, applying promotions), the third. The second is ruled out because it would force implementing retries on the shop's hottest path and would abort exactly at the traffic peaks, which is when it's least welcome.
Solution 2
1. The query is section 4's: SELECT ... WHERE status = 'paid' ORDER BY order_date FOR UPDATE SKIP LOCKED LIMIT 1. Session A takes order 18 (2026-01-27) and B, without waiting, 19 (2026-02-09).
2. The third returns 0 rows, because GreenStore's only two paid orders are locked by A and B. It's the correct behaviour of an empty queue: operator 3 doesn't wait, sees there's no work and asks again later.
3. Without SKIP LOCKED, the second session would be left waiting for A to finish. And on unblocking the worst would happen: PostgreSQL rereads the row, re-evaluates the WHERE and discovers that order 18 no longer satisfies status = 'paid' (A moved it to shipped), so it discards it and returns… 0 rows. It doesn't even take 19, because the LIMIT 1 had already been resolved. Operator 2 would have waited for nothing.
4. You need to mark the orders somebody is picking and since when, so the abandoned ones can be recovered:
-- ⚠️ NOT canonical: example columns for this exercise
ALTER TABLE orders ADD COLUMN picking_since TIMESTAMPTZ;The query becomes WHERE status = 'paid' AND (picking_since IS NULL OR picking_since < now() - INTERVAL '15 minutes'), and the process writes picking_since = now() when it takes one. That way, if an operator dies halfway, their order goes back into the queue after fifteen minutes. It isn't canonical because 01-06's schema models a shop, not a work queue: the column exists only for this mechanism and no other module uses it.
Solution 3
1. The message is section 8's, with deadlock detected and code 40P01. The victim is the session that triggers the detection, that is, the one that had been waiting for longer than deadlock_timeout when the cycle was found — normally the second to become blocked (B in the script). The other carries on and commits normally.
2. From the third session you see two rows, each blocked by the other: A's pid has B's pid in blocked_by, and B's has A's. That reciprocity is the wait graph's cycle, visible in a query's output. It's the module's most satisfying diagnosis, and you have to be quick: PostgreSQL resolves it in about a second.
3. It's enough to always access the rows in the same order, for example by ascending id: both sessions run BEGIN; → UPDATE ... WHERE id = 1; → UPDATE ... WHERE id = 15; → COMMIT;, in that order. Now the cycle is impossible: whoever gets product 1 will end up getting 15, and whoever doesn't get it will be waiting with nothing locked, so it can't block anybody. It's section 8's golden rule. The general version, when the rows are chosen at run time, is to sort the list before walking it: SELECT ... WHERE id = ANY($1) ORDER BY id FOR UPDATE.
4. It should retry the whole transaction. The code is 40P01 (deadlock_detected), and the pattern is exactly 09-04's loop: rollback(), a wait with exponential backoff and jitter, up to five attempts, without retrying any other kind of error. That's why that pseudocode filtered on '40001' and '40P01': they're the module's two errors that mean "try again", not "you did it wrong".
Module conclusion
You close the last lesson with the tools that were missing:
- Every
UPDATEandDELETElocks its row until the end of the transaction, and that's why session B was waiting. Reading never waits; writing does. And on unblocking, PostgreSQL rereads and recomputes, which is whySET stock = stock - 1gave 38. - The
SELECT ... FOR UPDATE / FOR NO KEY UPDATE / FOR SHARE / FOR KEY SHAREfamily with its compatibility table, and the safe read-modify-write pattern: the correct solution to 09-04's lost update, and the one shops actually use. SKIP LOCKED, which turns a query into a work dispatcher —two operators, orders 18 and 19, without treading on each other— andNOWAITfor failing in milliseconds instead of hanging a user.- Optimistic against pessimistic: locking in advance where there's real contention (the stock), or checking on writing with a version column where there's human waiting — always looking at the
UPDATE 0, because there's no error there to warn you. - The table locks and their eight modes, with the two promises they close: most
ALTER TABLEs takeACCESS EXCLUSIVEand queue the whole database behind them (05-06), andCREATE INDEXblocks writes whileCREATE INDEX CONCURRENTLYdoesn't (08-02). In production,lock_timeoutandCONCURRENTLY, always. - Deadlocks: the cycle in the wait graph, the
deadlock detectedwith its40P01, the victim picked by the engine after a second, and the golden rule that makes them impossible — always access resources in the same order. - The diagnosis:
pg_blocking_pids()as the first query when something hangs,pg_locksfor the detail,pg_cancel_backendandpg_terminate_backendto cut things off, and the four timeouts headed bylock_timeout. - And why a sequence isn't undone by a
ROLLBACK: because making it transactional would turn everyINSERTinto a bottleneck. Hence the gaps, and hence the rule: the primary key isn't an invoice number.
And with that module 9 closes. In five lessons you've gone from "there's a statement and I run it" to understanding the whole system: you know that every standalone statement is already a transaction and that confirming an order is four operations that have to go together; you know the four ACID guarantees one by one, with the WAL that makes a COMMIT survive a power cut and with MVCC, which finally explains the dead rows, the bloat and the VACUUM left pending in module 8; you handle the complete TCL, including the SAVEPOINTs that rescue a batch from a failed load; you can name the four concurrency anomalies, you've watched a matcha tea sale get lost across two sessions and you know that PostgreSQL doesn't implement READ UNCOMMITTED and its REPEATABLE READ allows no phantoms; and you can choose between a conditional statement, a FOR UPDATE and a SERIALIZABLE with a retry, knowing what you pay in each case.
You already know how to read (modules 2 to 4 and 7), write (module 5), transform (module 6), optimize (module 8) and coordinate (this one). What's missing isn't a new capability: it's the arsenal that makes everything above maintainable, and that separates a query that works from a system you can live with. In module 10, Advanced, you'll see views, to give a name to a complex query and stop copying it everywhere; CTEs with WITH, which turn a forty-line nested subquery into readable steps and let you write recursive queries to walk GreenStore's employee hierarchy or its referral chain; window functions, with which you compute rankings, moving averages and running totals without losing the detail; stored procedures, where the order confirmation you built in 09-03 will end up living; triggers, which automatically apply those business rules 09-02 left outside a CHECK's reach; and the JSON type, for what doesn't fit in a fixed schema. It's the toolbox of somebody who's no longer learning SQL, but using it.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
