This is the most demanding lesson of the course, and also the one that most resembles real work. There is no warm-up SELECT here: there are window functions, recursive CTEs, jsonb, transactions with error handling, five exercises with two psql sessions in parallel and two on reading execution plans. All of it over BiblioRed.
How to work through this lesson. The same as the previous ones —try before you look— with one important difference: you need two terminals open. The concurrency block cannot be read, it has to be run. Each exercise in that block states exactly what to type in Session A and in Session B, and in what order. The instruction "do not run ahead with Session B until A has done its part" is not a suggestion: if you skip the order, the phenomenon you want to observe does not happen and you will think the exercise is wrong.
Open the two terminals like this, and give them distinguishable prompts so you do not get confused:
# Terminal 1
psql -d biblioredx
\set PROMPT1 '[A] %/%R%# '
# Terminal 2
psql -d biblioredx
\set PROMPT1 '[B] %/%R%# 'A warning about execution plans: the numbers you see on your machine will not match the ones in the solutions, because your loans table has 20 rows and the plans of exercises 12 and 13 correspond to a BiblioRed in production with 900,000. What has to be learned is not the milliseconds: it is the shape of the plan and the relationship between estimated and actual rows.
This lesson does not repeat basic SQL (that is 07-01) or normalization (that is 07-03).
Before You Begin
Start from the same dataset as lesson 07-01. If you do not have it loaded, go back there and run the script; the check that it is right is that lesson's counting query (20 loans, 15 copies, 26 registrations).
On top of that dataset three things have to be added that this lesson needs: the reports table with jsonb, a tree of topic categories and a queue of notices.
-- ============================================================
-- Module 7 add-on for lesson 07-04
-- ============================================================
CREATE TABLE event_reports (
event_id INTEGER PRIMARY KEY REFERENCES events(event_id) ON DELETE CASCADE,
actual_attendees SMALLINT NOT NULL CHECK (actual_attendees >= 0),
average_rating NUMERIC(3,2),
notes TEXT,
survey_responses JSONB,
report_date DATE NOT NULL
);
INSERT INTO event_reports VALUES
(101, 7, 4.50, 'Good atmosphere; the room was a tight fit.', '{
"channel": "web",
"topics": ["historical fiction","book club"],
"responses": [
{"member_id":14,"score":5,"would_recommend":true,"comment":"Very good pace"},
{"member_id":11,"score":4,"would_recommend":true,"comment":null},
{"member_id":13,"score":5,"would_recommend":true,"comment":"I will come again"},
{"member_id":12,"score":4,"would_recommend":false,"comment":"Small room"}
]}'::jsonb, '2026-03-14'),
(102, 9, 4.00, 'Children took part enthusiastically.', '{
"channel": "paper",
"topics": ["children","storytelling"],
"responses": [
{"member_id":16,"score":5,"would_recommend":true,"comment":"Delighted"},
{"member_id":12,"score":3,"would_recommend":false,"comment":"Too noisy"},
{"member_id":11,"score":4,"would_recommend":true,"comment":null}
]}'::jsonb, '2026-04-20'),
(103, 5, 4.75, 'Small group, very good standard.', '{
"channel": "web",
"topics": ["writing","workshop"],
"responses": [
{"member_id":14,"score":5,"would_recommend":true,"comment":"Excellent"},
{"member_id":13,"score":5,"would_recommend":true,"comment":null},
{"member_id":15,"score":4,"would_recommend":true,"comment":"Too short"},
{"member_id":11,"score":5,"would_recommend":true,"comment":"I will come again"}
]}'::jsonb, '2026-05-12'),
(104, 8, 3.25, 'Plenty of spare capacity; PA system problems.', '{
"channel": "paper",
"topics": ["book launch","novel"],
"responses": [
{"member_id":11,"score":3,"would_recommend":false,"comment":"Few people"},
{"member_id":12,"score":4,"would_recommend":true,"comment":null},
{"member_id":14,"score":4,"would_recommend":true,"comment":null},
{"member_id":16,"score":2,"would_recommend":false,"comment":"Could not hear"}
]}'::jsonb, '2026-06-08'),
(105, 4, 4.67, 'Quiet session, good conversation.', '{
"channel": "web",
"topics": ["contemporary fiction","book club"],
"responses": [
{"member_id":15,"score":5,"would_recommend":true,"comment":"Very good"},
{"member_id":16,"score":4,"would_recommend":true,"comment":null},
{"member_id":14,"score":5,"would_recommend":true,"comment":null}
]}'::jsonb, '2026-07-18');
CREATE INDEX idx_reports_survey ON event_reports USING gin (survey_responses);
-- Subject tree of the catalog
CREATE TABLE topic_categories (
category_id INTEGER PRIMARY KEY,
name VARCHAR(60) NOT NULL,
parent_id INTEGER REFERENCES topic_categories(category_id)
);
INSERT INTO topic_categories VALUES
(1,'Fiction',NULL), (2,'Narrative',1), (3,'Historical fiction',2),
(4,'Contemporary fiction',2), (5,'Non-fiction',NULL), (6,'History',5),
(7,'Contemporary history',6), (8,'Popular science',5);
-- Queue of notices to members
CREATE TABLE notices (
notice_id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id),
type VARCHAR(20) NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(12) NOT NULL DEFAULT 'pending',
sent_at TIMESTAMPTZ
);
INSERT INTO notices (member_id, type, message) VALUES
(13,'overdue','EJ-3088 was due on 26/04'),
(14,'overdue','EJ-3093 was due on 31/05'),
(16,'overdue','EJ-3087 was due on 01/07'),
(15,'reservation','Your reservation for The Map of Time is ready'),
(14,'fine','You have €24.00 outstanding'),
(11,'reservation','Your reservation expires on 08/08');
-- Needed for exercise 5
CREATE UNIQUE INDEX uq_fine_loan_reason ON fines (loan_id, reason);PostgreSQL version. You need 12 or higher for everything in this lesson; FOR UPDATE SKIP LOCKED has existed since 9.5 and FILTER since 9.4.
SQLite. It supports window functions and recursive CTEs since 3.25, but it has no jsonb (it has different json_* functions), no FOR UPDATE, no SKIP LOCKED and no configurable isolation levels: it locks the whole database when writing. Blocks (b), (c) and (d) of this lesson are intrinsically PostgreSQL.
Contents
- Block A — Advanced queries: windows, recursion and
jsonb(exercises 1-3) - Block B — Transactions and error handling (exercises 4-6)
- Block C — Concurrency: five two-session exercises (exercises 7-11)
- Block D — Indexes and execution plans (exercises 12-13)
- Common mistakes and tips
- Reinforcement exercises
Block A — Advanced queries
Exercise 1: Window functions
Difficulty: Intermediate
Task. Four reports that lesson 07-01 could not solve because window functions were needed:
- (a) The most-loaned material of each branch, with its number of loans. One row per branch. The branch is determined by the copy loaned, and ties are broken alphabetically by title.
- (b) The complete ranking of materials of the North branch, showing
ROW_NUMBER,RANKandDENSE_RANKin separate columns, to see how they differ when there is a tie. - (c) Monthly loans of the Central branch in 2026, with the difference and the percentage change against the previous month.
- (d) The running total of fine collection, payment by payment, ordered by date.
Hint. For (a), number the rows inside each partition and keep number 1; the WHERE cannot filter window functions, you have to wrap it in a CTE.
Solution
-- (a) Top 1 per group with ROW_NUMBER
WITH counts AS (
SELECT c.branch_id, m.material_id, m.title, count(*) AS n
FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
JOIN materials m ON m.material_id = c.material_id
GROUP BY c.branch_id, m.material_id, m.title
),
ranking AS (
SELECT ct.*,
ROW_NUMBER() OVER (PARTITION BY ct.branch_id
ORDER BY ct.n DESC, ct.title ASC) AS rn
FROM counts ct
)
SELECT b.name AS branch, r.title, r.n AS loans
FROM ranking r
JOIN branches b ON b.branch_id = r.branch_id
WHERE r.rn = 1
ORDER BY r.n DESC;
-- (b) The three ranking functions over the same partition
SELECT m.title,
count(*) AS n,
ROW_NUMBER() OVER (ORDER BY count(*) DESC, m.title) AS row_number,
RANK() OVER (ORDER BY count(*) DESC) AS rank,
DENSE_RANK() OVER (ORDER BY count(*) DESC) AS dense_rank
FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
JOIN materials m ON m.material_id = c.material_id
WHERE c.branch_id = 2
GROUP BY m.material_id, m.title;
-- (c) LAG to compare with the previous month
WITH monthly AS (
SELECT date_trunc('month', l.loan_date)::date AS month_,
count(*) AS loans
FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
WHERE c.branch_id = 1
AND l.loan_date >= DATE '2026-01-01'
GROUP BY 1
)
SELECT to_char(month_, 'YYYY-MM') AS month_,
loans,
LAG(loans) OVER (ORDER BY month_) AS previous_month,
loans - LAG(loans) OVER (ORDER BY month_) AS difference,
round(100.0 * (loans - LAG(loans) OVER (ORDER BY month_))
/ NULLIF(LAG(loans) OVER (ORDER BY month_), 0), 1) AS pct_change
FROM monthly
ORDER BY month_;
-- (d) Running total with SUM() OVER
SELECT pm.payment_date,
pm.method,
pm.amount,
sum(pm.amount) OVER (ORDER BY pm.payment_date, pm.payment_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM payments pm
ORDER BY pm.payment_date, pm.payment_id;Expected result
(a)
| branch | title | loans |
|---|---|---|
| Central | The Map of Time | 3 |
| North | The Frozen Heart | 2 |
| South | Kafka on the Shore | 2 |
(b) North branch:
| title | n | row_number | rank | dense_rank |
|---|---|---|---|---|
| The Frozen Heart | 2 | 1 | 1 | 1 |
| The Map of Time | 2 | 2 | 1 | 1 |
| Documentary: The Voice of Chernobyl | 1 | 3 | 3 | 2 |
(c)
| month_ | loans | previous_month | difference | pct_change |
|---|---|---|---|---|
| 2026-01 | 1 | (NULL) | (NULL) | (NULL) |
| 2026-02 | 1 | 1 | 0 | 0.0 |
| 2026-03 | 1 | 1 | 0 | 0.0 |
| 2026-04 | 3 | 1 | 2 | 200.0 |
| 2026-05 | 2 | 3 | -1 | -33.3 |
| 2026-06 | 1 | 2 | -1 | -50.0 |
| 2026-07 | 3 | 1 | 2 | 200.0 |
(d)
| payment_date | method | amount | running_total |
|---|---|---|---|
| 2026-03-06 | cash | 2.20 | 2.20 |
| 2026-04-12 | card | 3.00 | 5.20 |
| 2026-05-10 | card | 4.00 | 9.20 |
| 2026-05-18 | gateway | 2.50 | 11.70 |
Explanation. Five points, and all of them are real traps:
The East branch does not appear in (a), and that is correct: its two copies (EJ-3085 reserved and EJ-3095 withdrawn) have never gone out, so there is no loans row mentioning it. If the report had to show all four branches with "(none)" at East, you would have to start from branches with a LEFT JOIN against the CTE. It is the same lesson as the anti-join of 07-01 applied to windows.
You cannot filter by a window function in the WHERE. WHERE ROW_NUMBER() OVER (...) = 1 is a syntax error, and it is not a whim: window functions are evaluated after the WHERE and the GROUP BY, near the end of the logical order, alongside the SELECT. That is why they have to be wrapped in a CTE or a derived table and filtered at the outer level.
The three ranking functions do different things when there is a tie, and (b) shows it with data: "The Frozen Heart" and "The Map of Time" have 2 loans each at North.
| Function | On a tie | Numbers you get |
|---|---|---|
ROW_NUMBER() |
Breaks the tie arbitrarily | 1, 2, 3 |
RANK() |
Gives the same number and skips | 1, 1, 3 |
DENSE_RANK() |
Gives the same number and does not skip | 1, 1, 2 |
For a "top 1 per group" you have to use ROW_NUMBER: with RANK you would get two rows for North, because the two tied ones would have rank 1. And if you really do want both in the event of a tie, then RANK is the right one. It is a business decision, not a syntax one.
In (c), LAG is computed over the rows the query returns, not over the calendar. If in some month there had been no loans at all, that month would not appear and LAG would compare with the previous present month, not with the immediately preceding one in time. That gives false comparisons. The robust way is to generate the series of months and left-join it —exactly what exercise 2 does—. It is not needed here because all seven months have loans, but in production it is a very expensive mistake.
In (d), the window frame matters. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW accumulates row by row. The default frame when there is an ORDER BY is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which groups all the rows with the same ordering value. If two payments had the same date, with RANGE both would show the same running total (the one after adding both) and with ROWS they would show the intermediate step. That is why the window's ORDER BY includes payment_id: it makes the order unique and removes the ambiguity.
Exercise 2: Recursive CTEs
Difficulty: Advanced
Task. Two different uses of recursion:
- (a) The daily occupancy report for July 2026: one row for each day of the month, with the number of loans of that day. Days with no loans must appear with 0. Generate the series of dates with a recursive CTE.
- (b) The subject tree of the
topic_categoriestable: for each category descending from "Fiction" (itself included), its depth level and its full path from the root.
Hint. A recursive CTE always has two parts joined by UNION ALL: the base case and the recursive step, which refers to the CTE itself.
Solution
-- (a) Series of dates + LEFT JOIN
WITH RECURSIVE days AS (
SELECT DATE '2026-07-01' AS day -- base case
UNION ALL
SELECT day + 1 -- recursive step
FROM days
WHERE day < DATE '2026-07-31' -- stop condition: mandatory!
)
SELECT d.day,
count(l.loan_id) AS loans
FROM days d
LEFT JOIN loans l ON l.loan_date = d.day
GROUP BY d.day
ORDER BY d.day;
-- (b) Walking a tree
WITH RECURSIVE tree AS (
SELECT category_id, name, parent_id,
0 AS level,
name::text AS path
FROM topic_categories
WHERE name = 'Fiction' -- base case: the root we care about
UNION ALL
SELECT c.category_id, c.name, c.parent_id,
t.level + 1,
t.path || ' > ' || c.name
FROM topic_categories c
JOIN tree t ON t.category_id = c.parent_id -- recursive step
)
SELECT level, category_id, name, path
FROM tree
ORDER BY path;Expected result
(a) 31 rows. The first ones and the ones with loans:
| day | loans |
|---|---|
| 2026-07-01 | 0 |
| 2026-07-02 | 0 |
| 2026-07-03 | 0 |
| 2026-07-04 | 0 |
| 2026-07-05 | 1 |
| 2026-07-06 | 0 |
| ... | ... |
| 2026-07-10 | 1 |
| ... | ... |
| 2026-07-20 | 1 |
| 2026-07-22 | 1 |
| ... | ... |
| 2026-07-31 | 0 |
Total: 4 loans spread over 4 days, and 27 days at zero.
(b)
| level | category_id | name | path |
|---|---|---|---|
| 0 | 1 | Fiction | Fiction |
| 1 | 2 | Narrative | Fiction > Narrative |
| 2 | 4 | Contemporary fiction | Fiction > Narrative > Contemporary fiction |
| 2 | 3 | Historical fiction | Fiction > Narrative > Historical fiction |
Explanation. The structure of a recursive CTE is always the same and is worth memorizing:
WITH RECURSIVE name AS (
<base case> -- does not refer to itself
UNION ALL
<recursive step> -- it DOES refer to "name"
)The engine runs the base case, puts the result in a working table, runs the recursive step using only the new rows from the previous iteration, and repeats until an iteration produces no rows.
The stop condition is your responsibility. In (a), without WHERE day < DATE '2026-07-31' the query generates dates until it blows up. In (b), the stop is implicit: when there are no more categories whose parent is at the current level, the iteration returns zero rows and finishes. But if the tree had a cycle —a category that was its own ancestor— the query would never end. Against that, PostgreSQL 14 and above have the CYCLE clause:
In earlier versions, the manual pattern is to drag an array with the path walked so far and add WHERE NOT c.category_id = ANY(t.path_) in the recursive step. It is exactly what was missing in exercise 3 of lesson 07-02, where the prerequisites of a course could form a cycle A → B → C → A.
In PostgreSQL, (a) has a much better shortcut:
SELECT g.day::date, count(l.loan_id)
FROM generate_series(DATE '2026-07-01', DATE '2026-07-31', INTERVAL '1 day') AS g(day)
LEFT JOIN loans l ON l.loan_date = g.day::date
GROUP BY g.day ORDER BY g.day;generate_series is shorter, faster and more readable. The recursive version is in the exercise because it is portable (it works in SQLite and in SQL Server) and because understanding the mechanism is what lets you solve (b), where there is no shortcut.
The LEFT JOIN is the whole point of the exercise. A daily occupancy report built with GROUP BY loan_date over the loans table would return 4 rows, not 31. And a chart drawn with those 4 rows presents the month as if there had been continuous activity. Days at zero are data, not an absence of data, and the only way to have them is to generate the complete time axis and left-join it.
Exercise 3: jsonb and a manual pivot
Difficulty: Advanced
Task. The satisfaction surveys of the events are stored in event_reports.survey_responses, of type jsonb. You are asked to:
- (a) List each event with the survey channel and the number of responses received.
- (b) The reports whose surveys were done through the web, using the containment operator
@>. - (c) Unnest the array of responses with
jsonb_array_elementsand compute, per event, the average score, the number of responses that would recommend and the percentage. - (d) A manual pivot: per event, how many responses gave each score (2, 3, 4 and 5), in columns.
Hint. -> returns jsonb; ->> returns text. The difference matters when you have to compare or convert.
Solution
-- (a) Basic navigation: -> and ->>
SELECT er.event_id,
ev.title,
er.survey_responses ->> 'channel' AS channel,
jsonb_array_length(er.survey_responses -> 'responses') AS n_responses
FROM event_reports er
JOIN events ev ON ev.event_id = er.event_id
ORDER BY er.event_id;
-- (b) Containment: @> asks "does the jsonb on the left contain this?"
SELECT event_id, survey_responses ->> 'channel' AS channel
FROM event_reports
WHERE survey_responses @> '{"channel":"web"}'::jsonb
ORDER BY event_id;
-- (c) Unnest the array into rows and aggregate
WITH responses AS (
SELECT er.event_id,
(r ->> 'score')::int AS score,
(r ->> 'would_recommend')::boolean AS would_recommend
FROM event_reports er,
LATERAL jsonb_array_elements(er.survey_responses -> 'responses') AS r
)
SELECT event_id,
count(*) AS responses,
round(avg(score), 2) AS average,
count(*) FILTER (WHERE would_recommend) AS recommend,
round(100.0 * count(*) FILTER (WHERE would_recommend) / count(*), 1) AS pct_recommend
FROM responses
GROUP BY event_id
ORDER BY average DESC;
-- (d) Manual pivot with FILTER
WITH responses AS (
SELECT er.event_id, (r ->> 'score')::int AS score
FROM event_reports er,
LATERAL jsonb_array_elements(er.survey_responses -> 'responses') AS r
)
SELECT event_id,
count(*) FILTER (WHERE score = 2) AS "2",
count(*) FILTER (WHERE score = 3) AS "3",
count(*) FILTER (WHERE score = 4) AS "4",
count(*) FILTER (WHERE score = 5) AS "5",
count(*) AS total
FROM responses
GROUP BY event_id
ORDER BY event_id;Expected result
(a)
| event_id | title | channel | n_responses |
|---|---|---|---|
| 101 | Book club: The Pillars of the Earth | web | 4 |
| 102 | Spring storytelling | paper | 3 |
| 103 | Creative writing workshop | web | 4 |
| 104 | Book launch: The Wishing Box | paper | 4 |
| 105 | Book club: Norwegian Wood | web | 3 |
(b) Events 101, 103 and 105.
(c)
| event_id | responses | average | recommend | pct_recommend |
|---|---|---|---|---|
| 103 | 4 | 4.75 | 4 | 100.0 |
| 105 | 3 | 4.67 | 3 | 100.0 |
| 101 | 4 | 4.50 | 3 | 75.0 |
| 102 | 3 | 4.00 | 2 | 66.7 |
| 104 | 4 | 3.25 | 2 | 50.0 |
(d)
| event_id | 2 | 3 | 4 | 5 | total |
|---|---|---|---|---|---|
| 101 | 0 | 0 | 2 | 2 | 4 |
| 102 | 0 | 1 | 1 | 1 | 3 |
| 103 | 0 | 0 | 1 | 3 | 4 |
| 104 | 1 | 1 | 2 | 0 | 4 |
| 105 | 0 | 0 | 1 | 2 | 3 |
Explanation. The four parts cover the four operations used 95% of the time with jsonb:
-> versus ->>. survey_responses -> 'channel' returns "web" with quotes, because it is a jsonb value of string type. survey_responses ->> 'channel' returns web, plain text. The practical consequence: WHERE survey_responses -> 'channel' = 'web' fails or finds nothing, because it compares a jsonb with a text. You have to use ->> to compare with text, or -> 'channel' = '"web"'::jsonb. It is mistake number one with jsonb.
@> is the operator that uses the GIN index. WHERE survey_responses ->> 'channel' = 'web' gives the same result as @> but cannot use the GIN index we created in Before You Begin: a GIN index by default indexes the structure of the document and answers containment and existence operators (@>, ?, ?|, ?&), not extractions. With five rows it makes no difference; with 200,000 reports, the difference is three orders of magnitude. If you need to index a specific extraction, the right thing is an expression index: CREATE INDEX ... ON event_reports ((survey_responses ->> 'channel')).
jsonb_array_elements is a function that returns rows, not a value. That is why it appears in the FROM with LATERAL, which lets it see the er.survey_responses column of the row being processed. The word LATERAL is optional in PostgreSQL when the function goes in the FROM separated by a comma, but writing it makes explicit what is happening: for each row of event_reports, as many rows are generated as its array has elements.
The manual pivot with FILTER. PostgreSQL does not have the PIVOT clause of other engines; you do it with a conditional aggregate per column. count(*) FILTER (WHERE score = 5) is standard SQL and is equivalent to sum(CASE WHEN score = 5 THEN 1 ELSE 0 END), which is the portable version. Beware the variant count(CASE WHEN score = 5 THEN 1 END): it works because count ignores nulls, but count(CASE WHEN ... THEN 1 ELSE 0 END) does not work, because it counts the zeros as well and always returns the total. It is a classic mistake.
The limitation of the manual pivot: the columns have to be written out by hand. If tomorrow the survey accepts scores from 1 to 10, the query has to be edited. A pivot with a variable number of columns is not expressible in pure SQL —the number of columns of the result must be known when the query is parsed— and it is solved by generating the SQL from the application or by returning the result in long format and pivoting in the presentation layer.
Block B — Transactions and error handling
Exercise 4: The complete loan transaction
Difficulty: Intermediate
Task. Iván Pereda (member 15) shows up at the North branch desk to pick up "The Map of Time", which he had reserved (reservation 502, status active). The available copy is EJ-3082 (copy_id 3082).
Write the complete transaction that:
- Checks that the copy is really available, locking it.
- Inserts the loan with a 21-day term from August 2, 2026.
- Marks the copy as
on_loan. - Closes the reservation as
fulfilled.
It must be atomic: if any step fails, nothing must remain. And it must detect the case in which somebody else took the copy between the catalog query and the button press.
Solution
Interactive version in psql, to understand the flow:
BEGIN;
-- 1) Lock and check. FOR UPDATE prevents another session from touching it
-- until this transaction finishes.
SELECT copy_id, code, status
FROM copies
WHERE copy_id = 3082
FOR UPDATE;
-- It must return status = 'available'. If it returns anything else: ROLLBACK.
-- 2) Record the loan
INSERT INTO loans (loan_id, member_id, copy_id,
loan_date, due_date)
VALUES (21, 15, 3082, DATE '2026-08-02', DATE '2026-08-23');
-- 3) Change the copy's status, conditioned on the expected status
UPDATE copies
SET status = 'on_loan'
WHERE copy_id = 3082 AND status = 'available';
-- It must say UPDATE 1. If it says UPDATE 0: ROLLBACK.
-- 4) Close the reservation
UPDATE reservations
SET status = 'fulfilled'
WHERE reservation_id = 502 AND status = 'active';
-- It must say UPDATE 1.
COMMIT;Production version, with real error handling, as a function:
CREATE OR REPLACE FUNCTION register_loan(
p_member_id INTEGER,
p_copy_id INTEGER,
p_days INTEGER DEFAULT 21
) RETURNS INTEGER AS $$
DECLARE
v_status VARCHAR(15);
v_material_id INTEGER;
v_loan_id INTEGER;
v_rows INTEGER;
BEGIN
-- 1) Lock the copy's row and read its status
SELECT status, material_id INTO v_status, v_material_id
FROM copies
WHERE copy_id = p_copy_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Copy % does not exist', p_copy_id
USING ERRCODE = 'no_data_found';
END IF;
IF v_status <> 'available' THEN
RAISE EXCEPTION 'Copy % is not available (status: %)',
p_copy_id, v_status
USING ERRCODE = 'check_violation';
END IF;
-- 2) Record the loan
INSERT INTO loans (member_id, copy_id, loan_date,
due_date)
VALUES (p_member_id, p_copy_id, CURRENT_DATE,
CURRENT_DATE + p_days)
RETURNING loan_id INTO v_loan_id;
-- 3) Change the status
UPDATE copies SET status = 'on_loan'
WHERE copy_id = p_copy_id AND status = 'available';
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows <> 1 THEN
RAISE EXCEPTION 'Race detected while marking copy %', p_copy_id;
END IF;
-- 4) Close the member's reservation on that material, if there is one
UPDATE reservations SET status = 'fulfilled'
WHERE member_id = p_member_id AND material_id = v_material_id
AND status = 'active';
RETURN v_loan_id;
END;
$$ LANGUAGE plpgsql;Expected result
BEGIN
copy_id | code | status
---------+---------+-----------
3082 | EJ-3082 | available
INSERT 0 1
UPDATE 1
UPDATE 1
COMMITAnd checking the effect:
SELECT c.code, c.status, r.status AS reservation_status
FROM copies c
JOIN reservations r ON r.reservation_id = 502
WHERE c.copy_id = 3082;| code | status | reservation_status |
|---|---|---|
| EJ-3082 | on_loan | fulfilled |
If another session had taken the copy first, the function would raise:
...and nothing of the transaction would remain: not the loan, not the status change, not the closed reservation.
Explanation. Four decisions that separate this transaction from one that works "almost always":
FOR UPDATE in the checking SELECT. Without it, between the SELECT that says "available" and the UPDATE that marks it as loaned there is a window in which another session can do the same. FOR UPDATE locks the row until the end of the transaction: the second session waits at its own SELECT ... FOR UPDATE and, when the first one commits, reads the already updated status. It is the check-then-act pattern, and without the lock it is not atomic.
The UPDATE carries AND status = 'available' even though we already checked it. It is a belt on top of the braces: it turns the UPDATE into a conditional operation whose number of affected rows tells us whether the premise was still true. GET DIAGNOSTICS ... ROW_COUNT is the way to read that number from plpgsql; in interactive psql you see it in the UPDATE 1 / UPDATE 0.
An UPDATE 0 is not an error. It is the most common trap: if the reservation was already closed, step 4 returns UPDATE 0 and the transaction commits all the same. Here that is deliberate —there may be no reservation, and nothing happens—, but in step 3 it is not, and that is why it is checked there. Deciding explicitly, step by step, whether an UPDATE 0 is acceptable or is a failure, is half the work of writing a transaction.
In plpgsql, the whole function is an implicit transaction. You do not have to write BEGIN/COMMIT inside: if the function raises an exception, everything it has done is undone automatically. What it can do is catch exceptions with EXCEPTION WHEN ... THEN, and there it is worth knowing that every EXCEPTION block creates an implicit savepoint, with its cost. A loop of a million iterations with an EXCEPTION block inside is noticeably slower than the same loop without it.
Exercise 5: SAVEPOINT in a batch process
Difficulty: Intermediate
Task. The nightly process of August 2, 2026 must issue a late-return fine for every overdue loan not returned: loans 9, 12 and 15. The amount is €0.20/day with a cap of €15.00.
The problem: the index uq_fine_loan_reason on (loan_id, reason) exists, and some of those loans already have a late-return fine issued. If the process runs as a monolithic transaction, the first clash aborts the whole batch and no fine at all is issued.
Write the batch in such a way that the failure of one element does not prevent the others from being processed, using SAVEPOINT. When it finishes, report how many were issued and how many were skipped.
Hint. ROLLBACK TO SAVEPOINT undoes back to the savepoint and leaves the transaction usable; without it, after an error the transaction is aborted and every later statement fails.
Solution
Interactive version, to see the mechanism:
BEGIN;
SAVEPOINT sp_l9;
INSERT INTO fines (fine_id, member_id, loan_id, reason, amount, issue_date, status)
VALUES (9, 13, 9, 'late_return', LEAST(98 * 0.20, 15.00), DATE '2026-08-02', 'pending');
-- ERROR: duplicate key value violates unique constraint "uq_fine_loan_reason"
ROLLBACK TO SAVEPOINT sp_l9; -- the transaction becomes usable again
SAVEPOINT sp_l12;
INSERT INTO fines (fine_id, member_id, loan_id, reason, amount, issue_date, status)
VALUES (9, 14, 12, 'late_return', LEAST(63 * 0.20, 15.00), DATE '2026-08-02', 'pending');
-- INSERT 0 1
RELEASE SAVEPOINT sp_l12;
SAVEPOINT sp_l15;
INSERT INTO fines (fine_id, member_id, loan_id, reason, amount, issue_date, status)
VALUES (10, 16, 15, 'late_return', LEAST(32 * 0.20, 15.00), DATE '2026-08-02', 'pending');
-- ERROR: duplicate key value violates unique constraint "uq_fine_loan_reason"
ROLLBACK TO SAVEPOINT sp_l15;
COMMIT;Production version, with the loop and the count:
DO $$
DECLARE
r RECORD;
v_days INTEGER;
v_issued INTEGER := 0;
v_skipped INTEGER := 0;
BEGIN
FOR r IN
SELECT l.loan_id, l.member_id,
(DATE '2026-08-02' - l.due_date) AS days
FROM loans l
WHERE l.return_date IS NULL
AND l.due_date < DATE '2026-08-02'
ORDER BY l.loan_id
LOOP
BEGIN -- nested block = implicit SAVEPOINT
INSERT INTO fines (member_id, loan_id, reason, amount,
issue_date, status)
VALUES (r.member_id, r.loan_id, 'late_return',
LEAST(r.days * 0.20, 15.00), DATE '2026-08-02', 'pending');
v_issued := v_issued + 1;
EXCEPTION
WHEN unique_violation THEN
v_skipped := v_skipped + 1;
RAISE NOTICE 'Loan %: already had a late-return fine, skipped',
r.loan_id;
END;
END LOOP;
RAISE NOTICE 'Batch finished: % issued, % skipped', v_issued, v_skipped;
END $$;Expected result
NOTICE: Loan 9: already had a late-return fine, skipped
NOTICE: Loan 15: already had a late-return fine, skipped
NOTICE: Batch finished: 1 issued, 2 skippedSpecifically:
| loan_id | days late | Result | Reason |
|---|---|---|---|
| 9 | 98 | Skipped | Fine 7 already exists, late_return, of member 13 |
| 12 | 63 | Issued, €12.60 | It only had fine 4, of reason loss |
| 15 | 32 | Skipped | Fine 8 already exists, late_return (voided, but it occupies the index) |
Final result: 1 fine issued, 2 skipped, and the transaction commits.
Check:
SELECT fine_id, member_id, loan_id, reason, amount, status
FROM fines WHERE issue_date = DATE '2026-08-02';| fine_id | member_id | loan_id | reason | amount | status |
|---|---|---|---|---|---|
| 9 | 14 | 12 | late_return | 12.60 | pending |
Explanation. What this exercise teaches is a property of PostgreSQL that surprises whoever comes from other engines:
In PostgreSQL, an error inside a transaction aborts the whole thing. From that moment on, every statement returns
ERROR: current transaction is aborted, commands ignored until end of transaction block, and the finalCOMMITbehaves like aROLLBACK.
That is: without SAVEPOINT, a single unique-key clash on element number 3 of a batch of 500 throws away all 500. And it does not fail loudly: the COMMIT answers ROLLBACK and you have to be watching to notice.
SAVEPOINT is the antidote. It marks a point you can go back to; ROLLBACK TO SAVEPOINT undoes only what was done afterwards and returns the transaction to a usable state. RELEASE SAVEPOINT discards it when it is no longer needed (optional, but advisable in long batches: every live savepoint consumes resources).
In plpgsql you do not write SAVEPOINT: you use a BEGIN ... EXCEPTION ... END block. That block creates and manages the savepoint automatically. It is the idiomatic form and the one to use.
The case of loan 15 deserves a design comment. Its previous fine (number 8) is in status voided, so conceptually it "does not count" and perhaps a new one should be issuable. But the unique index is on (loan_id, reason) and nothing else, and it does not distinguish statuses. If the business wants to allow it, the correct index would be partial:
DROP INDEX uq_fine_loan_reason;
CREATE UNIQUE INDEX uq_fine_loan_reason
ON fines (loan_id, reason)
WHERE status IN ('pending','paid');It is the same partial unique index pattern we used in 07-02 for "a single open rental per copy". A batch skipping an element because of an over-broad constraint is a design symptom, not just a process problem.
Exercise 6: What survives a sequence of SAVEPOINT and ROLLBACK TO
Difficulty: Advanced
Task. An operator runs this sequence in a single session. State, for each numbered statement, whether its effect survives the final COMMIT or not, and describe the final state of the affected tables. Justify every answer.
BEGIN;
INSERT INTO speakers (speaker_id, first_name, last_name, email, external)
VALUES (4,'Lidia','Serna','lidia.serna@example.org',TRUE); -- (1)
SAVEPOINT sp1;
UPDATE events SET offered_seats = 20 WHERE event_id = 106; -- (2)
SAVEPOINT sp2;
INSERT INTO participations VALUES (106,4,'workshop_leader',400.00); -- (3)
UPDATE events SET status = 'full' WHERE event_id = 106; -- (4)
ROLLBACK TO SAVEPOINT sp2;
INSERT INTO participations VALUES (107,4,'storyteller',150.00); -- (5)
SAVEPOINT sp3;
DELETE FROM speakers WHERE speaker_id = 2; -- (6)
ROLLBACK TO SAVEPOINT sp3;
UPDATE events SET published = TRUE WHERE event_id = 107; -- (7)
COMMIT;Second part: what would have happened if the ROLLBACK TO SAVEPOINT sp3 had been omitted after statement (6)?
Solution
| # | Statement | Does it survive? | Why |
|---|---|---|---|
| (1) | INSERT speaker 4 |
Yes | It happens before sp1; no ROLLBACK TO goes back that far |
| (2) | UPDATE seats of 106 to 20 |
Yes | It happens between sp1 and sp2. The ROLLBACK TO sp2 goes back to point sp2, which is later than this statement |
| (3) | INSERT participation (106,4) |
No | Later than sp2, undone by ROLLBACK TO sp2 |
| (4) | UPDATE status of 106 to full |
No | Later than sp2, undone by ROLLBACK TO sp2 |
| (5) | INSERT participation (107,4) |
Yes | Later than the ROLLBACK TO sp2 and earlier than sp3; nothing undoes it |
| (6) | DELETE of speaker 2 |
No | It fails: it violates the foreign key from participations (speaker 2 takes part in 102, 103 and 106). The ROLLBACK TO sp3 leaves the transaction usable |
| (7) | UPDATE published of 107 |
Yes | Last statement, committed by the COMMIT |
Final state of the tables:
| speaker_id | first_name | last_name |
|---|---|---|
| 1 | Rosa | Calduch |
| 2 | Aitor | Lemus |
| 3 | Delia | Marchetti |
| 4 | Lidia | Serna |
| event_id | offered_seats | status | published |
|---|---|---|---|
| 106 | 20 | open | t |
| 107 | 20 | scheduled | t |
participations goes from 7 to 8 rows: (107, 4, 'storyteller', 150.00) is added and (106, 4, 'workshop_leader', 400.00) is not.
Second part: without the ROLLBACK TO SAVEPOINT sp3.
DELETE FROM speakers WHERE speaker_id = 2;
ERROR: update or delete on table "speakers" violates foreign key constraint
"participations_speaker_id_fkey" on table "participations"
UPDATE events SET published = TRUE WHERE event_id = 107;
ERROR: current transaction is aborted, commands ignored until end of transaction block
COMMIT;
ROLLBACKAbsolutely everything is lost: statement (7) is not even executed, and the COMMIT answers literally ROLLBACK. Speaker 4 is not created, the seats of 106 are still 15 and the participation (107,4) does not exist. A single error, with no savepoint to contain it, throws away the entire piece of work.
Explanation. The point to fix in your mind, and the one almost everybody gets wrong the first time:
ROLLBACK TO SAVEPOINT spundoes what happened aftersp. What happened beforesp—including what lies betweensp1andsp2— remains.
Statement (2) is the one that separates whoever has understood it from whoever has not. It sits between two savepoints, and the ROLLBACK TO sp2 goes back to the second one, not to the first. Had the operator wanted to undo it, they would have had to write ROLLBACK TO sp1.
Second point: a ROLLBACK TO does not destroy the savepoint. After ROLLBACK TO sp2, the point sp2 still exists and you can go back to it again. It is RELEASE SAVEPOINT that removes it. And ROLLBACK TO sp1 would automatically invalidate sp2, because it is later.
Third point, the most important in practice: the ROLLBACK answer to a COMMIT is easy to miss. In an interactive psql it jumps out at you; in an application client that does not check the value returned by commit(), the transaction is lost in silence and the application believes it has saved. This is the reason why serious ORMs wrap every operation in a savepoint and why psql's ON_ERROR_STOP (\set ON_ERROR_STOP on) should be active in every migration script.
Block C — Concurrency: two-session exercises
Instructions for the whole block. Run each line in the session indicated and in the order indicated. When a session "hangs" without giving the prompt back, it is because it is waiting for a lock: that is exactly what we want to observe. Before each exercise, make sure neither session has an open transaction (
ROLLBACK;just in case).
Exercise 7: Reproducing a lost update and fixing it
Difficulty: Advanced
Task. The regulations add a €2.00 surcharge to pending fines more than 30 days old. Two clerks, at two different branches, apply the surcharge to the same fine: number 3 (Iván Pereda, €1.60, pending).
The application does it in two steps: it reads the amount, adds 2.00 in memory and writes the result.
Part 1. Reproduce the phenomenon. What should the fine be worth in the end and what is it worth?
Part 2. Fix it in two different ways and explain which one you prefer.
Solution — Part 1: the lost update
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT amount FROM fines WHERE fine_id = 3; → 1.60 |
|
| 3 | BEGIN; |
|
| 4 | SELECT amount FROM fines WHERE fine_id = 3; → 1.60 |
|
| 5 | UPDATE fines SET amount = 3.60 WHERE fine_id = 3; |
|
| 6 | COMMIT; |
|
| 7 | UPDATE fines SET amount = 3.60 WHERE fine_id = 3; |
|
| 8 | COMMIT; |
|
| 9 | SELECT amount FROM fines WHERE fine_id = 3; → 3.60 |
It should be €5.60 (1.60 + 2.00 + 2.00). It is €3.60. One of the two surcharges has disappeared without a trace: no error, no warning, no entry in any log. Both clerks saw UPDATE 1 and believe their work is done.
Solution — Part 2, option 1: SELECT ... FOR UPDATE
Restore the value (UPDATE fines SET amount = 1.60 WHERE fine_id = 3;) and repeat with locking:
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT amount FROM fines WHERE fine_id = 3 FOR UPDATE; → 1.60 |
|
| 3 | BEGIN; |
|
| 4 | SELECT amount FROM fines WHERE fine_id = 3 FOR UPDATE; → it waits |
|
| 5 | UPDATE fines SET amount = 3.60 WHERE fine_id = 3; |
(still waiting) |
| 6 | COMMIT; |
→ it unblocks and returns 3.60 |
| 7 | UPDATE fines SET amount = 5.60 WHERE fine_id = 3; |
|
| 8 | COMMIT; |
Result: €5.60. Correct.
Solution — Part 2, option 2: the atomic UPDATE
-- Session A -- Session B
UPDATE fines SET amount = amount + 2.00 UPDATE fines SET amount = amount + 2.00
WHERE fine_id = 3; WHERE fine_id = 3;With no explicit BEGIN, each UPDATE is its own transaction. The second waits for the first to commit and re-reads the updated row before applying its change, because in READ COMMITTED a blocked UPDATE re-evaluates the row when it is released.
Result: €5.60. Also correct, and with no explicit locking.
Expected result
| Approach | Final result | Round trips to the database |
|---|---|---|
| Read, compute, write (no lock) | €3.60 — incorrect | 2 |
SELECT ... FOR UPDATE + UPDATE |
€5.60 | 2 |
UPDATE ... SET amount = amount + 2.00 |
€5.60 | 1 |
Explanation. The lost update is the most treacherous concurrency phenomenon because the engine does not consider it an error. Both UPDATEs are legitimate, both affect one row, both commit. The inconsistency is in the application's head, not in the database.
When to use each solution:
- The atomic
UPDATEis always preferable when it is possible. A single round trip, no race window, no lock to manage. The rule: if the new value can be expressed as a function of the old value inside the SQL itself, do it that way. FOR UPDATEis necessary when the computation does not fit into theUPDATE: when you have to query other tables, apply complex business logic or decide whether to update. That is the case of the loan transaction in exercise 4.- Optimistic locking with a
versioncolumn (exercise 10) is the third way, and the one that fits when the user has a form open on screen: you cannot hold a lock while somebody is thinking.
An important note about READ COMMITTED, which is the default level: at step 7 of option 2, session B's UPDATE does not work on the snapshot it saw at the start; when the lock is released, PostgreSQL re-evaluates the WHERE against the most recent version of the row. That behavior —which is not what a strict isolation would do— is what saves option 2, and it does not work in REPEATABLE READ: there the second UPDATE would abort with ERROR: could not serialize access due to concurrent update.
Exercise 8: READ COMMITTED versus REPEATABLE READ
Difficulty: Advanced
Task. A web catalog query reads twice, within the same transaction, how many available copies there are of material 902 ("The Map of Time"). Between the two reads, a colleague returns a copy.
Run the scenario twice: first with READ COMMITTED and then with REPEATABLE READ. Note what Session A sees on each read and explain the difference.
Starting state: material 902 has two copies, EJ-3081 (on loan) and EJ-3082 (available). Available: 1.
Solution — Scenario 1: READ COMMITTED (the default level)
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN; (READ COMMITTED by default) |
|
| 2 | SELECT count(*) FROM copies WHERE material_id=902 AND status='available'; → 1 |
|
| 3 | UPDATE copies SET status='available' WHERE copy_id=3081; |
|
| 4 | COMMIT; |
|
| 5 | SELECT count(*) FROM copies WHERE material_id=902 AND status='available'; → 2 |
|
| 6 | COMMIT; |
Session A has seen 1 and then 2 within the same transaction: a non-repeatable read.
Solution — Scenario 2: REPEATABLE READ
Restore the state (UPDATE copies SET status='on_loan' WHERE copy_id=3081;) and repeat:
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| 2 | SELECT count(*) ... ; → 1 |
|
| 3 | UPDATE copies SET status='available' WHERE copy_id=3081; |
|
| 4 | COMMIT; |
|
| 5 | SELECT count(*) ... ; → 1 |
|
| 6 | COMMIT; |
|
| 7 | SELECT count(*) ... ; (now outside the transaction) → 2 |
Session A sees 1 both times. Only after the COMMIT, in a new transaction, does it see the 2.
Expected result
| Level | 1st read | 2nd read | Phenomenon |
|---|---|---|---|
READ COMMITTED |
1 | 2 | Non-repeatable read |
REPEATABLE READ |
1 | 1 | None: stable snapshot |
Explanation. The difference lies in when the snapshot is taken of what each transaction reads:
- In
READ COMMITTED, each statement takes its own snapshot, when it starts. That is why the second query sees the changes committed in between. It is PostgreSQL's default level, and it is the right one for 95% of applications: it maximizes concurrency and never reads uncommitted data. - In
REPEATABLE READ, the snapshot is taken once only, at the transaction's first statement, and it is kept until the end. Everything the transaction reads will be consistent with itself, as if the world had frozen.
When it really matters. If the monthly management report runs eight queries —loans, members, fines, payments, events...— and it runs in READ COMMITTED while the system is in use, the eight queries may see different states of the database. The total of fines may not match the breakdown by reason. In REPEATABLE READ that is impossible: all eight see exactly the same instant.
The price. In REPEATABLE READ, if your transaction tries to modify a row that another transaction modified and committed after your snapshot, PostgreSQL aborts yours with:
It is not a failure: it is the contract. The application has to be ready to retry the whole transaction. If it is not, raising the isolation level swaps a problem of inconsistent data for a problem of errors in production.
A terminological note. The SQL standard says that REPEATABLE READ allows phantom reads (new rows appearing in a range query). PostgreSQL's implementation, based on MVCC with snapshots, does not allow them: its REPEATABLE READ is stronger than the minimum the standard demands. For write skew —the phenomenon that does slip past it— there is SERIALIZABLE, which is the fourth level and the only one guaranteeing equivalence with a serial execution.
Exercise 9: Causing and resolving a deadlock
Difficulty: Advanced
Task. Two processes update the data of two members, but in different order: process A starts with Marta Alsina (14) and continues with Iván Pereda (15); process B starts with Iván and continues with Marta.
Part 1. Cause the deadlock and observe what PostgreSQL does. Part 2. Fix it without changing what each process does.
Solution — Part 1: the deadlock
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | UPDATE members SET active=TRUE WHERE member_id=14; → UPDATE 1 |
|
| 3 | BEGIN; |
|
| 4 | UPDATE members SET active=TRUE WHERE member_id=15; → UPDATE 1 |
|
| 5 | UPDATE members SET active=TRUE WHERE member_id=15; → waits (B holds row 15) |
|
| 6 | UPDATE members SET active=TRUE WHERE member_id=14; → waits (A holds row 14) |
|
| 7 | (after ~1 second, one of the two gets the error) |
Output in the victim session (the one PostgreSQL decides to abort):
ERROR: deadlock detected
DETAIL: Process 18422 waits for ShareLock on transaction 9931; blocked by process 18455.
Process 18455 waits for ShareLock on transaction 9930; blocked by process 18422.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,3) in relation "members"The other session unblocks immediately and can carry on to its COMMIT. The victim is left with an aborted transaction and must ROLLBACK and retry.
Solution — Part 2: ordering the locks
The cause is the reverse acquisition order. The solution is for all the processes to lock the rows in the same order, for instance by ascending primary key:
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | UPDATE members SET active=TRUE WHERE member_id=14; |
|
| 3 | BEGIN; |
|
| 4 | UPDATE members SET active=TRUE WHERE member_id=14; → waits |
|
| 5 | UPDATE members SET active=TRUE WHERE member_id=15; → UPDATE 1 |
(waiting) |
| 6 | COMMIT; |
→ it unblocks, UPDATE 1 |
| 7 | UPDATE members SET active=TRUE WHERE member_id=15; → UPDATE 1 |
|
| 8 | COMMIT; |
There is waiting, but no deadlock: B queues behind A and both finish. And in a single statement, better still:
PostgreSQL locks the rows in the order it finds them, which is deterministic for the same query and the same plan.
Expected result
| Scenario | Result |
|---|---|
| Reverse order | ERROR: deadlock detected in one of the two sessions, after ~1 s |
| Consistent order | Both finish; B waits for A |
| A single statement | Both finish; no deadlock window |
Explanation. A deadlock is a cycle of waits: A waits for a resource held by B, and B waits for one held by A. Neither can move forward and neither is going to let go of what it holds.
PostgreSQL detects it and resolves it on its own. Every so often —deadlock_timeout, one second by default— it checks whether there is a cycle in the wait graph, and if there is, it aborts one of the transactions to break it. It chooses the victim by internal criteria; you cannot predict which one it will be. That is why the diagnosis is always the same:
ERROR: deadlock detectedis not a database failure: it is an application failure, one that has requested locks in an inconsistent order. The engine has merely detected it in time.
The three rules for not having them:
- Consistent order. Fix an ordering criterion —ascending primary key is the simplest— and apply it in all the code that locks several rows. If your process reads a list of identifiers to update, sort it first.
- Short transactions. The less time a lock is held, the smaller the window. Never make a network call, or wait for a user, with an open transaction.
- Automatic retry. Even with the two previous rules, a deadlock can happen. Every important transactional operation should be wrapped in a retry loop that catches SQLSTATE code
40P01(deadlock_detected) and tries again, typically 3 times with increasing waits.
Note: deadlocks also appear without the programmer touching two tables. Two INSERTs into tables related by a foreign key acquire locks on the parent row, and two processes inserting children of different parents in crossed order can block each other. That is why the ordering criterion must be applied to the keys, not to the tables.
Exercise 10: Optimistic locking with the version column
Difficulty: Advanced
Task. Event 106 ("Introduction to genealogy workshop", 15 seats) has 14 seats taken: one is left. Two people open the portal's registration form at the same time; both see "1 seat available" and both press "Register" a few seconds apart.
The portal cannot hold a lock while the form is on screen: the user may take minutes or go to lunch. Implement optimistic locking using the events.version column.
Set up the scenario:
UPDATE events SET offered_seats = 5, version = 1 WHERE event_id = 106;
-- 106 has 4 occupied seats (registrations of members 14, 15 and 16)
-- → with 5 offered, exactly 1 is freeSolution
| Order | Session A | Session B |
|---|---|---|
| 1 | (the user opens the form) SELECT offered_seats, version FROM events WHERE event_id=106; → 5, version 1 |
|
| 2 | (another user opens the form) same SELECT → 5, version 1 |
|
| 3 | (4 minutes go by) | (5 minutes go by) |
| 4 | BEGIN; |
|
| 5 | UPDATE events SET status='full', version = version + 1 WHERE event_id=106 AND version = 1; → UPDATE 1 |
|
| 6 | INSERT INTO registrations VALUES (106,11,DATE '2026-08-02','confirmed',0,1); |
|
| 7 | COMMIT; |
|
| 8 | BEGIN; |
|
| 9 | UPDATE events SET status='full', version = version + 1 WHERE event_id=106 AND version = 1; → UPDATE 0 |
|
| 10 | (the application detects the 0 and aborts) ROLLBACK; |
|
| 11 | Re-reads: SELECT version, status FROM events WHERE event_id=106; → version 2, full |
The application version, in plpgsql:
CREATE OR REPLACE FUNCTION register_optimistic(
p_event_id INTEGER, p_member_id INTEGER, p_seen_version INTEGER
) RETURNS TEXT AS $$
DECLARE
v_rows INTEGER;
v_free INTEGER;
BEGIN
SELECT ev.offered_seats - COALESCE(sum(r.occupied_seats), 0)
INTO v_free
FROM events ev
LEFT JOIN registrations r
ON r.event_id = ev.event_id
AND r.status IN ('confirmed','attended')
WHERE ev.event_id = p_event_id
GROUP BY ev.offered_seats;
IF v_free < 1 THEN
RETURN 'NO_SEATS';
END IF;
UPDATE events
SET version = version + 1,
status = CASE WHEN v_free = 1 THEN 'full' ELSE status END
WHERE event_id = p_event_id
AND version = p_seen_version; -- <-- the heart of the method
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows = 0 THEN
RETURN 'VERSION_CONFLICT'; -- somebody got ahead: retry
END IF;
INSERT INTO registrations (event_id, member_id, registration_date,
status, companions, occupied_seats)
VALUES (p_event_id, p_member_id, CURRENT_DATE, 'confirmed', 0, 1);
RETURN 'OK';
END;
$$ LANGUAGE plpgsql;Expected result
-- Session A
SELECT register_optimistic(106, 11, 1); --> OK
-- Session B, with the version it read (1)
SELECT register_optimistic(106, 13, 1); --> VERSION_CONFLICTAnd the final state:
| event_id | offered_seats | version | status | confirmed registrations |
|---|---|---|---|---|
| 106 | 5 | 2 | full | 4 (members 14, 15, 16 and 11) |
Member 13 is not registered and the application can show them an honest message: "the last seat has just been taken".
Explanation. Optimistic locking solves a problem that pessimistic locking cannot: the interval between reading and writing may last minutes, and holding a lock for that long is unacceptable —it would block everybody else and, if the user closes the browser, the lock stays hanging until the connection expires.
The mechanism has three pieces and all three are necessary:
- A
versioncolumn (an integer, or atimestamp, or any value that changes with every modification). - The application reads and remembers the version it saw.
- The
UPDATEincludesAND version = <the one I saw>and increments the version. If somebody got ahead, the condition does not hold and theUPDATEaffects 0 rows.
What makes it work is that UPDATE 0 is not an error: it is information. You have to check it explicitly, and that is the point most often forgotten. An UPDATE that returns 0 rows and is not checked turns optimistic locking into a decorative ornament.
Optimistic versus pessimistic:
Pessimistic (FOR UPDATE) |
Optimistic (version) |
|
|---|---|---|
| When | Read and write back to back, in the same transaction | There is a long pause between the two (form, queue, API) |
| Cost with no conflict | A held lock | None |
| Cost with a conflict | Waiting | The work is lost and has to be retried |
| When NOT to use it | Long or interactive transactions | Very frequent conflicts: endless retrying |
And the final detail: in Session B, the free-seat check (v_free) would already have returned NO_SEATS in this specific case, because A committed first. The version check is the one covering the worst case: that both sessions reach the UPDATE at the same time, when neither has seen the other's change. The two checks are not redundant: the first gives a better message, the second is the one that guarantees correctness.
Exercise 11: Consuming a queue with SKIP LOCKED
Difficulty: Advanced
Task. The notices table has 6 pending notices. Two sending processes run in parallel and each one must take 2 notices to process. No notice may be processed twice and no process should be left waiting for the other.
Part 1. Check what happens with plain FOR UPDATE.
Part 2. Solve it with FOR UPDATE SKIP LOCKED.
Solution — Part 1: FOR UPDATE blocks
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT notice_id, message FROM notices WHERE status='pending' ORDER BY notice_id LIMIT 2 FOR UPDATE; → notices 1 and 2 |
|
| 3 | BEGIN; |
|
| 4 | The same query → it waits |
Session B hangs: it wants the same two rows —they are the first two by notice_id— and they are locked. With two processes, the queue is sequential; with ten, nine are stopped. Plain FOR UPDATE turns a parallel queue into a queue of one.
Solution — Part 2: SKIP LOCKED
Close both transactions (ROLLBACK in both) and repeat:
| Order | Session A | Session B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT notice_id, type, message FROM notices WHERE status='pending' ORDER BY notice_id LIMIT 2 FOR UPDATE SKIP LOCKED; → notices 1 and 2 |
|
| 3 | BEGIN; |
|
| 4 | The same query → notices 3 and 4, with no waiting | |
| 5 | UPDATE notices SET status='sent', sent_at=now() WHERE notice_id IN (1,2); |
|
| 6 | COMMIT; |
|
| 7 | UPDATE notices SET status='sent', sent_at=now() WHERE notice_id IN (3,4); |
|
| 8 | COMMIT; |
Expected result
Step 2, Session A:
| notice_id | type | message |
|---|---|---|
| 1 | overdue | EJ-3088 was due on 26/04 |
| 2 | overdue | EJ-3093 was due on 31/05 |
Step 4, Session B:
| notice_id | type | message |
|---|---|---|
| 3 | overdue | EJ-3087 was due on 01/07 |
| 4 | reservation | Your reservation for The Map of Time is ready |
Final state of the queue:
| status | count |
|---|---|
| pending | 2 |
| sent | 4 |
Notices 5 and 6 are left for the next round. Zero overlap, zero waiting.
Explanation. SKIP LOCKED changes the locking behavior in a very specific way:
Instead of waiting for a locked row to be released, it skips it and looks for the next one matching the
WHERE.
That is exactly what a work queue needs, and it is the reason why PostgreSQL can be used as a queueing system without adding a separate component.
Four indispensable details of the pattern:
LIMITbounds the batch. Without it, the first session would lock the whole pending queue.ORDER BYgives a processing order. Here it is by identifier (FIFO); it could be by priority, by age or by whatever the business requires.- The processed mark goes in the same transaction. If the
UPDATE ... SET status='sent'were done in a different transaction, between theSELECTand it there would be a window in which the lock no longer exists and another process could pick up the same notice. - What happens if the process dies between steps 4 and 7 is the best part of the pattern: the uncommitted transaction is undone, the locks are released and the notices go back to
pending. The queue repairs itself. If instead you had marked the notices as "in process" in a separate transaction, a crash would leave them stuck in that status forever and a rescue process would be needed.
When NOT to use SKIP LOCKED. Never in a normal business query. A balance query with SKIP LOCKED would return an incomplete result —it would be missing the rows somebody is modifying— with no warning at all. SKIP LOCKED only makes sense when "any available subset" is a valid answer, and that happens in queues and in practically nothing else.
Block D — Indexes and execution plans
Exercise 12: From Seq Scan to Index Scan
Difficulty: Advanced
Task. In the production BiblioRed, loans has 912,000 rows and about 3,100 of them are open. The "my current loans" screen runs this query and takes almost a second:
EXPLAIN (ANALYZE, BUFFERS)
SELECT loan_id, copy_id, loan_date, due_date
FROM loans
WHERE member_id = 14 AND return_date IS NULL; Seq Scan on loans (cost=0.00..21455.00 rows=4 width=20)
(actual time=112.338..873.912 rows=2 loops=1)
Filter: ((return_date IS NULL) AND (member_id = 14))
Rows Removed by Filter: 911998
Buffers: shared hit=1024 read=8431
Planning Time: 0.184 ms
Execution Time: 873.984 msYou are asked to: (a) diagnose the plan; (b) propose the index, justifying its shape; (c) predict the resulting plan and estimate the improvement.
Solution
(a) Diagnosis.
| Signal in the plan | What it means |
|---|---|
Seq Scan on loans |
All 912,000 rows are read, one by one |
Rows Removed by Filter: 911998 |
Of everything read, 99.9998% is discarded |
rows=4 ... rows=2 |
The estimate (4) is reasonable; the problem is not a bad statistic |
Buffers: read=8431 |
8,431 blocks went to disk: some 66 MB of physical reads |
Execution Time: 873 ms |
Almost a second for a query returning 2 rows |
The diagnosis is unmistakable: an index is missing. The engine knows there are only 4 candidate rows (it estimates well), but it has no way of finding them without looking at them all. The ratio between rows read and rows returned —456,000 to 1— is the definition of a missing index.
(b) The proposed index.
Three decisions, and each one has its reason:
- A partial index. The condition
return_date IS NULLholds for 3,100 rows out of 912,000: 0.34%. The partial index indexes only those and takes up around 80 KB against the ~20 MB of a full index onmember_id. Besides, the rows that get closed leave the index automatically when the loan is returned, so it stays small forever. member_idas the only column. Inside the partial index, the only discrimination left to do is the member. Addingreturn_dateto the columns would be redundant: in the partial index it isNULLin every row.- It is not
UNIQUE. A member can have several open loans.
(c) The resulting plan.
Index Scan using idx_loans_open on loans
(cost=0.28..12.42 rows=4 width=20) (actual time=0.021..0.028 rows=2 loops=1)
Index Cond: (member_id = 14)
Buffers: shared hit=4
Planning Time: 0.211 ms
Execution Time: 0.049 msExpected result
| Metric | Before | After | Factor |
|---|---|---|---|
| Root node | Seq Scan |
Index Scan |
— |
| Rows read | 912,000 | 2 | 456,000× |
Blocks (Buffers) |
9,455 | 4 | 2,360× |
| Execution time | 873.98 ms | 0.05 ms | ~17,500× |
| Index size | — | ~80 KB | — |
Explanation. What matters in this exercise is not that an index speeds up a query —you knew that since 06-03—, but how the plan is read to reach that conclusion with confidence.
Rows Removed by Filter is the queen metric. It is the number of rows the engine read and threw away. If it is huge compared with the ones it returns, there is an index waiting to be created. If it is small, the Seq Scan may be the right choice: reading 500 rows sequentially is faster than jumping around an index, and the planner knows it.
Buffers distinguishes an I/O problem from a CPU problem. shared hit are blocks that were in memory; read, the ones that had to be brought from disk. Here 8,431 physical reads explain most of the 873 ms. A plan with many hit and few read that is still slow has a different problem (too many comparisons, an expensive function, a badly chosen JOIN).
The comparison between estimated rows= and actual rows= is the other key diagnosis. Here they are 4 and 2: the planner gets it right, so the problem is one of access, not of statistics. Had it estimated 4 and found 300,000, the diagnosis would be the opposite: stale statistics, and the solution ANALYZE loans; before any index.
A warning: after creating the index, run ANALYZE loans; and measure again. And do not create indexes "just in case": every index slows down writes and takes up space. An index is justified by a plan before and a plan after, as in this exercise.
Exercise 13: Indexes that exist and are not used, and choosing between two composites
Difficulty: Advanced
Task. Part 1. These three queries from the production BiblioRed do a Seq Scan even though the right indexes exist. Identify the reason for each one and rewrite the query so that the index is used. The existing indexes are:
CREATE INDEX idx_loans_date ON loans (loan_date);
CREATE INDEX idx_copies_code ON copies (code);
CREATE INDEX idx_materials_title ON materials (title);-- (a)
SELECT count(*) FROM loans WHERE EXTRACT(YEAR FROM loan_date) = 2026;
-- (b)
SELECT * FROM copies WHERE code::text = 'EJ-3081' || '';
-- (c)
SELECT material_id, title FROM materials WHERE title LIKE '%Chernobyl%';Part 2. The front desk constantly runs these two queries:
-- Q1: available copies of a material at a branch (some 900 times/hour)
SELECT copy_id, code FROM copies
WHERE material_id = 902 AND branch_id = 1 AND status = 'available';
-- Q2: full inventory of a branch by status (some 20 times/hour)
SELECT status, count(*) FROM copies
WHERE branch_id = 1 GROUP BY status;You can only create one composite index. Choose between (material_id, branch_id, status) and (branch_id, status, material_id) and reason with the leftmost prefix rule.
Solution — Part 1
(a) A function over the indexed column.
EXTRACT(YEAR FROM loan_date) is not loan_date. The B-tree index stores dates in order; it knows nothing about the extracted year. Rewriting as a range:
Now the condition is directly on the column and the index is usable. The alternative —if this query were very frequent— is an expression index:
...but the range rewrite is preferable: it works for any interval, not only for whole years.
(b) Type conversion and an expression on the column's side.
code::text forces a conversion and 'EJ-3081' || '' forces a concatenation to be evaluated. The first is the serious one: converting the column takes it out of the index. Rewriting:
General rule: transformations go on the literal's side, never on the column's side. If the parameter's type does not match, convert it yourself before passing it, or declare the parameter with the right type. This is the mistake that shows up most often when an ORM sends a varchar to an integer column or the other way round.
(c) A leading wildcard.
LIKE '%Chernobyl%' has no fixed prefix. A B-tree orders by the beginning of the string: with no known beginning, there is no range to walk. LIKE 'Chernobyl%' would use it, but it changes the meaning of the query.
The correct solution is an index of another type, GIN with trigrams:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_materials_title_trgm
ON materials USING gin (title gin_trgm_ops);
-- The query does not change and now it does use the index:
SELECT material_id, title FROM materials WHERE title ILIKE '%chernobyl%';The trigram index breaks each title down into sequences of three characters and can answer substring searches at any position. For text search by whole words, the alternative is tsvector + GIN, which also handles stemming and stop words.
Solution — Part 2
The index to create is (material_id, branch_id, status). Reasoning:
| Query | With (material_id, branch_id, status) |
With (branch_id, status, material_id) |
|---|---|---|
Q1 (material_id, branch_id, status) |
Optimal: the three columns are used as a complete prefix. The index is walked all the way | Also usable: the three conditions are equalities, so the order does not prevent its use |
Q2 (branch_id + GROUP BY status) |
Useless: branch_id is the second column, and with no condition on material_id there is no prefix. Seq Scan |
Optimal: branch_id is the prefix, and status comes next, so the rows come out already grouped |
Here both options are good for Q1 and only the second is good for Q2. So why choose the first?
Because the frequencies are 900 against 20 per hour, and because Q1 is the interactive query. Q1 is run by a person waiting at the front desk; Q2 is a report that can take 200 ms without anybody complaining. With (material_id, branch_id, status), Q1 goes straight to the small set of copies of that material and the index is very selective from the first column: there are 40,000 materials, so material_id = 902 narrows it down to a handful of rows. With (branch_id, status, material_id), Q1 starts with branch_id, which has only 4 distinct values: the first level of the index barely discriminates, and a much larger part has to be walked.
The leftmost prefix rule, stated precisely:
A composite index
(A, B, C)can be used for conditions onA, onA, Band onA, B, C. It cannot be used for conditions that only mentionB,CorB, C.
And the design rule that follows: the most selective, always-present column goes first. "Always present" weighs more than "most selective": an index whose first column is missing from the query is not used at all.
Expected result
| Query | Problem | Solution |
|---|---|---|
(a) EXTRACT(YEAR ...) |
A function over the column | Rewrite as a date range |
(b) code::text = ... |
A type conversion over the column | Compare directly, with no conversion |
(c) LIKE '%...%' |
Leading wildcard, no prefix | GIN index with pg_trgm |
| Part 2 | A single index for two queries | (material_id, branch_id, status), prioritizing Q1 |
Explanation. The three cases in part 1 share a single cause: the index indexes the column, not an expression over the column. As soon as the condition transforms the column's value —with a function, with a conversion, with a concatenation—, the engine can no longer search the index, because it does not know what relationship there is between the order of the original values and that of the transformed ones.
The way to spot it in a plan is direct: if the node shows Filter: with an expression around the column name, the index is not being used for that. If it shows Index Cond: with the bare column, it is.
About part 2, three nuances worth keeping in mind:
- The answer changes if the data changes. If BiblioRed had 400 branches instead of 4,
branch_idwould be much more selective and the choice would get closer. Indexing decisions depend on the real cardinality, and that is measured (SELECT count(DISTINCT ...)), not assumed. - PostgreSQL can use an index without its prefix, but badly. With
enable_seqscan = offyou would see anIndex Scanwalking the whole index comparing every entry. It is worse than theSeq Scanand that is why the planner does not choose it. "It can use it" does not mean "it helps". - The rule does not apply the same way to GIN and BRIN indexes, which are not ordered trees. The leftmost prefix rule is a property of B-trees.
Common Mistakes and Tips
1. Filtering by a window function in the WHERE. You cannot: they are evaluated afterwards. Wrap it in a CTE or a derived table.
2. Confusing ROW_NUMBER, RANK and DENSE_RANK. For a "top 1 per group" only ROW_NUMBER works; RANK returns all the tied ones.
3. LAG over a series with gaps. It compares with the previous present row, not with the previous period. Generate the complete time axis and left-join it.
4. Forgetting the stop condition of a recursive CTE. Infinite loop. And if the graph can have cycles, use CYCLE or drag the walked path along.
5. Comparing -> with text. -> 'channel' = 'web' finds nothing. Use ->> for text and @> when you want to take advantage of the GIN index.
6. count(CASE WHEN ... THEN 1 ELSE 0 END) in a pivot. It counts the zeros too. Use FILTER or drop the ELSE 0.
7. Check-then-act with no FOR UPDATE. Between the SELECT that confirms and the UPDATE that acts there is a window. If the computation fits inside the UPDATE itself, make it atomic and you will not need a lock.
8. Ignoring an UPDATE 0. In optimistic locking it is the conflict signal; in a business transaction it is usually a silent failure. Always check it and decide explicitly whether it is acceptable.
9. Believing that an error inside a transaction can be ignored. In PostgreSQL it aborts the whole transaction and the COMMIT answers ROLLBACK. Use SAVEPOINT (or EXCEPTION blocks in plpgsql) in every batch process.
10. Locking rows in different orders. It is the cause of 90% of deadlocks. Fix an order —ascending primary key— and respect it throughout the code.
11. Raising the isolation level without implementing retries. REPEATABLE READ and SERIALIZABLE abort legitimate transactions on serialization conflicts. With no retry loop, you swap inconsistent data for errors in production.
12. Using SKIP LOCKED outside a queue. It returns incomplete results with no warning.
13. Transforming the column in the WHERE. EXTRACT, lower(), ::text, ||: any of them disables the index. Transformations go on the literal.
14. Creating indexes without measuring. Every index slows down writes. An index is justified with an EXPLAIN ANALYZE before and another one after.
A method tip for the concurrency block. When a session hangs and you do not know why, open a third one and query who is blocking whom:
SELECT pid, state, wait_event_type, wait_event,
left(query, 60) AS query,
pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE datname = current_database() AND state <> 'idle';pg_blocking_pids returns the list of processes blocking each one. It is the tool that saves the most time in a real production incident.
Exercises
No hints and more demanding.
Exercise A: Event performance report by branch
In a single query, return per branch: number of events held, offered seats, occupied seats, occupancy percentage, average rating weighted by the number of survey responses, and the branch's position in the rating ranking. Use jsonb for the responses and a window function for the ranking.
Exercise B: Return transaction with a surcharge, resistant to concurrency
Write the function register_return(p_copy_id) which, in a single transaction: locates the open loan of that copy and locks it, records today's return date, puts the copy back to available status, and —if there is a delay— issues a fine of €0.20/day with a cap of €15.00, without duplicating it if the process runs twice. It must fail cleanly if the copy has no open loan. Explain what happens if two sessions call it at the same time on the same copy.
Exercise C: Diagnosing a plan with a Nested Loop
Diagnose this plan from the production BiblioRed, say which is the problematic node, what the root cause is and which two interventions you would propose, in order of priority.
HashAggregate (cost=48211.02..48214.02 rows=300 width=40)
(actual time=6841.220..6841.402 rows=4 loops=1)
Group Key: b.name
-> Nested Loop (cost=0.29..48196.02 rows=3000 width=32)
(actual time=0.412..6802.118 rows=418 loops=1)
-> Seq Scan on materials m (cost=0.00..1204.00 rows=30 width=8)
(actual time=0.098..38.442 rows=127 loops=1)
Filter: (lower(title) ~~ '%chernobyl%'::text)
Rows Removed by Filter: 39873
-> Index Scan using idx_copies_material on copies c
(cost=0.29..1599.50 rows=100 width=32)
(actual time=1.204..53.210 rows=3 loops=127)
Index Cond: (material_id = m.material_id)
Planning Time: 1.882 ms
Execution Time: 6841.688 msSolutions
Solution A
WITH occupancy AS (
SELECT ev.event_id, ev.room_id, ev.offered_seats,
COALESCE(sum(r.occupied_seats) FILTER (
WHERE r.status IN ('confirmed','attended')), 0) AS occupied
FROM events ev
LEFT JOIN registrations r ON r.event_id = ev.event_id
WHERE ev.status = 'held'
GROUP BY ev.event_id, ev.room_id, ev.offered_seats
),
surveys AS (
SELECT er.event_id,
count(*) AS n_responses,
avg((r ->> 'score')::int) AS avg_score
FROM event_reports er,
LATERAL jsonb_array_elements(er.survey_responses -> 'responses') AS r
GROUP BY er.event_id
),
by_branch AS (
SELECT b.branch_id, b.name AS branch,
count(*) AS events,
sum(o.offered_seats) AS offered,
sum(o.occupied) AS occupied,
round(100.0 * sum(o.occupied) / sum(o.offered_seats), 1) AS pct_occupancy,
round(sum(s.avg_score * s.n_responses) / sum(s.n_responses), 2) AS rating
FROM occupancy o
JOIN rooms ro ON ro.room_id = o.room_id
JOIN branches b ON b.branch_id = ro.branch_id
LEFT JOIN surveys s ON s.event_id = o.event_id
GROUP BY b.branch_id, b.name
)
SELECT branch, events, offered, occupied, pct_occupancy, rating,
RANK() OVER (ORDER BY rating DESC) AS place
FROM by_branch
ORDER BY place;| branch | events | offered | occupied | pct_occupancy | rating | place |
|---|---|---|---|---|---|---|
| South | 1 | 8 | 5 | 62.5 | 4.75 | 1 |
| East | 1 | 10 | 4 | 40.0 | 4.67 | 2 |
| North | 1 | 20 | 9 | 45.0 | 4.00 | 3 |
| Central | 2 | 52 | 15 | 28.8 | 3.88 | 4 |
Central comes out at 3.88 because its rating is the weighted average of its two events: 101 (4.50 with 4 responses) and 104 (3.25 with 4 responses). (4.50×4 + 3.25×4) / 8 = 3.875. Had the average of the averages been taken it would come out the same by coincidence —both have 4 responses—, but as soon as the sizes differ, the average of averages is wrong. That is the point of the exercise: weight by the number of responses, do not average averages.
A note on the three CTEs: each one aggregates at its own level to avoid the row-multiplication problem of exercise 15 of 07-01. Joining registrations and event_reports in the same JOIN would make each survey response repeat once per registration.
Solution B
CREATE OR REPLACE FUNCTION register_return(p_copy_id INTEGER)
RETURNS TABLE (loan INTEGER, days_late INTEGER, fine NUMERIC) AS $$
DECLARE
v_loan RECORD;
v_days INTEGER;
v_amount NUMERIC(8,2) := 0;
v_fine_id INTEGER;
BEGIN
-- 1) Locate and LOCK the open loan of that copy
SELECT l.loan_id, l.member_id, l.due_date
INTO v_loan
FROM loans l
WHERE l.copy_id = p_copy_id AND l.return_date IS NULL
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Copy % has no open loan', p_copy_id
USING ERRCODE = 'no_data_found';
END IF;
-- 2) Close the loan
UPDATE loans SET return_date = CURRENT_DATE
WHERE loan_id = v_loan.loan_id;
-- 3) Put the copy back into circulation
UPDATE copies SET status = 'available'
WHERE copy_id = p_copy_id;
-- 4) A fine if applicable, without duplicating it
v_days := CURRENT_DATE - v_loan.due_date;
IF v_days > 0 THEN
v_amount := LEAST(v_days * 0.20, 15.00);
INSERT INTO fines (member_id, loan_id, reason, amount,
issue_date, status)
VALUES (v_loan.member_id, v_loan.loan_id, 'late_return',
v_amount, CURRENT_DATE, 'pending')
ON CONFLICT (loan_id, reason) DO NOTHING
RETURNING fine_id INTO v_fine_id;
IF v_fine_id IS NULL THEN
v_amount := 0; -- it already existed: not duplicated
END IF;
END IF;
RETURN QUERY SELECT v_loan.loan_id, GREATEST(v_days, 0), v_amount;
END;
$$ LANGUAGE plpgsql;What happens with two simultaneous sessions on the same copy. The first runs the SELECT ... FOR UPDATE and locks the loan's row. The second waits at that same SELECT. When the first commits, the second unblocks and re-evaluates the WHERE against the updated version of the row: since return_date is no longer NULL, the row stops matching the condition, FOUND is false and the function raises "has no open loan". It is exactly the desired behavior: the second return of the same copy is an error, not a silent duplication.
The ON CONFLICT ... DO NOTHING is the second safety net, in case the process is retried after a network failure that left the transaction committed but with no response.
Solution C
Problematic node: the inner Index Scan of the Nested Loop. Look at loops=1 against loops=127: that node is executed 127 times, once per row of the outer side, and each execution takes about 53 ms → 127 × 53 ≈ 6,700 ms, which is practically the whole time of the query.
Root cause: a badly wrong estimate in the outer Seq Scan. The planner estimated rows=30 for the filter lower(title) ~~ '%chernobyl%' and found 127. With 30 iterations expected, the Nested Loop looked cheap; with 127 actual ones, it comes out four times more expensive than calculated. The bad estimate is unavoidable: PostgreSQL has no useful statistics for a LIKE with a leading wildcard over a function, and applies a default selectivity.
Two interventions, in order of priority:
- A GIN index with trigrams on
title(pg_trgm). It attacks the root cause: it turns theSeq Scanover 40,000 rows into an indexed access, removes the 39,873 discarded rows and, incidentally, radically improves the estimate, because the index lets the planner bound the number of rows better. With that, theNested Loopgoes on to iterate over a small and correctly estimated set. - Raising the statistics target of
materials.title(ALTER TABLE materials ALTER COLUMN title SET STATISTICS 500; ANALYZE materials;). It is a complementary patch: it does not fix theSeq Scan, but it improves the estimate and may lead the planner to choose aHash Joininstead of theNested Loop, which for 127 × 3 rows would be more stable.
What you must not do is touch the inner Index Scan: idx_copies_material works correctly —3 rows per iteration, exactly what it should— and its only problem is that it is called 127 times. In a slow Nested Loop, the culprit is almost never the inner node: it is the number of iterations the outer one imposes on it.
Conclusion
You have closed the module with the thirteen most demanding exercises of the course, and with them you have practiced the complete repertoire of a database professional in production: window functions for the top N per group, rankings with ties and period-against-period comparisons; recursive CTEs to generate time axes with no gaps and to walk hierarchies; jsonb with its navigation, containment and unnesting operators, and the manual pivot with FILTER. Then, real transactions: the loan one with its FOR UPDATE, its check of affected rows and its error handling; SAVEPOINT so that a batch does not die because of one element; and the exact reasoning about what survives a sequence of savepoints. The five two-session exercises have made you see with your own eyes a lost update, the difference between READ COMMITTED and REPEATABLE READ, a deadlock detected by the engine, optimistic locking saving the last seat and a queue consumed in parallel with SKIP LOCKED. And the last two have taught you to read a plan: Rows Removed by Filter, Buffers, loops, the distance between estimated and actual rows, and why an index that exists may be of no use at all.
If there is one idea that sums up the whole module, it is this: in production, the failures that matter do not raise errors. A COUNT(*) that counts one where it should count zero, a report that multiplies the offered seats by the number of registrations, a surcharge that disappears because two clerks applied it at the same time, a batch that commits after having aborted, an index that exists and that the query does not use. None of them shows up in an error log. All of them are detected the same way: by checking the number of rows, by reading the plan, by running the scenario in two sessions and by distrusting the results that come out right first time.
With this lesson module 7 ends, and so does the part of the course in which you wrote isolated queries. What comes next is the whole system. Module 8, Case Studies, goes through three complete projects from beginning to end: in 08-01 a relational system with the full cycle —requirements, model, schema, queries, indexes and operation—; in 08-02 a non-relational case where the same problem is modeled in documents and you check what is gained and what is lost; and in 08-03 polyglot persistence, where a single application combines a relational engine for the transactions, a document store for the catalog, a key-value one for the session and a search one for free text, and you have to decide which datum lives where and how they are kept consistent with each other. After that, module 9 gathers the books, courses and tools with which to carry on by yourself. You can close the second terminal now: in module 8 we go back to looking at the complete blueprint.
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
