One letter was left undeveloped in the previous lesson. The A for atomicity, the C for consistency and the D for durability we opened right up; the I for isolation we stated in four lines and postponed. This is the postponement.
The reason for postponing it is that isolation is not like the other three. Atomicity admits no degrees: a transaction is atomic or it is not. Neither does durability: what is committed survives or it does not. Isolation, by contrast, is a dial. The SQL standard defines four positions, each management system implements the ones it wants and how it wants, and choosing the wrong position produces errors that do not show up on the development laptop, do not show up in the tests, show up on a Tuesday at eleven in the morning at the Central branch front desk and cannot be reproduced.
Because that is exactly what has happened this week at BiblioRed. Copy EJ-3081 of "The Map of Time" shows as on loan to two different members at the same time. The North branch's autumn reading club has 25 seats and 26 people signed up, even though the system checked capacity before registering anyone. And a published event has been left with no speaker at all, despite the application preventing the last one from being cancelled.
None of the three is a programming error in the usual sense. The code that produces them reads well, passes review and works perfectly when a single person runs it. All three are concurrency failures, and this lesson is about recognizing them, triggering them at will and fixing them.
Everything that follows is designed for you to reproduce. Open two terminals with psql connected to the same database. Throughout the lesson we will call them Session A and Session B, and each demonstration states in what order each statement has to be run. Running them in another order gives another result, and that is precisely the lesson.
Contents
- Why concurrency is essential and why it is dangerous
- The laboratory: two terminals and the initial state
- Phenomenon 1: lost update
- Phenomenon 2: dirty read
- Phenomenon 3: non-repeatable read
- Phenomenon 4: phantom read and the last seat
- Phenomenon 5: write skew, the surprising one
- The four isolation levels of the SQL standard
- How the level is set and what PostgreSQL really does
- Lock-based concurrency control
- Explicit locks:
FOR UPDATE,FOR SHARE,LOCK TABLE,SKIP LOCKED - Multiversion concurrency control (MVCC) and why
VACUUMexists - Deadlocks: how they arise, how they are detected, how they are avoided
- Optimistic versus pessimistic locking
- The complete solution: the last seat in the reading club
- Outside PostgreSQL: SQLite and the return of the problem in NoSQL
- Why concurrency is essential and why it is dangerous
Let us start with the obvious, because it explains why "doing it one at a time" is not enough.
Why it is essential. BiblioRed has four front desks, a web catalog open to 12,000 members, a mobile application and several automated processes (due-date notices, report generation). If operations ran strictly one after another, each front desk would wait for all the others to finish. And not only that: while one transaction waits for the disk to confirm its fsync() —those milliseconds from section 12 of the previous lesson—, the processor would sit idle. Concurrency is what allows one operation's waiting time to be another's working time.
| Without concurrency | With concurrency |
|---|---|
| The server handles one operation at a time | It handles dozens or hundreds simultaneously |
| Resources (CPU, disk, network) are used in turns | They are used at once and overlap |
| Response time grows linearly with load | It stays stable until saturation |
| Reasoning about the code is trivial | Reasoning about the code is hard |
Why it is dangerous. That last row is the whole lesson. When two transactions touch the same data at the same time, the result may depend on the exact order in which their statements interleave. And you do not control that order: it is decided by the operating system's scheduler, by network latency and by which page happened to be in cache in that microsecond.
The classic formulation of the problem is this:
The goal of concurrency control is that the interleaved execution of several transactions produces the same result as some execution in which they had run one after another. That property is called serializability.
Notice the "some": no specific order is required. If A and I run at the same time, it is fine for the result to be that of "A and then I" or that of "I and then A". What is not fine is for it to be a result that no sequential order would have produced. When that happens, we have an anomaly.
- The laboratory: two terminals and the initial state
Before triggering anything, we have to set the stage. Open two terminals:
# Terminal 1 — we will call it Session A
psql -U bibliored -d biblioredb
# Terminal 2 — we will call it Session B
psql -U bibliored -d biblioredbA very handy trick: make each session identify itself in the prompt and always show the process number, which will be needed when we talk about locks.
And the starting state of the data we are about to abuse:
copy_id | code | status | branch_id
---------+---------+-----------+-----------
3081 | EJ-3081 | available | 1SELECT event_id, title, offered_seats,
(SELECT coalesce(sum(occupied_seats),0) FROM registrations r
WHERE r.event_id = e.event_id AND r.status = 'confirmed') AS occupied
FROM events e WHERE event_id = 51; event_id | title | offered_seats | occupied
----------+-----------------------+---------------+----------
51 | Autumn reading club | 25 | 24One free seat. One available copy. Everything you need to break the system.
- Phenomenon 1: lost update
Lost update. Two transactions read the same piece of data, both compute a new value from what they read, and both write. The second write overwrites the first, which is lost without a trace and without an error.
This is the EJ-3081 failure. The front desk application does the natural thing: it reads the status, checks that it is available, and lends it.
Triggering it
The two front desks at the Central branch serve members at the same time. Session A is desk 1, serving Marta Alsina (member 14). Session B is desk 2, serving Iván Pereda (member 15). Run in the order of the "step" column:
| Step | Session A (desk 1, member 14) | Session B (desk 2, member 15) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | BEGIN; |
|
| t3 | SELECT status FROM copies WHERE copy_id=3081; → available |
|
| t4 | SELECT status FROM copies WHERE copy_id=3081; → available |
|
| t5 | The application decides: it is available, it can be lent | |
| t6 | The application decides: it is available, it can be lent | |
| t7 | INSERT INTO loans (member_id, copy_id, loan_date, due_date) VALUES (14,3081,CURRENT_DATE,CURRENT_DATE+21); |
|
| t8 | UPDATE copies SET status='on_loan' WHERE copy_id=3081; |
|
| t9 | COMMIT; |
|
| t10 | INSERT INTO loans (...) VALUES (15,3081,...); |
|
| t11 | UPDATE copies SET status='on_loan' WHERE copy_id=3081; |
|
| t12 | COMMIT; |
Check the damage:
SELECT loan_id, member_id, copy_id, return_date
FROM loans WHERE copy_id = 3081 AND return_date IS NULL;loan_id | member_id | copy_id | return_date ---------+-----------+---------+------------- 88301 | 14 | 3081 | 88302 | 15 | 3081 |
Two open loans of the same physical copy. Marta has taken it home and Iván is at the front desk asking where his book is. There has been no error, no exception, no trace. Both transactions were atomic, consistent and durable. And the result is impossible.
Why it happened
A's check (t3) and A's write (t8) are separated in time, and in that gap B read. B made its decision with information that stopped being true before B acted. It is what is known as an unprotected read-modify-write sequence.
Notice an important detail: no sequential order produces this result. If A had run in full and then B, B would have read on_loan and would have rejected the loan. The other way round, the same. The result obtained corresponds to no serial execution: it is an anomaly in the strict sense of section 1.
A note on the standalone UPDATE
It is important to understand why this happens despite the fact that a standalone UPDATE is safe. Compare:
-- Dangerous: read, decide outside, write
SELECT status FROM copies WHERE copy_id = 3081; -- the application decides
UPDATE copies SET status = 'on_loan' WHERE copy_id = 3081;
-- Safe: the decision is inside the write itself
UPDATE copies SET status = 'on_loan'
WHERE copy_id = 3081 AND status = 'available';The second form is truly atomic: the management system locks the row to update it and evaluates the condition against the most recent version. If another transaction has already set it to on_loan, the reply is:
And that UPDATE 0 is the signal the application must read as "somebody got ahead of me, cancel the operation". Checking the number of affected rows is the cheapest defense there is against lost updates, and it is surprising how much code ignores it.
- Phenomenon 2: dirty read
Dirty read. A transaction reads data that another has modified but has not yet committed. If the other one does a
ROLLBACK, the first has worked with data that never existed.
Trying to trigger it
| Step | Session A (collecting a fine) | Session B (revenue report) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | UPDATE fines SET status='paid' WHERE fine_id=4102; → UPDATE 1 |
|
| t3 | BEGIN; |
|
| t4 | SELECT status FROM fines WHERE fine_id=4102; |
|
| t5 | ROLLBACK; (the card reader declines the card) |
In a management system that allowed dirty reads, at t4 Session B would read paid and the report would count a payment that never happened.
In PostgreSQL, at t4 Session B reads pending. Always. There is no way to trigger a dirty read, not even by asking for one explicitly:
PostgreSQL accepts the READ UNCOMMITTED syntax for standard compatibility, but internally treats it as READ COMMITTED. The reason is its architecture: as we will see in section 12, multiversion concurrency control means a transaction always reads a committed version of every row. It is not that reading dirty data is forbidden: it is that there is no mechanism with which to do it.
This does not mean the phenomenon is a historical curiosity. Other management systems do allow it —SQL Server with READ UNCOMMITTED or the infamous WITH (NOLOCK), MySQL with READ UNCOMMITTED— and some people turn it on "so the reports do not block anything". It is a bad idea: besides reading data that may disappear, on some engines it can read duplicated rows or skip rows if the index is reorganized during the scan.
- Phenomenon 3: non-repeatable read
Non-repeatable read. A transaction reads a row, and on reading it again within the same transaction it gets different values, because another transaction modified and committed it in between.
Triggering it (it works in PostgreSQL at the default level)
BiblioRed's management asks for a report that first counts the pending fines and then adds up their amounts:
| Step | Session A (management report) | Session B (South front desk) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | SELECT amount FROM fines WHERE fine_id=4102; → 12.40 |
|
| t3 | UPDATE fines SET amount=3.50 WHERE fine_id=4102; |
|
| t4 | (autocommit: it is already committed) | |
| t5 | SELECT amount FROM fines WHERE fine_id=4102; → 3.50 |
|
| t6 | COMMIT; |
The same query, within the same transaction, has returned two different values. The report A is building mixes figures from two instants: if the first read fed a total and the second a breakdown, the total and the breakdown do not match. And whoever receives it will think there is a calculation error.
The solution: raise the level
-- Session A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT amount FROM fines WHERE fine_id = 4102; -- 12.40
-- (Session B modifies and commits)
SELECT amount FROM fines WHERE fine_id = 4102; -- 12.40 ← stable
COMMIT;At REPEATABLE READ, PostgreSQL takes a snapshot of the database on the transaction's first statement, and all later reads see that snapshot, ignoring whatever others commit afterwards. It is exactly what a report needs: a coherent photograph of one instant.
Practical rule. Every report that runs more than one query and presents the results together should go inside a
REPEATABLE READtransaction. It is free, it is one line, and it eliminates the whole "the numbers do not add up" family at the root.
- Phenomenon 4: phantom read and the last seat
Phantom read. A transaction runs a query with a condition, and on repeating it new rows appear that satisfy that condition and that another transaction inserted and committed in between. The difference from the non-repeatable read is that there the values of a row changed; here the set of rows changes.
This is the reading club failure, and it is subtler than the previous ones because the code that produces it looks impeccable.
The registration application does the following: it counts the occupied seats, checks that some are free, and inserts.
-- What the application does when registering somebody
SELECT coalesce(sum(occupied_seats), 0)
FROM registrations
WHERE event_id = 51 AND status = 'confirmed';
-- if the result < offered_seats, then:
INSERT INTO registrations (event_id, member_id, registration_date, status, companions, occupied_seats)
VALUES (51, ..., now(), 'confirmed', 0, 1);Triggering it
Remember the state: event 51, 25 seats, 24 occupied, one free. Marta Alsina (14) signs up from her phone while Iván Pereda (15) signs up at the North front desk.
| Step | Session A (Marta, member 14) | Session B (Iván, member 15) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | BEGIN; |
|
| t3 | SELECT sum(occupied_seats) FROM registrations WHERE event_id=51 AND status='confirmed'; → 24 |
|
| t4 | SELECT sum(occupied_seats) FROM registrations WHERE event_id=51 AND status='confirmed'; → 24 |
|
| t5 | 24 < 25 → there is room | |
| t6 | 24 < 25 → there is room | |
| t7 | INSERT INTO registrations VALUES (51,14,now(),'confirmed',0,1); |
|
| t8 | INSERT INTO registrations VALUES (51,15,now(),'confirmed',0,1); |
|
| t9 | COMMIT; |
|
| t10 | COMMIT; |
SELECT sum(occupied_seats) AS occupied
FROM registrations WHERE event_id = 51 AND status = 'confirmed';26 people for 25 chairs. On the day of the reading club, somebody is left standing.
Why the prior COUNT is never enough
This is the point to internalize, because it is counterintuitive:
Counting before inserting protects you from nothing. The count is true at the instant it is taken and stops being true immediately afterwards. Between the
SELECTand theINSERTthere is a gap, and a whole transaction fits through that gap.
And there is something worse. With the non-repeatable read, raising to REPEATABLE READ was enough. Here no snapshot is enough, because the problem is not what A sees: it is that A and B are deciding about the same seat without knowing it. Not even a lock on the rows read would help, because the conflicting rows —the other one's registration— did not exist yet when the read happened. You cannot lock a row that does not exist. Hence the name "phantom".
The real solutions are covered in section 15, and there are three of them, with different consequences.
A nuance about PostgreSQL and phantoms
The SQL standard says that REPEATABLE READ permits phantom reads. PostgreSQL does not permit them: its REPEATABLE READ is really snapshot isolation, and the snapshot covers the whole database, so rows inserted afterwards are invisible. It is stricter than the standard.
But be careful with the conclusion: that solves the read phantom, not the last-seat problem. Under REPEATABLE READ, A would not see B's registration... and would still insert its own, and both COMMITs would succeed because there is no write conflict on the same row. The result would still be 26. This is the natural bridge to the next phenomenon.
- Phenomenon 5: write skew, the surprising one
Write skew. Two transactions read the same set of data, each decides something based on it, and each writes to different rows. Neither overwrites the other, so there is no detectable conflict, but together they violate a rule that individually they respected.
It is the phenomenon that surprises even people who have worked with databases for years, because it happens at REPEATABLE READ / snapshot isolation, which many people take to be "the safe level".
The BiblioRed case
House rule: every published event must have at least one confirmed speaker. Event 51 has two: Nuria Bastos and an external speaker. When cancelling a speaker, the application checks that one is left.
| Step | Session A (cancels speaker 7) | Session B (cancels speaker 9) |
|---|---|---|
| t1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| t2 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| t3 | SELECT count(*) FROM participations WHERE event_id=51 AND status='confirmed'; → 2 |
|
| t4 | SELECT count(*) FROM participations WHERE event_id=51 AND status='confirmed'; → 2 |
|
| t5 | 2 > 1 → I can cancel one | |
| t6 | 2 > 1 → I can cancel one | |
| t7 | UPDATE participations SET status='cancelled' WHERE event_id=51 AND speaker_id=7; |
|
| t8 | UPDATE participations SET status='cancelled' WHERE event_id=51 AND speaker_id=9; |
|
| t9 | COMMIT; |
|
| t10 | COMMIT; |
Zero speakers in a published event. And both transactions were right when they decided.
Notice why the management system did not protest: A wrote to speaker 7's row, B to speaker 9's. There is no conflicting row. The mechanisms that detect lost updates work at row level, and here there are no two writes on the same row. The conflict is in the premise: both read a set the other was about to modify.
Other examples of the same pattern, so you recognize it when you see it:
| Domain | Rule | Write skew |
|---|---|---|
| Medical on-call rota | Always at least one doctor on call | Two doctors go off duty at the same time |
| Joint account | The combined balance of the two accounts cannot be negative | Two simultaneous withdrawals, one from each account |
| Room bookings | Two events cannot overlap | Two event creations that overlap each other |
| BiblioRed | Every branch keeps one reference copy | Two simultaneous transfers of the last copy |
The only defenses against write skew are:
SERIALIZABLEisolation (with retries, because it will abort transactions).- Materializing the conflict: forcing both transactions to write to the same row, even artificially —for example, locking the
eventsrow withSELECT ... FOR UPDATEbefore touching its speakers—. - A database constraint that expresses the rule, when possible. Here it is not directly possible (a
CHECKcannot count rows in another table), which illustrates the limit of declarative constraints against rules that span several rows.
In section 15 we will apply exactly these three ideas to the last-seat problem.
- The four isolation levels of the SQL standard
The SQL:1992 standard defined four levels, precisely in terms of which phenomena they permit. This is the canonical table, the one you have to know:
| Level | Dirty read | Non-repeatable read | Phantom read | Write skew |
|---|---|---|---|---|
READ UNCOMMITTED |
Possible | Possible | Possible | Possible |
READ COMMITTED |
Impossible | Possible | Possible | Possible |
REPEATABLE READ |
Impossible | Impossible | Possible (per the standard) | Possible |
SERIALIZABLE |
Impossible | Impossible | Impossible | Impossible |
The last two columns deserve a note: write skew does not appear in the 1992 standard. It was described later, when snapshot isolation became popular and it turned out that it satisfied the standard's table up to REPEATABLE READ and still permitted anomalies. We include it because in practice it is the one that causes most problems today.
And now the table that really matters when you work with PostgreSQL:
| Level requested | What PostgreSQL actually does | Dirty | Non-repeatable | Phantom | Write skew |
|---|---|---|---|---|---|
READ UNCOMMITTED |
Behaves like READ COMMITTED |
No | Yes | Yes | Yes |
READ COMMITTED (default) |
A new snapshot on every statement | No | Yes | Yes | Yes |
REPEATABLE READ |
Snapshot isolation: one snapshot for the whole transaction | No | No | No | Yes |
SERIALIZABLE |
Serializable snapshot isolation (SSI) | No | No | No | No |
Three readings of this table:
- PostgreSQL is stricter than the standard at
REPEATABLE READ: it forbids read phantoms, which the standard permits. - The default level is
READ COMMITTED, which permits three of the four phenomena. It is not an oversight: it is a deliberate balance between correctness and performance, and it means that the isolation level your application is running at today, if nobody has touched it, is the second weakest. - The jump in guarantee is between
REPEATABLE READandSERIALIZABLE, and it is the most expensive one:SERIALIZABLEis the only level that eliminates write skew, and it does so by aborting transactions.
The crucial difference between READ COMMITTED and REPEATABLE READ
It lies in when the snapshot is taken:
READ COMMITTED |
REPEATABLE READ |
|
|---|---|---|
| Moment of the snapshot | At the start of every statement | At the start of the transaction's first statement |
Two identical SELECTs in a row |
May give different results | Always give the same |
| Write conflict | Waits and retries against the new version | Aborts with a serialization error |
| Needs retry logic | No | Yes |
That "write conflict" row is the one that surprises people in production. Under REPEATABLE READ:
| Step | Session A | Session B |
|---|---|---|
| t1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| t2 | SELECT amount FROM fines WHERE fine_id=4102; → 12.40 |
|
| t3 | UPDATE fines SET amount=3.50 WHERE fine_id=4102; (committed) |
|
| t4 | UPDATE fines SET amount=amount-1 WHERE fine_id=4102; |
A's transaction is left aborted and has to be retried in full. It is not a failure: it is the management system refusing to produce an anomaly. But if your application does not know how to retry, the user sees an error.
- How the level is set and what PostgreSQL really does
Three ways, from most local to most global:
-- 1) For one specific transaction (the preferable form)
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ...
COMMIT;
-- 2) Equivalent, right after the BEGIN
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;
-- 3) For the whole session (affects subsequent transactions)
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;Checking the current level:
You can also change the server default in postgresql.conf with default_transaction_isolation, but it is not advisable: it makes the application's behavior depend on a file that is probably not in the same repository as the code. The level is a decision of the code, and it should be visible in the code.
Read-only mode and deferrable transactions
Two modifiers useful for reports:
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
-- long queries for management's monthly report
COMMIT;READ ONLYprevents writes and allows the management system some optimizations.DEFERRABLE, combined withSERIALIZABLE READ ONLY, makes the transaction wait until it can take a snapshot that guarantees it will never abort due to serialization. It is ideal for a long nightly report: it may take a while to get going, but it will not fail halfway after twenty minutes of work.
How to choose the level: a practical guide
| Type of operation in BiblioRed | Recommended level |
|---|---|
| Standalone web catalog queries | READ COMMITTED (default) |
Registering a loan with UPDATE ... WHERE status='available' |
READ COMMITTED + check affected rows |
| Multi-query management report | REPEATABLE READ READ ONLY |
| Registration for an event with limited capacity | SERIALIZABLE with retry, or a counter with a constraint (section 15) |
| Cancelling a speaker while respecting "at least one" | SERIALIZABLE with retry |
| Nightly accounting close process | SERIALIZABLE |
- Lock-based concurrency control
Historically, the first answer to the concurrency problem was the lock: if a transaction is going to use a piece of data, it reserves it and everybody else waits.
Shared and exclusive locks
| Type | Symbol | Taken in order to | Compatible with shared | Compatible with exclusive |
|---|---|---|---|---|
Shared (read, S) |
S |
Read | Yes | No |
Exclusive (write, X) |
X |
Modify | No | No |
The idea is intuitive: many can read at once, but writing requires exclusivity. The compatibility table reads like this: if A holds an S lock on row 3081 and B asks for another S, B goes through. If B asks for X, B waits.
Granularity
A lock can be taken on units of different sizes, and there is a clear trade-off:
| Granularity | Concurrency | Management cost | Who uses it |
|---|---|---|---|
| Row | Maximum | High (many locks to record) | PostgreSQL, Oracle, InnoDB |
| Page (disk block) | Medium | Medium | SQL Server (with escalation) |
| Table | Low | Low | DDL operations, LOCK TABLE |
| Database / file | None for writing | Minimal | SQLite |
Some management systems practice lock escalation: if a transaction accumulates too many row locks, they replace them with a table lock to save memory, with the side effect of blocking everybody. PostgreSQL does not escalate locks: it stores the lock mark in the row itself, so it can hold millions without spending server memory. It is a notable practical difference.
Two-phase locking
The protocol that guarantees serializability through locks is called two-phase locking (2PL), and its rule is deceptively simple:
A transaction has a growing phase, in which it can only acquire locks, and a shrinking phase, in which it can only release them. Once it has released the first lock, it cannot acquire any more.
In practice, almost all management systems use strict 2PL: exclusive locks are held until the COMMIT or the ROLLBACK. That guarantees nobody reads uncommitted changes and simplifies recovery.
And it explains the biggest operational consequence of all this: the longer a transaction lasts, the longer it holds its locks and the more people wait. It is the technical justification for the "short transactions" rule from lesson 06-01.
The price of 2PL is that transactions wait for one another, and that is where the deadlocks of section 13 come from.
PostgreSQL's table lock modes
To complete the picture, PostgreSQL has eight table-level lock modes. You do not have to memorize them, but you should know they exist and when it takes them:
| Mode | Taken by | Main conflict |
|---|---|---|
ACCESS SHARE |
SELECT |
Only with ACCESS EXCLUSIVE |
ROW SHARE |
SELECT ... FOR UPDATE |
With EXCLUSIVE and above |
ROW EXCLUSIVE |
INSERT, UPDATE, DELETE |
With SHARE and above |
SHARE UPDATE EXCLUSIVE |
VACUUM, CREATE INDEX CONCURRENTLY |
With itself and above |
SHARE |
CREATE INDEX (without CONCURRENTLY) |
With writes |
ACCESS EXCLUSIVE |
ALTER TABLE, DROP TABLE, TRUNCATE |
With everything, including SELECT |
That last row is the cause of half the service outages during deployments: an ALTER TABLE waiting behind a long query, and the whole request queue waiting behind the ALTER TABLE, including the SELECTs that worked a moment ago. Viewing the locks in progress:
SELECT pid, wait_event_type, state, left(query, 60) AS query
FROM pg_stat_activity
WHERE datname = 'biblioredb' AND state <> 'idle';pid | wait_event_type | state | query -------+-----------------+--------+------------------------------------------------- 41207 | | active | ALTER TABLE loans ADD COLUMN notes TEXT 41255 | Lock | active | SELECT count(*) FROM loans WHERE return_date
The wait_event_type = Lock on the second row is the unmistakable signature of "I am waiting for somebody else".
- Explicit locks:
FOR UPDATE, FOR SHARE, LOCK TABLE, SKIP LOCKED
FOR UPDATE, FOR SHARE, LOCK TABLE, SKIP LOCKEDBesides the automatic locks, SQL lets you ask for them by hand. It is the tool of pessimistic locking (section 14).
SELECT ... FOR UPDATE
Locks the rows read as if they were about to be modified. Any other transaction that tries to modify them —or lock them— waits.
Row 3081 is now reserved. Reproduction with two sessions:
| Step | Session A | Session B |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | SELECT status FROM copies WHERE copy_id=3081 FOR UPDATE; → available |
|
| t3 | BEGIN; |
|
| t4 | SELECT status FROM copies WHERE copy_id=3081 FOR UPDATE; → it waits |
|
| t5 | UPDATE copies SET status='on_loan' WHERE copy_id=3081; |
(still waiting) |
| t6 | COMMIT; |
→ returns on_loan |
And there is the key: when B finally gets the row, it sees it with the new value. Its "is it available?" check is now correct, and it will reject the loan. This resolves the lost update of section 3 cleanly.
SELECT ... FOR SHARE
Shared lock: it prevents others from modifying the rows, but allows others to read them with FOR SHARE. It is used when you need to guarantee that a row does not change while you work with related data, without intending to modify it yourself.
BEGIN;
-- We guarantee that the member is not deregistered while we record their loan
SELECT active FROM members WHERE member_id = 14 FOR SHARE;
INSERT INTO loans (...) VALUES (14, 3081, ...);
COMMIT;There are also two gentler variants, FOR NO KEY UPDATE and FOR KEY SHARE, which PostgreSQL uses internally for foreign keys and which allow more concurrency. Knowing they exist is enough.
LOCK TABLE
Locks the whole table. It is blunt and almost always disproportionate.
Its legitimate use is the nightly maintenance process that needs a table to sit still, or the step of a migration that reorganizes data. On the path of a user operation, never.
NOWAIT and SKIP LOCKED
Two modifiers that change what happens when the row is taken:
| Modifier | Behavior if the row is locked |
|---|---|
| (nothing) | Waits indefinitely |
NOWAIT |
Fails immediately with an error |
SKIP LOCKED |
Skips that row and returns the others |
NOWAIT is for giving the user a fast answer instead of leaving them waiting:
The application translates that error into "another front desk is handling this copy right now, please try again", which is infinitely better than a frozen screen.
SKIP LOCKED is the basis of work queues. BiblioRed has a process that sends the due-date notices; with several workers in parallel, each one must take different notices:
BEGIN;
SELECT notice_id, member_id
FROM pending_notices
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- ...send the notices, mark them as sent...
COMMIT;Each worker gets ten notices that no other one is processing, with no waiting and no duplicates. It is a pattern that replaces a message queue in many medium-sized systems, and it works surprisingly well.
- Multiversion concurrency control (MVCC) and why
VACUUM exists
VACUUM existsLocking has a serious flaw: if writing requires exclusivity, readers get in the writers' way and vice versa. Management's monthly report, which scans three million loans, would block the front desk for its entire run.
The solution adopted by PostgreSQL, Oracle and InnoDB is multiversion concurrency control.
MVCC. The management system does not overwrite the data: every modification creates a new version of the row. Each transaction sees the version that was visible at the moment of its snapshot. That way, readers never block writers nor writers readers.
How it works in PostgreSQL
Every physical row carries two hidden columns:
| Hidden column | Meaning |
|---|---|
xmin |
Identifier of the transaction that created this version |
xmax |
Identifier of the transaction that deleted or replaced it (0 if it is still current) |
You can see them:
ctid | xmin | xmax | copy_id | status --------+-------+------+---------+----------- (12,7) | 90114 | 0 | 3081 | available
Now an UPDATE, and we look again:
UPDATE copies SET status = 'on_loan' WHERE copy_id = 3081;
SELECT ctid, xmin, xmax, status FROM copies WHERE copy_id = 3081;UPDATE 1 ctid | xmin | xmax | status ---------+-------+------+--------- (12,41) | 90118 | 0 | on_loan
The ctid —the row's physical address— has changed from (12,7) to (12,41). The row has not been modified: a new one has been written somewhere else, and the old one has been marked with xmax = 90118. An UPDATE in PostgreSQL is, physically, an INSERT plus a marking of the previous version.
When a transaction reads, it applies a simple rule: a version is visible if its xmin corresponds to a transaction committed before my snapshot and its xmax is zero or corresponds to a transaction not committed in my snapshot.
The bill: dead tuples
This design has an unavoidable consequence. After a while in operation, the copies table contains the current version of every row and all the old versions that nobody can see any more. They are called dead tuples.
Dead tuples cost in three ways:
- Disk space, which grows without stopping.
- Read time: a scan of the table also reads the dead tuples and discards them one by one.
- Consumption of transaction identifiers, which are finite.
To see it:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname IN ('copies','loans');relname | n_live_tup | n_dead_tup | last_autovacuum ---------+------------+------------+------------------------------- copies | 40000 | 1842 | 2026-08-02 11:20:14.331+02 loans | 2841077 | 412903 | 2026-08-02 06:02:51.882+02
VACUUM: who pays the bill
VACUUMscans the tables and marks as reusable the space of the dead tuples that no transaction can see any more.
INFO: vacuuming "biblioredb.public.copies" INFO: finished vacuuming: removed 1842 dead row versions in 96 pages INFO: analyzing "biblioredb.public.copies" VACUUM
Variants:
| Statement | What it does | Does it lock? |
|---|---|---|
VACUUM table |
Marks the dead space as reusable | No |
VACUUM FULL table |
Rewrites the whole table compacting it and returns space to the system | Yes, ACCESS EXCLUSIVE: it blocks everything |
ANALYZE table |
Recomputes statistics for the planner (the subject of 06-03) | No |
Under normal conditions you do not have to run it by hand: the autovacuum process does it on its own. But you have to know what happens if it does not run:
- Bloat: the table takes up several times what it should and queries slow down progressively. A
loanstable with 2 GB of useful data can end up taking 9 GB. - Stale statistics, and with them bad execution plans (06-03).
- Transaction identifier exhaustion: PostgreSQL uses a 32-bit counter. If
VACUUMdoes not "freeze" the old rows in time, the server stops completely to avoid data loss, with an unforgettable message:database is not accepting commands to avoid wraparound data loss. It is one of the few ways to take a PostgreSQL database out of service through operational neglect.
VACUUM's number one enemy is long transactions. A transaction open for three hours forces all the dead tuples created since then to be kept, because in theory that transaction might need them. It is another reason —the third by now— for transactions to be short. Spotting the culprits:
SELECT pid, state, now() - xact_start AS duration, left(query,50) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 3;pid | state | duration | query -------+---------------------+-----------------+---------------------------------------- 39104 | idle in transaction | 03:12:47.220188 | SELECT * FROM loans WHERE member_id 41207 | active | 00:00:00.003912 | SELECT pid, state, now() - xact_start
That three-hour idle in transaction is exactly the pattern lesson 06-01 asked you to avoid with idle_in_transaction_session_timeout.
- Deadlocks: how they arise, how they are detected, how they are avoided
Deadlock. Two or more transactions wait for each other in a cycle: A waits for a resource B holds, and B waits for a resource A holds. Without outside intervention, they would wait forever.
Triggering one
The classic case: two transactions that touch the same two rows in reverse order. In BiblioRed, a transfer of copies between the Central and North branches, run at the same time in both directions.
| Step | Session A (Central → North) | Session B (North → Central) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | BEGIN; |
|
| t3 | UPDATE copies SET branch_id=2 WHERE copy_id=3081; → UPDATE 1 |
|
| t4 | UPDATE copies SET branch_id=1 WHERE copy_id=3095; → UPDATE 1 |
|
| t5 | UPDATE copies SET branch_id=2 WHERE copy_id=3095; → waits for B |
|
| t6 | UPDATE copies SET branch_id=1 WHERE copy_id=3081; → waits for A |
|
| t7 | (after ~1 second) → ERROR | (continues normally) |
In Session A:
ERROR: deadlock detected
DETAIL: Process 41207 waits for ShareLock on transaction 90231; blocked by process 41255.
Process 41255 waits for ShareLock on transaction 90230; blocked by process 41207.
HINT: See server log for query details.
CONTEXT: while updating tuple (12,41) in relation "copies"The wait-for graph the management system has built:
graph LR
A["Session A<br/>pid 41207<br/>holds: row 3081"] -->|waits for row 3095| B["Session B<br/>pid 41255<br/>holds: row 3095"]
B -->|waits for row 3081| A
A cycle. When the detector finds a cycle, it picks a victim transaction —usually the one that has done least work— and aborts it. The other carries on and finishes fine.
How PostgreSQL detects it
It does not check for the cycle on every wait: that would be extremely expensive. When a transaction has been waiting for longer than deadlock_timeout (1 second by default), and only then, it builds the graph and looks for cycles.
Consequence: a deadlock costs at least one second before it is resolved. On a system with many deadlocks, that alone is a performance problem.
To investigate them, it is worth turning on logging of slow lock waits:
How to avoid them
| Technique | What it consists of | Effectiveness |
|---|---|---|
| Always order accesses the same way | If several transactions touch several rows, have them all touch them in the same order (for example, copy_id ascending) |
By far the most effective |
| Short transactions | Less time holding locks, a smaller collision window | High |
| Take the locks up front | Lock everything you need at the start, rather than asking as you go | Medium-high |
| Reduce the granularity | Lock rows, not tables | Medium |
| Retry | Accept that they will happen and retry the transaction | Essential as a safety net |
The first is the fundamental one, and it is easy to apply. The copy transfer rewritten:
BEGIN;
-- Always lock in ascending order of identifier, whatever the direction of the transfer
SELECT copy_id FROM copies
WHERE copy_id IN (3081, 3095)
ORDER BY copy_id
FOR UPDATE;
UPDATE copies SET branch_id = 2 WHERE copy_id = 3081;
UPDATE copies SET branch_id = 1 WHERE copy_id = 3095;
COMMIT;With both sessions taking the locks in the same order, the cycle is impossible: the second waits for the first and finishes afterwards. There is waiting, but there is no deadlock.
And about retries: a deadlock is identified by SQLSTATE 40P01, and a serialization failure by 40001. Both are transient and retryable. A serious application catches them and retries with an increasing wait and some randomness, up to a maximum of three or five attempts.
# Outline of the retry pattern (pseudocode)
for attempt in range(5):
try:
with connection.transaction():
register_member(event_id=51, member_id=14)
break
except SerializationFailure: # 40001
wait(0.05 * 2**attempt + random(0, 0.05))
except DeadlockDetected: # 40P01
wait(0.05 * 2**attempt + random(0, 0.05))
else:
log_incident("Could not register after 5 attempts")
- Optimistic versus pessimistic locking
The two general strategies for protecting a read-modify-write sequence. The difference lies in the starting assumption.
| Pessimistic | Optimistic | |
|---|---|---|
| Assumption | There will be a conflict | There will be no conflict |
| Mechanism | Lock on read (FOR UPDATE) |
Detect the change on write |
| Cost with no conflict | Always paid (waits, locks) | Almost none |
| Cost with a conflict | Waiting | The work is lost and has to be redone |
| Risk | Deadlocks, long waits | Retries, starvation under heavy contention |
| Suitable for | High contention, short transactions | Low contention, or when a human is thinking in the middle |
Implementing optimistic locking with a version column
It is the standard pattern. You add a column to the table that is incremented on every modification, and the update is only applied if the version is still the one that was read.
The flow, applied to editing a BiblioRed event from the management panel:
-- Step 1: read (WITHOUT an open transaction; the manager may take minutes to decide)
SELECT event_id, title, offered_seats, version
FROM events WHERE event_id = 51; event_id | title | offered_seats | version
----------+---------------------+---------------+---------
51 | Autumn reading club | 25 | 7-- Step 2: save, demanding that nobody has touched anything meanwhile
UPDATE events
SET offered_seats = 30,
version = version + 1
WHERE event_id = 51
AND version = 7;If nobody has modified the event:
If another manager modified it while our user was thinking:
And that UPDATE 0 is the whole detection. The application shows "another user has modified this event; review the changes and save again" instead of silently overwriting somebody else's work.
This pattern solves the problem from section 15.2 of lesson 06-01: there is no open transaction while the human decides. The transaction lasts as long as an UPDATE.
To automate the increment so that nobody forgets it, a trigger —of the kind we saw briefly in 05-04:
CREATE FUNCTION increment_version() RETURNS TRIGGER AS $$
BEGIN
NEW.version := OLD.version + 1;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_events_version
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION increment_version();Careful: with the trigger, the application must no longer write version = version + 1 in its UPDATE, but it must still put AND version = ? in the WHERE. The condition is the protection; the increment is just bookkeeping.
When to choose each one
- Optimistic if there is a human being between the read and the write, or if conflicts are rare (editing member records, cataloging materials, managing events). It is the default in web applications.
- Pessimistic if contention is high and conflict is the norm (lending the last copy at peak hour), or if redoing the work is expensive.
- The complete solution: the last seat in the reading club
Let us go back to the problem from section 6 and really solve it. State: event 51, 25 seats, 24 occupied, two members signing up at the same time.
There are three correct approaches. All three work; they are not equivalent.
Approach 1: the constraint in the database
The idea: turn capacity into a piece of data held in a single row, with a declared constraint. That way the conflict stops being a phantom and becomes a collision on the same row, which the management system knows how to resolve.
-- Denormalized counter (with the discipline of 05-04) and its constraint
ALTER TABLE events ADD COLUMN occupied_seats_total INTEGER NOT NULL DEFAULT 0;
ALTER TABLE events ADD CONSTRAINT chk_capacity
CHECK (occupied_seats_total >= 0
AND occupied_seats_total <= offered_seats);And the registration:
BEGIN;
-- The increment and the check happen in the same atomic statement
UPDATE events
SET occupied_seats_total = occupied_seats_total + 1
WHERE event_id = 51;
INSERT INTO registrations (event_id, member_id, registration_date, status, companions, occupied_seats)
VALUES (51, 14, now(), 'confirmed', 0, 1);
COMMIT;With two simultaneous sessions:
| Step | Session A (Marta, 14) | Session B (Iván, 15) |
|---|---|---|
| t1 | BEGIN; |
BEGIN; |
| t2 | UPDATE events SET occupied_seats_total = occupied_seats_total + 1 WHERE event_id=51; → UPDATE 1 |
|
| t3 | same UPDATE → waits (the row is locked by A) |
|
| t4 | INSERT INTO registrations ...; COMMIT; |
(still waiting) |
| t5 | the UPDATE is re-evaluated against the new version (24→25) and fails |
In Session B:
ERROR: new row for relation "events" violates check constraint "chk_capacity" DETAIL: Failing row contains (51, Autumn reading club, ..., 25, 26).
Going above 25 is impossible. It does not matter what the isolation level is, it does not matter what the client is, it does not matter if tomorrow somebody writes a script that inserts by hand: the constraint is in the database and it is always enforced.
Two technical details worth understanding:
- The
UPDATE ... SET x = x + 1is not an application-level read-modify-write: the management system locks the row, reads the current value and writes. UnderREAD COMMITTED, when B is unblocked it re-evaluates itsUPDATEagainst the most recent version, so it adds to 25 and not to 24. - Under
REPEATABLE READ, instead of theCHECKerror, B would getcould not serialize access due to concurrent update. Also correct, but it requires a retry.
Cost: the event's row becomes a serialization point. All registrations for that event queue up on it. For a 25-seat reading club that is irrelevant; for selling 60,000 tickets in two minutes it would be a bottleneck.
Approach 2: explicit pessimistic locking
The idea: lock the event's row before counting, so that only one transaction at a time can be deciding about that event.
BEGIN;
-- Lock the event: it materializes the conflict on one specific row
SELECT offered_seats FROM events WHERE event_id = 51 FOR UPDATE;
-- Now the count IS reliable: nobody else can be here
SELECT coalesce(sum(occupied_seats), 0) AS occupied
FROM registrations WHERE event_id = 51 AND status = 'confirmed';
-- if occupied < offered_seats:
INSERT INTO registrations (event_id, member_id, registration_date, status, companions, occupied_seats)
VALUES (51, 14, now(), 'confirmed', 0, 1);
COMMIT;Session B waits on its FOR UPDATE until A's COMMIT, then counts 25, sees there is no room and rejects cleanly.
Advantages: it requires no new columns or denormalization, and the capacity logic (which can be complex: companions, seats reserved for school groups, waiting list) stays in one place.
Drawbacks: the protection lives in the application code. If another program, another team or a maintenance script inserts into registrations without taking the lock, the guarantee disappears without anybody noticing. And you have to remember always to lock the same row and in the same order relative to other locks, or the deadlocks of section 13 come back.
Approach 3: SERIALIZABLE with retry
The idea: ask the management system for the full guarantee and let it detect the conflict.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT coalesce(sum(occupied_seats), 0)
FROM registrations WHERE event_id = 51 AND status = 'confirmed';
INSERT INTO registrations (event_id, member_id, registration_date, status, companions, occupied_seats)
VALUES (51, 14, now(), 'confirmed', 0, 1);
COMMIT;With the two sessions interleaved as in section 6, the first to commit succeeds and the second receives, at the 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.
PostgreSQL's SSI engine has detected that B read a set of rows that A modified, and that the combined result corresponds to no serial execution. It aborts B.
Advantages: it is the only solution that resolves all the phenomena at once, including the write skew of section 7. The application code is written as if there were no concurrency, which is enormously easier to reason about.
Drawbacks:
- It forces you to implement retries. Without them, the user sees a cryptic error.
- It has a cost: the management system tracks the read/write dependencies of every transaction.
- The error arrives at the
COMMIT, when all the work has already been done. - And, like approach 2, it does not protect against a script that uses another isolation level.
Comparison and recommendation
| Criterion | 1. Constraint in the database | 2. Pessimistic locking | 3. SERIALIZABLE |
|---|---|---|---|
| Does it protect against any client? | Yes | No | No |
| Does it require a schema change? | Yes (counter + CHECK) |
No | No |
| Does it require retries? | No (under READ COMMITTED) |
No | Yes |
| Does it resolve write skew? | Only the modeled case | Only if you lock properly | Yes, in general |
| Concurrency | Serializes on one row | Serializes on one row | Maximum as long as there is no conflict |
| Maintenance cost | A denormalized counter to maintain | Discipline throughout the code | Retry logic |
| Clarity of the error | Excellent (chk_capacity) |
Good | Cryptic without translation |
Reasoned recommendation for BiblioRed: approach 1, complemented with approach 3 where necessary.
The decisive argument is the one in the first row. A business rule as hard as "no more than 25 people fit" cannot depend on every program that touches the database, today and five years from now, remembering to take a lock or ask for an isolation level. The chk_capacity constraint is in the schema, it is always enforced, and on top of that it documents itself: anybody reading the definition of events will see the rule written down. It is the same argument as section 21 of lesson 04-04 about which rules belong in the database.
The occupied_seats_total counter is a denormalization —it has to be kept consistent with registrations, with the precautions of 05-04, including the periodic check that it adds up—, and that is its price. It is paid gladly.
And for the rules that cannot be expressed as a single-row constraint —the "at least one speaker" of section 7, which spans several rows of another table— you use approach 3 with retry, because it is the only one that covers them.
- Outside PostgreSQL: SQLite and the return of the problem in NoSQL
SQLite
Concurrency is the biggest difference between SQLite and a database server, and it is worth being clear about it so as not to choose badly.
| Aspect | SQLite |
|---|---|
| Simultaneous writers | Only one across the whole file |
| Default mode (rollback journal) | A writer also excludes readers |
WAL mode (PRAGMA journal_mode=WAL) |
Readers keep working during the write; there is still a single writer |
| Isolation levels | In practice, SERIALIZABLE: there is no write interleaving to protect against |
| Contention error | SQLITE_BUSY (database is locked) |
| Mitigation | PRAGMA busy_timeout = 5000; and BEGIN IMMEDIATE |
BEGIN IMMEDIATE deserves a note: in SQLite, a normal BEGIN is deferred and does not take the write lock until the first write, which produces the classic "I read, I decide, I write and I am told the database is busy" with the work already done. If the transaction is going to write, start it with BEGIN IMMEDIATE and you will take the lock from the outset.
The practical conclusion: SQLite is excellent with one writer and many readers. For BiblioRed's four front desks writing at the same time, no. For the inventory application on a tablet, perfect.
The problem does not disappear in NoSQL: it changes shape
You will remember from 03-04 that eventual consistency is the price of availability and partition tolerance. What is worth adding here is that eventual consistency does not eliminate the last-seat problem: it makes it worse.
| Scenario | In PostgreSQL | In an eventually consistent distributed system |
|---|---|---|
| Two simultaneous registrations | A constraint or an isolation level orders them | They may be applied on different nodes that do not know about each other yet |
| Conflict detection | On the spot, with an error | Afterwards, at reconciliation time |
| Resolution | The transaction fails and is retried | "Last writer wins", or a merge function you have to program |
The tools change name but respond to the same ideas:
- The read and write quorum (
w+r>n) is a way of buying consistency in a distributed system, analogous to raising the isolation level. - MongoDB's
writeConcern: {w: "majority"}is the explicit request for that guarantee. - Atomic document operations (
$inc,findAndModifywith a condition) are the equivalent of approach 1'sUPDATE ... SET x = x + 1 WHERE ...: the check and the write in a single indivisible operation.
// Conceptual equivalent of approach 1 in MongoDB
db.events.findOneAndUpdate(
{ _id: 51, occupied_seats_total: { $lt: 25 } },
{ $inc: { occupied_seats_total: 1 } }
)
// Returns null if nobody else fitted: there is the detectionThe underlying lesson, worth taking away: concurrency is not a problem of relational databases, it is a problem of reality. Changing technology does not eliminate it; it changes the tools you face it with and, almost always, shifts more responsibility onto the application code.
Common Mistakes and Tips
Counting before inserting and believing that protects you. It is this lesson's mistake. A whole transaction fits between the SELECT count(*) and the INSERT. If the rule is hard, express it as a constraint.
Not checking the number of affected rows. An UPDATE ... WHERE status='available' that returns UPDATE 0 is saying "somebody got ahead of me". Ignoring it turns a perfect protection into decoration.
Using REPEATABLE READ without retry logic. At READ COMMITTED a write conflict waits and carries on; at REPEATABLE READ it aborts. Raising the level without retries swaps a silent error for a noisy one, which is better, but it is still an error the user sees.
Believing SERIALIZABLE is "the safe level and that is it". It is safe and it obliges you to retry. Without retries it is not safer: it just fails more.
Assuming the default level is the strictest. It is READ COMMITTED, the second weakest. Check it with SHOW transaction_isolation; before reasoning about anything.
Keeping a transaction open while a human decides. It locks rows, prevents VACUUM and fixes nothing that optimistic locking does not fix better.
Accessing the same rows in different orders in different parts of the code. It is the recipe for a deadlock. Adopt a canonical order —by ascending primary key— and respect it everywhere.
Diagnosing a deadlock as a database failure. It is not: the database did exactly what it should by detecting it and breaking the cycle. The failure is in the code's access order.
Ignoring VACUUM until it hurts. Keep an eye on n_dead_tup and last_autovacuum in pg_stat_user_tables, and chase down long idle in transaction transactions, which are the usual reason autovacuum cannot do its job.
Using VACUUM FULL during service hours. It takes an ACCESS EXCLUSIVE lock: it blocks even SELECTs. It is a maintenance-window operation.
Final tip: always reproduce the failure before fixing it. Every phenomenon in this lesson can be triggered with two psql terminals in under a minute. A concurrency bug you cannot reproduce is a bug you do not know whether you have fixed.
Exercises
Exercise 1: Identify the phenomenon and propose the defense
For each of these three real BiblioRed situations, state which phenomenon of the five studied is occurring, why the READ COMMITTED level does not prevent it, and what the most appropriate defense is.
(a) Management's monthly report shows "312 overdue loans" in the headline and, three pages further down, a breakdown by branch that adds up to 315.
(b) The nightly process that moves little-used copies from Central to South, and another one that brings them from South to Central, hang and one of the two dies with an error after a second.
(c) The East branch must always keep at least two copies of material 907 (school reading list). It has three. Two librarians simultaneously process a transfer of one copy each to other branches; both check that two would be left and both confirm. In the end one is left.
Exercise 2: Reproduce and fix the lost update
Using two psql terminals, reproduce the double loan of copy EJ-3081 from section 3. Then rewrite the loan operation so that it is impossible for two front desks to lend the same copy, without changing the isolation level and without adding new columns. Write the complete transaction and state what the application must check and what message it must show the operator.
Exercise 3: Choose the capacity approach
The library wants to allow companions at events: a member can sign up with up to 3 companions, and the registrations.occupied_seats column records the total number of seats that registration consumes (1 + companions). In addition, every event reserves 5 of its seats for school groups, which members cannot take.
With these two new rules, decide which of the three approaches from section 15 you would use, justify it, and write the SQL of the solution.
Solutions
Solution 1
(a) Non-repeatable read.
The report runs two different queries within the same session. Under READ COMMITTED, each statement takes a new snapshot, so the second query sees three loans that fell due —or that were registered— between one and the other. Each figure is correct at its instant; together they are incoherent.
Defense: wrap the report in a transaction with a stable snapshot.
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM loans WHERE return_date IS NULL
AND due_date < CURRENT_DATE;
SELECT c.branch_id, count(*) FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
WHERE l.return_date IS NULL AND l.due_date < CURRENT_DATE
GROUP BY c.branch_id;
COMMIT;For a long report, SERIALIZABLE READ ONLY DEFERRABLE is even better: it guarantees it will not abort halfway.
(b) Deadlock.
Two transactions that update the same set of copies in reverse order. READ COMMITTED has nothing to do with it: deadlocks are independent of the isolation level, because they arise from the order in which locks are acquired, not from visibility. The error after a second is exactly deadlock_timeout doing its job.
Defense: a canonical access order. Have both processes lock the affected copies with SELECT ... FOR UPDATE ... ORDER BY copy_id at the start of the transaction, whatever the direction of the transfer. And retry on 40P01 as a safety net.
(c) Write skew.
The two librarians read the same set (the three copies of material 907 in East), each decides they can move a different one, and each writes to a different row. There is no row conflict, so no row-based mechanism detects it. Neither READ COMMITTED nor REPEATABLE READ prevents it: it is the phenomenon of section 7.
Defense, in order of preference:
- Materialize the conflict: lock a common row before deciding, for example the material's or the branch's.
BEGIN; SELECT 1 FROM materials WHERE material_id = 907 FOR UPDATE; SELECT count(*) FROM copies WHERE material_id = 907 AND branch_id = 4 AND status <> 'withdrawn'; -- if count > 2, transfer COMMIT; SERIALIZABLEwith retry, which detects it in general without having to anticipate the case.
A CHECK is no use here: the rule counts rows in another table, and that is outside what a declarative row constraint can express.
Solution 2
The reproduction is the table from section 3. The solution without changing the level or the schema, with pessimistic locking on the copy:
BEGIN;
-- 1) Lock the copy and read its real status
SELECT status
FROM copies
WHERE copy_id = 3081
FOR UPDATE;
-- If it returns 'available' → carry on. If it returns anything else → ROLLBACK and warn.
-- 2) Register the loan
INSERT INTO loans (member_id, copy_id, loan_date, due_date)
VALUES (14, 3081, CURRENT_DATE, CURRENT_DATE + 21);
-- 3) Mark the copy
UPDATE copies SET status = 'on_loan' WHERE copy_id = 3081;
COMMIT;The second session waits on the FOR UPDATE until the first one's COMMIT, and then reads on_loan, so it aborts with ROLLBACK.
An even better variant, which does not even require the application to check anything beforehand:
BEGIN;
UPDATE copies
SET status = 'on_loan'
WHERE copy_id = 3081
AND status = 'available';
-- The application checks the affected rows: if 0 → ROLLBACK
INSERT INTO loans (member_id, copy_id, loan_date, due_date)
VALUES (14, 3081, CURRENT_DATE, CURRENT_DATE + 21);
COMMIT;In the losing session:
What the application must check: the number of rows affected by the UPDATE. If it is 0, run ROLLBACK.
What it must show the operator: a message that reflects reality and tells them what to do. For example: "Copy EJ-3081 is no longer available: another front desk lent it a few seconds ago. Check other copies of the same title or create a reservation." No "Database error" and no numeric codes: the person at the front desk has a member standing in front of them.
An additional note: the second variant is preferable to the first because it does the check and the write in a single statement, holds the lock for less time and does not depend on the application remembering to compare the status it read.
Solution 3
Approach chosen: number 1, the constraint in the database, adapted to the two new rules.
Justification: the two new rules —seats for companions and the school reservation— make the capacity logic more complex, and therefore easier to get wrong somewhere in the code. The more complicated a rule is, the more arguments there are for it to live in a single place and not in every program that inserts registrations. Besides, both rules are still expressed over values in a single row of events, which is exactly what a CHECK can verify.
-- Column for the seats reserved for school groups
ALTER TABLE events
ADD COLUMN school_reserved_seats INTEGER NOT NULL DEFAULT 0;
-- Counter of seats consumed by members
ALTER TABLE events
ADD COLUMN occupied_seats_total INTEGER NOT NULL DEFAULT 0;
-- The complete capacity rule, in a single named constraint
ALTER TABLE events ADD CONSTRAINT chk_capacity_members
CHECK (occupied_seats_total >= 0
AND occupied_seats_total <= offered_seats - school_reserved_seats);
-- Consistency of the school reservation
ALTER TABLE events ADD CONSTRAINT chk_school_reservation
CHECK (school_reserved_seats BETWEEN 0 AND offered_seats);
-- Limit on companions, in the registration itself
ALTER TABLE registrations ADD CONSTRAINT chk_companions
CHECK (companions BETWEEN 0 AND 3);
ALTER TABLE registrations ADD CONSTRAINT chk_occupied_seats
CHECK (occupied_seats = companions + 1);Marta's registration with two companions:
BEGIN;
UPDATE events
SET occupied_seats_total = occupied_seats_total + 3 -- her + 2 companions
WHERE event_id = 51;
INSERT INTO registrations (event_id, member_id, registration_date, status, companions, occupied_seats)
VALUES (51, 14, now(), 'confirmed', 2, 3);
COMMIT;If the event has 25 seats, 5 reserved for schools and 18 already occupied by members, the operation fails because 18 + 3 > 20:
ERROR: new row for relation "events" violates check constraint "chk_capacity_members" DETAIL: Failing row contains (51, Autumn reading club, ..., 25, 5, 21).
Observations about the solution:
chk_occupied_seatsguarantees that the denormalizedoccupied_seatscolumn cannot drift away fromcompanions. It is the discipline of 05-04 applied: a computed column must carry its check alongside it. In fact, here it would be even better to declare it as a generated column (GENERATED ALWAYS AS (companions + 1) STORED), as we saw in 04-04.- The name of the constraint matters:
chk_capacity_membersappears literally in the error message, and the application can translate it into "There are not enough seats left for you and your companions". - A periodic check that
occupied_seats_totalmatches the real sum of confirmedregistrationsis still necessary, exactly as explained in 05-04 for summary tables. - If in the future a rule appeared that spanned several rows of other tables —for example, "a member cannot be registered for two overlapping events"—, that would no longer fit in a
CHECK, and you would have to go to approach 3 withSERIALIZABLEand retry. It is worth knowing where that border lies.
Conclusion
This lesson has demonstrated something uncomfortable: code that is correct for one user can be broken code for two. The three faults we started with —the copy lent twice, the 26 people in a club with 25 chairs, the published event with no speakers— did not come from programming errors in the usual sense. They came from an implicit assumption the real world does not respect: that nothing happens between reading and writing.
We have triggered the five phenomena with two terminals and understood them from the inside out. The lost update, whose cheapest defense is to put the condition inside the UPDATE itself and look at how many rows were affected. The dirty read, which in PostgreSQL simply cannot happen. The non-repeatable read, which ruins every report with more than one query and is cured with one line. The phantom read, which teaches the most important lesson of all: counting before inserting protects you from nothing, because you cannot lock a row that does not exist yet. And write skew, which happens at high isolation levels, is detected by no row-based mechanism, and is only solved with SERIALIZABLE or by materializing the conflict by hand.
We have seen the canonical table of the four levels and —more useful still— the table of what PostgreSQL actually does: that it has no real READ UNCOMMITTED, that its REPEATABLE READ is snapshot isolation and forbids phantoms the standard permits, that its default level is the second weakest, and that the real jump in guarantee is at SERIALIZABLE, which is paid for with aborted transactions and therefore with mandatory retry logic.
Then we went down into the machinery: shared and exclusive locks, granularity and the fact that PostgreSQL locks rows and never escalates; two-phase locking, which explains why a long transaction is a collective problem; explicit locks, with FOR UPDATE to protect a read, NOWAIT to answer fast instead of freezing the screen and SKIP LOCKED to share out a work queue among several processes. We have opened MVCC up until we saw xmin, xmax and ctid changing before our eyes, and we have understood its bill: the dead tuples, the VACUUM that cleans them up and what happens when it does not get there in time. We have triggered a deadlock, read its real message, drawn its wait-for graph and learned that the fundamental defense fits in one sentence: always access rows in the same order.
And we have solved the last-seat problem three times, only to discover that all three solutions work and only one really protects. Pessimistic locking and SERIALIZABLE live in the application code, and are therefore lost as soon as another program touches the database. The chk_capacity constraint lives in the schema, is always enforced and documents itself. It is the same conclusion we reached in 04-04 and in 05-04, and we can now state it as a principle: a rule that cannot be broken must live where it cannot be bypassed.
With this, BiblioRed's system is now correct under concurrency. What remains is for it to be fast. Because there is a problem pending since the module's first line: the listing of overdue loans by branch, the very one we have just used in the exercises, takes fourteen seconds now that the loans table has passed two million rows. And fourteen seconds at a front desk with a member standing there is an eternity.
Lesson 06-03, Indexes and Query Optimization, is the one that fixes that —and it is the lesson 05-04 explicitly pointed us to when we said that indexes are the first thing to try before denormalizing. We will see what an index is and how a B-tree turns millions of comparisons into four reads; what an index costs, because they are not free and that is why you do not index everything; unique, composite, partial and expression indexes, with the leftmost prefix rule that explains why the order of the columns changes everything; what to index and what not to, including the warning that PostgreSQL does not index foreign keys on its own; how to read an EXPLAIN ANALYZE execution plan line by line and what it means when the estimated rows look nothing like the real ones; and the complete case of those fourteen seconds, with its plan before, its diagnosis, the index that fixes it and its plan after.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
