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

  1. Block A — Advanced queries: windows, recursion and jsonb (exercises 1-3)
  2. Block B — Transactions and error handling (exercises 4-6)
  3. Block C — Concurrency: five two-session exercises (exercises 7-11)
  4. Block D — Indexes and execution plans (exercises 12-13)
  5. Common mistakes and tips
  6. 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, RANK and DENSE_RANK in 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_categories table: 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:

WITH RECURSIVE tree AS ( ... )
CYCLE category_id SET is_cycle USING path_

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_elements and 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:

  1. Checks that the copy is really available, locking it.
  2. Inserts the loan with a 21-day term from August 2, 2026.
  3. Marks the copy as on_loan.
  4. 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
COMMIT

And 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:

ERROR:  Copy 3082 is not available (status: on_loan)

...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 skipped

Specifically:

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 final COMMIT behaves like a ROLLBACK.

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:

SELECT speaker_id, first_name, last_name FROM speakers ORDER BY speaker_id;
speaker_id first_name last_name
1 Rosa Calduch
2 Aitor Lemus
3 Delia Marchetti
4 Lidia Serna
SELECT event_id, offered_seats, status, published FROM events WHERE event_id IN (106,107);
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;
ROLLBACK

Absolutely 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 sp undoes what happened after sp. What happened before sp —including what lies between sp1 and sp2— 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 UPDATE is 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 UPDATE is necessary when the computation does not fit into the UPDATE: 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 version column (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:

ERROR:  could not serialize access due to concurrent update

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:

UPDATE members SET active = TRUE WHERE member_id IN (14, 15);

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 detected is 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:

  1. 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.
  2. 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.
  3. 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 free

Solution

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 SELECT5, 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_CONFLICT

And 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:

  1. A version column (an integer, or a timestamp, or any value that changes with every modification).
  2. The application reads and remembers the version it saw.
  3. The UPDATE includes AND version = <the one I saw> and increments the version. If somebody got ahead, the condition does not hold and the UPDATE affects 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:

SELECT status, count(*) FROM notices GROUP BY status;
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:

  1. LIMIT bounds the batch. Without it, the first session would lock the whole pending queue.
  2. ORDER BY gives a processing order. Here it is by identifier (FIFO); it could be by priority, by age or by whatever the business requires.
  3. The processed mark goes in the same transaction. If the UPDATE ... SET status='sent' were done in a different transaction, between the SELECT and it there would be a window in which the lock no longer exists and another process could pick up the same notice.
  4. 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 ms

You 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.

CREATE INDEX idx_loans_open
    ON loans (member_id)
    WHERE return_date IS NULL;

Three decisions, and each one has its reason:

  • A partial index. The condition return_date IS NULL holds 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 on member_id. Besides, the rows that get closed leave the index automatically when the loan is returned, so it stays small forever.
  • member_id as the only column. Inside the partial index, the only discrimination left to do is the member. Adding return_date to the columns would be redundant: in the partial index it is NULL in 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 ms

Expected 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:

SELECT count(*) FROM loans
WHERE loan_date >= DATE '2026-01-01'
  AND loan_date <  DATE '2027-01-01';

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:

CREATE INDEX idx_loans_year ON loans (EXTRACT(YEAR FROM loan_date));

...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:

SELECT * FROM copies WHERE code = 'EJ-3081';

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 on A, on A, B and on A, B, C. It cannot be used for conditions that only mention B, C or B, 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_id would 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 = off you would see an Index Scan walking the whole index comparing every entry. It is worse than the Seq Scan and 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 ms

Solutions

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:

  1. A GIN index with trigrams on title (pg_trgm). It attacks the root cause: it turns the Seq Scan over 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, the Nested Loop goes on to iterate over a small and correctly estimated set.
  2. 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 the Seq Scan, but it improves the estimate and may lead the planner to choose a Hash Join instead of the Nested 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.

© Copyright 2026. All rights reserved