We go back to looking at the complete blueprint, as we promised when closing module 7. For seven modules you have built BiblioRed out of pieces: a table here, a JOIN there, an index when something ran slow. Each piece was explained separately because it had to be learned separately. In a real project nobody hands you the pieces in order: they hand you a conversation with a client and you have to get from there to a production system on your own.

That is what this lesson does. Vallmar City Council, happy with the library network, has just commissioned a second system: VallBici, the municipal bike-sharing service. It is not an extension of BiblioRed; it is a new domain, with its own rules, its own concurrency problems and its own reports. BiblioRed will show up now and then as a point of comparison —"we already solved this that way, here it changes because…"—, but the work is new from start to finish.

The route is that of a real project and in its real order: the commission, the written requirements, the conceptual model, the physical schema, the normalization check, the data load, the critical transactions, the reports, performance and operations. At every point where something has to be decided, the decision comes with its justification and the alternatives that were discarded. That is the content of the lesson: not the final SQL —which is copied in ten minutes— but the reasoning that leads to it.

And it ends where an honest case study has to end: by listing the three things this schema handles badly, which are exactly the material of lesson 08-02.

Contents

  1. The commission: the initial conversation
  2. VallBici requirements document v1.0
  3. Conceptual model: entities, relationships and three hard decisions
  4. The ER diagram
  5. Physical schema: the annotated CREATE TABLE
  6. The constraints that encode the business rules
  7. Normalization check and two deliberate denormalizations
  8. Data load and realistic volume
  9. The two critical transactions: unlock and dock
  10. Reporting queries: the city council's reports
  11. Performance: the two queries that degrade
  12. Operations: roles, personal data and backups
  13. What this schema handles badly
  14. Common Mistakes and Tips
  15. Exercises
  16. Conclusion

  1. The commission: the initial conversation

The first meeting with the council's Mobility department produces this, transcribed almost verbatim:

"We have 60 stations spread across the city's five districts and 900 bicycles, some mechanical and some electric. Each station has a fixed number of docks; a bike occupies a dock when it is parked. There are 24,000 subscribers. A person unlocks a bike at one station, makes their trip and docks it at another. There are three subscription types —annual, monthly and a three-day tourist one— and if you go over the included time a surcharge is billed. Bikes go through inspections, break down and are pulled into the workshop. The mobile app has to show availability in real time, remember the session, let people search for stations by name or by address and store the GPS trace of every trip. And we want monthly usage reports by district and time slot."

We apply the technique from 04-01: underline the nouns (entity candidates), the verbs (relationship candidates) and the phrases containing "cannot", "always", "each" (constraint candidates). But first we have to do something 04-01 insisted on a lot: ask again. A two-paragraph commission always hides decisions the client takes as obvious that are not.

Question to the client Answer Design consequence
Can a dock be broken without the station being broken? Yes, it happens often The dock needs its own status
Can a person hold two subscriptions at once? Never overlapping; consecutive, yes Non-overlap constraint
And two open trips at once? Impossible: you only unlock through the app Partial unique index
If the fare goes up, does the price of already-billed trips change? Never. It would be illegal Frozen fare on the trip
What happens if a bike disappears? It is withdrawn, but its history is kept ON DELETE RESTRICT + logical withdrawal
Are electric and mechanical billed the same? Electric carries a fixed unlock surcharge The hierarchy affects the price
How often do you want the reports? Monthly, but the operations dashboard is daily Two different query profiles

Those seven answers are worth more than the next hundred lines of SQL. The fourth one in particular is what avoids the expensive mistake: if the fare is not frozen, the first price change rewrites two years of billing.

  1. VallBici requirements document v1.0

Functional requirements

Id Requirement
FR1 Register subscribers with their contact details and their district of residence
FR2 Sell annual, monthly and 3-day tourist subscriptions, with their validity period
FR3 Maintain the inventory of stations, docks and bicycles, with their status
FR4 Record the unlocking of a bicycle and its later docking, with exact instants
FR5 Compute the amount of each trip according to the fare in force at the moment of unlocking
FR6 Record inspections and breakdowns, and pull bicycles into the workshop
FR7 Publish the availability of each station
FR8 Produce the reporting queries of the next section

Business rules

Id Rule Where it is encoded
BR1 A dock holds at most one bicycle PRIMARY KEY of docks
BR2 A bicycle is in at most one dock UNIQUE (bicycle_id)
BR3 A person cannot hold two subscriptions with overlapping validity EXCLUDE USING gist
BR4 A bicycle cannot have two open trips Partial unique index
BR5 A person cannot have two open trips Partial unique index
BR6 The docking instant is later than the unlocking instant CHECK
BR7 A closed trip has destination station, destination dock, end instant and amount; an open one has none of the four Joint CHECK
BR8 The number of bikes at a station never exceeds its number of docks CHECK
BR9 The fare applied to a trip does not change even if the fares change Fare copy in trips
BR10 A trip can only be started with a subscription in force Transaction logic

Queries the city council wants to be able to answer

This list is part of the requirement, not an extra. It is what in 04-01 we called the schema's success criterion: a model that cannot answer them is a failed model, however elegant it may be.

  • Q1 — Trips by district and time slot, month by month.
  • Q2 — The ten most frequent origin→destination station pairs.
  • Q3 — Stations that systematically empty out or fill up, by slot (the rebalancing problem: this is the one that really costs money, because it forces you to move bikes by van).
  • Q4 — Revenue by subscription type, separating fees from surcharges.
  • Q5 — Bicycles with the most breakdowns per hour of use (not in absolute terms: a heavily used bike breaks down more and that does not make it a bad one).

  1. Conceptual model: entities, relationships and three hard decisions

The text yields these without discussion: district, station, dock, bicycle, bike model, subscriber, subscription type, fare, subscription, trip, workshop order and charge. What is up for discussion are three decisions, and they are the ones that separate a model that holds up from one that does not.

Decision 1 — Is the dock an entity or a number inside the station?

The temptation: store in stations a total_docks column and an available_bikes one, and not model the dock at all. It is simpler and apparently sufficient: to draw the app you only need to know how many bikes there are.

Why it is discarded. Three reasons, in order of weight:

  1. The client said that a dock can break down on its own. An attribute has no status; an entity does. Without docks, a station with 20 docks and 3 broken ones still "has capacity 20" and the system promises slots that do not exist.
  2. The app has to tell the person which dock holds the bike they reserved and which one they should leave it in. That data does not exist if the dock does not exist.
  3. BR1 and BR2 —"one dock, one bike; one bike, one dock"— are integrity constraints, and in module 4 we set the principle: a constraint the schema can enforce is not delegated to code. With docks as a table they are a primary key and a UNIQUE; without it, they are application code and trust.

How it is modeled: the dock is a weak entity of the station (rule 8 of 04-03), with composite primary key (station_id, dock_number). The number 7 only means something inside a specific station, exactly as the receipt number of a fine depended on the fine in BiblioRed.

Decision 2 — Is the trip a relationship or an entity?

A trip connects a person (via their subscription), a bicycle and two stations. In the terms of 04-02 it would be a degree-4 relationship, and high-degree relationships are almost always a symptom of a missing entity.

It is modeled as an entity, and for four reasons:

  1. It has its own and abundant attributes: two instants, duration, amount, frozen fare.
  2. It has identity: the same person can make the same trip between the same two stations with the same bike twice on the same day, and those are two distinct facts. A junction table with a composite key would confuse them.
  3. It is born incomplete. When the bike is unlocked only half of the trip is known. A relationship that exists halfway is an entity with null columns, not a relationship.
  4. Other things reference it: the surcharge charge points at the trip.

The two stations are two distinct 1:N relationships toward stations (rule 5 of 04-03): origin_station and destination_station. It is not a rare case: it is the same pattern as "flight with departure and arrival airport", and the only precaution is not to forget that the two foreign keys point at the same table, which forces you to use aliases in every query that uses them.

Fine detail: the foreign key does not point at stations but at docks(station_id, dock_number), because we care about knowing which specific dock it left from and which one it entered. The station is determined by the dock.

Decision 3 — The mechanical / electric hierarchy

Every bicycle is either mechanical or electric (a total and disjoint hierarchy). Electric ones have three attributes that make no sense for mechanical ones: battery capacity, range and battery serial number. In 04-03, rule 10, we saw the three strategies. Let us go over the decision with the criteria from there:

Criterion Single table Table per subclass Table per concrete class
Specific attributes 3 null columns in 900 rows No nulls No nulls
Can trips reference the superclass? Yes Yes No — it would need two FKs
"All the bikes at station 12" Trivial Trivial UNION of two branches
battery_serial mandatory and unique only on electric ones Impossible with NOT NULL Direct Direct
Cost of the typical query None An occasional LEFT JOIN UNION always

We choose table per subclass (strategy 2), just as we did with BiblioRed's materials and for the same dominant reason: there are tables that reference the superclass. trips, docks and workshop_orders point at "a bicycle", regardless of type, and strategy 3 would make that impossible without duplicating every foreign key.

Strategy 1 (single table) was defensible —they are only three columns— and with 900 rows the waste is irrelevant. It is discarded on the fourth criterion: battery_serial has to be mandatory and unique on electric ones, and in a single table it could only be optional. It is exactly the argument that in BiblioRed made us split off materials_book for the isbn.

And as in 04-03, strategy 2 drags along its known hole: nothing on its own prevents a bike with type = 'mechanical' from having a row in electric_bicycles. That is plugged with the discriminated foreign key trick, which you will see in the schema.

  1. The ER diagram

erDiagram
    DISTRICT      ||--o{ STATION            : groups
    DISTRICT      ||--o{ SUBSCRIBER         : "resides in"
    STATION       ||--|{ DOCK               : contains
    DOCK          |o--o| BICYCLE            : holds
    BIKE_MODEL    ||--o{ BICYCLE            : "is of model"
    BICYCLE       ||--o| ELECTRIC_BICYCLE   : "specializes into"
    BICYCLE       ||--o{ TRIP               : "is used in"
    BICYCLE       ||--o{ WORKSHOP_ORDER     : "goes through"
    SUBSCRIBER    ||--o{ SUBSCRIPTION       : contracts
    SUBSCRIPTION_TYPE ||--o{ SUBSCRIPTION   : classifies
    SUBSCRIPTION_TYPE ||--o{ FARE           : "is priced by"
    FARE          ||--o{ SUBSCRIPTION       : "sets price of"
    FARE          ||--o{ TRIP               : "frozen into"
    SUBSCRIPTION  ||--o{ TRIP               : authorizes
    DOCK          ||--o{ TRIP               : "is origin of"
    DOCK          ||--o{ TRIP               : "is destination of"
    SUBSCRIBER    ||--o{ CHARGE             : pays
    SUBSCRIPTION  ||--o{ CHARGE             : "generates fee"
    TRIP          ||--o{ CHARGE             : "generates surcharge"

Read it with the notation from 04-02: || is total participation and cardinality 1, o{ is cardinality N with partial participation, |{ is N with total participation. The fact that STATION ||--|{ DOCK is total on both sides says something true and non-trivial: a station with no docks at all is not a station, and a dock without a station does not exist. The fact that DOCK |o--o| BICYCLE is partial on both sides says the opposite: there are empty docks and there are bikes outside any dock (in circulation or in the workshop).

  1. Physical schema: the annotated CREATE TABLE

Two extensions before we start. btree_gist lets you mix in an EXCLUDE equality columns (an integer) with overlap columns (a range), which is exactly what BR3 and the fares ask for.

CREATE EXTENSION IF NOT EXISTS btree_gist;

Inventory

CREATE TABLE districts (
    district_id  SMALLINT     PRIMARY KEY,           -- 5 rows: natural key, stable
    name         VARCHAR(40)  NOT NULL UNIQUE
);

CREATE TABLE stations (
    station_id       INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code             CHAR(6)      NOT NULL UNIQUE,   -- 'VB-012', the one on the sign
    name             VARCHAR(80)  NOT NULL,
    address          VARCHAR(120) NOT NULL,
    district_id      SMALLINT     NOT NULL REFERENCES districts ON DELETE RESTRICT,
    latitude         NUMERIC(9,6) NOT NULL CHECK (latitude  BETWEEN  -90 AND  90),
    longitude        NUMERIC(9,6) NOT NULL CHECK (longitude BETWEEN -180 AND 180),
    total_docks      SMALLINT     NOT NULL CHECK (total_docks BETWEEN 8 AND 40),
    status           VARCHAR(14)  NOT NULL DEFAULT 'active'
                     CHECK (status IN ('active','maintenance','retired')),
    join_date        DATE         NOT NULL DEFAULT CURRENT_DATE,
    -- Deliberate denormalization no. 1 (justified in section 7)
    available_bikes  SMALLINT     NOT NULL DEFAULT 0 CHECK (available_bikes >= 0),
    CONSTRAINT chk_fits_in_station CHECK (available_bikes <= total_docks)  -- BR8
);

Why these types, one by one:

Column Type chosen Discarded alternative and why
district_id natural SMALLINT IDENTITY: five districts that never change do not need a surrogate key
station_id INTEGER IDENTITY SERIAL: obsolete since PostgreSQL 10; IDENTITY is standard SQL and leaves no orphan sequences
code CHAR(6) It would be the natural key, but a sign gets repainted: it stays as UNIQUE, not as PK
latitude/longitude NUMERIC(9,6) FLOAT: exact decimal precision and ≈11 cm of resolution are enough. When real geometry is needed, PostGIS (we discuss it in 08-02)
total_docks SMALLINT INTEGER: no station will have 33,000 docks
status VARCHAR + CHECK ENUM: adding a value to an ENUM requires ALTER TYPE; a CHECK is changed with ALTER TABLE and is readable in \d
CREATE TABLE bike_models (
    model_id     SMALLINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    manufacturer VARCHAR(40) NOT NULL,
    name         VARCHAR(40) NOT NULL,
    type         VARCHAR(10) NOT NULL CHECK (type IN ('mechanical','electric')),
    weight_kg    NUMERIC(4,1) NOT NULL CHECK (weight_kg > 0),
    UNIQUE (manufacturer, name)
);

CREATE TABLE bicycles (
    bicycle_id     INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    plate          CHAR(7)     NOT NULL UNIQUE,      -- 'VB-0417'
    model_id       SMALLINT    NOT NULL REFERENCES bike_models ON DELETE RESTRICT,
    type           VARCHAR(10) NOT NULL CHECK (type IN ('mechanical','electric')),
    join_date      DATE        NOT NULL DEFAULT CURRENT_DATE,
    status         VARCHAR(10) NOT NULL DEFAULT 'docked'
                   CHECK (status IN ('docked','in_use','workshop','withdrawn')),
    accumulated_km NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (accumulated_km >= 0),
    -- Redundant as a key, but necessary: it is the anchor of the discriminant
    CONSTRAINT uq_bicycle_type UNIQUE (bicycle_id, type)
);

CREATE TABLE electric_bicycles (
    bicycle_id     INTEGER     PRIMARY KEY,
    type           VARCHAR(10) NOT NULL DEFAULT 'electric' CHECK (type = 'electric'),
    capacity_wh    SMALLINT    NOT NULL CHECK (capacity_wh > 0),
    range_km       SMALLINT    NOT NULL CHECK (range_km BETWEEN 10 AND 200),
    battery_serial VARCHAR(24) NOT NULL UNIQUE,
    FOREIGN KEY (bicycle_id, type)
        REFERENCES bicycles (bicycle_id, type) ON DELETE CASCADE
);

The discriminant trick, explained. The composite foreign key (bicycle_id, type) forces the referenced row in bicycles to have type = 'electric', because this table's CHECK pins type to that value. Result: it is impossible to register a battery for a mechanical bike. The hole that 04-03 left open in strategy 2 is plugged in the half that matters. The other half —an electric one having no row here— still cannot be enforced declaratively and is controlled in the registration process.

CREATE TABLE docks (
    station_id  INTEGER     NOT NULL REFERENCES stations ON DELETE CASCADE,
    dock_number SMALLINT    NOT NULL CHECK (dock_number > 0),
    status      VARCHAR(10) NOT NULL DEFAULT 'operational'
                CHECK (status IN ('operational','broken','blocked')),
    bicycle_id  INTEGER     REFERENCES bicycles ON DELETE SET NULL,
    PRIMARY KEY (station_id, dock_number),                 -- BR1: weak entity
    CONSTRAINT uq_bicycle_in_one_dock UNIQUE (bicycle_id)  -- BR2
);

The two most important rules of the system are two lines of schema. The composite primary key stops dock 7 of station 12 from holding two bikes. The UNIQUE (bicycle_id) stops bike 417 from being in two docks simultaneously; it works because in PostgreSQL UNIQUE accepts as many NULLs as it likes, so all the empty docks coexist without conflict.

ON DELETE CASCADE from docks toward stations is justified: a dock has no life of its own outside its station (it is the action that 02-06 reserved precisely for weak entities). ON DELETE SET NULL toward bicycles is the right thing for the opposite: if a bike were to disappear from the system, the dock should be left free, not disappear.

People, subscriptions and fares

CREATE TABLE subscribers (
    subscriber_id     INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    document_hash     CHAR(64)     NOT NULL UNIQUE,   -- SHA-256 with salt; see section 12
    first_name        VARCHAR(60)  NOT NULL,
    last_name         VARCHAR(80)  NOT NULL,
    email             VARCHAR(120) NOT NULL UNIQUE,
    phone             VARCHAR(20),
    birth_date        DATE         NOT NULL,
    district_id       SMALLINT     REFERENCES districts ON DELETE SET NULL,
    registration_date TIMESTAMPTZ  NOT NULL DEFAULT now(),
    withdrawn_date    DATE,
    CONSTRAINT chk_minimum_age CHECK (birth_date <= CURRENT_DATE - INTERVAL '14 years'),
    CONSTRAINT chk_withdrawal_after CHECK (withdrawn_date IS NULL
                                    OR withdrawn_date >= registration_date::date)
);

CREATE TABLE subscription_types (
    subscription_type VARCHAR(12) PRIMARY KEY
                      CHECK (subscription_type IN ('annual','monthly','tourist')),
    description       VARCHAR(60) NOT NULL,
    duration          INTERVAL    NOT NULL             -- '1 year', '1 mon', '3 days'
);

CREATE TABLE fares (
    fare_id            INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscription_type  VARCHAR(12)  NOT NULL REFERENCES subscription_types ON DELETE RESTRICT,
    validity           DATERANGE    NOT NULL,
    fee                NUMERIC(6,2) NOT NULL CHECK (fee >= 0),
    included_minutes   SMALLINT     NOT NULL CHECK (included_minutes >= 0),
    fraction_minutes   SMALLINT     NOT NULL CHECK (fraction_minutes > 0),
    fraction_price     NUMERIC(5,2) NOT NULL CHECK (fraction_price >= 0),
    electric_surcharge NUMERIC(5,2) NOT NULL DEFAULT 0 CHECK (electric_surcharge >= 0),
    -- Two fares of the same subscription type cannot be in force at the same time
    EXCLUDE USING gist (subscription_type WITH =, validity WITH &&)
);

CREATE TABLE subscriptions (
    subscription_id   INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscriber_id     INTEGER      NOT NULL REFERENCES subscribers ON DELETE RESTRICT,
    subscription_type VARCHAR(12)  NOT NULL REFERENCES subscription_types ON DELETE RESTRICT,
    fare_id           INTEGER      NOT NULL REFERENCES fares ON DELETE RESTRICT,
    validity          DATERANGE    NOT NULL,
    amount            NUMERIC(6,2) NOT NULL CHECK (amount >= 0),
    status            VARCHAR(10)  NOT NULL DEFAULT 'active'
                      CHECK (status IN ('active','suspended','canceled')),
    CONSTRAINT chk_bounded_validity CHECK (NOT lower_inf(validity) AND NOT upper_inf(validity)),
    -- BR3: no overlapping subscriptions for the same person
    EXCLUDE USING gist (subscriber_id WITH =, validity WITH &&) WHERE (status <> 'canceled')
);

About NUMERIC for money there is no debate and it is worth repeating because it is the most expensive mistake made with types: FLOAT does not represent 0.10 exactly, and a system that adds up 1.6 million amounts with FLOAT produces an accounting mismatch that nobody will be able to explain. NUMERIC(6,2) goes up to €9,999.99, more than enough for an annual fee.

DATERANGE instead of two start_date/end_date columns is what makes the EXCLUDE possible. With two loose columns, "these two subscriptions overlap" is a query with four comparisons that you have to get right every time; with a range it is the && operator and the engine enforces it.

Trips, workshop and charges

CREATE TABLE trips (
    trip_id             BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscription_id     INTEGER     NOT NULL REFERENCES subscriptions ON DELETE RESTRICT,
    bicycle_id          INTEGER     NOT NULL REFERENCES bicycles ON DELETE RESTRICT,
    origin_station      INTEGER     NOT NULL,
    origin_dock         SMALLINT    NOT NULL,
    start_ts            TIMESTAMPTZ NOT NULL DEFAULT now(),
    destination_station INTEGER,
    destination_dock    SMALLINT,
    end_ts              TIMESTAMPTZ,
    duration            INTERVAL GENERATED ALWAYS AS (end_ts - start_ts) STORED,
    -- Deliberate denormalization no. 2: the frozen fare (BR9)
    fare_id             INTEGER      NOT NULL REFERENCES fares ON DELETE RESTRICT,
    included_minutes    SMALLINT     NOT NULL,
    fraction_minutes    SMALLINT     NOT NULL,
    fraction_price      NUMERIC(5,2) NOT NULL,
    electric_surcharge  NUMERIC(5,2) NOT NULL DEFAULT 0,
    amount              NUMERIC(6,2) CHECK (amount >= 0),
    FOREIGN KEY (origin_station,      origin_dock)
        REFERENCES docks (station_id, dock_number) ON DELETE RESTRICT,
    FOREIGN KEY (destination_station, destination_dock)
        REFERENCES docks (station_id, dock_number) ON DELETE RESTRICT,
    CONSTRAINT chk_time_order CHECK (end_ts IS NULL OR end_ts > start_ts),        -- BR6
    CONSTRAINT chk_complete_closure CHECK (                                       -- BR7
         (end_ts IS NULL     AND destination_station IS NULL
                             AND destination_dock    IS NULL AND amount IS NULL)
      OR (end_ts IS NOT NULL AND destination_station IS NOT NULL
                             AND destination_dock    IS NOT NULL AND amount IS NOT NULL))
);

-- BR4 and BR5: neither the bike nor the person can have two open trips
CREATE UNIQUE INDEX uq_open_trip_bicycle     ON trips (bicycle_id)      WHERE end_ts IS NULL;
CREATE UNIQUE INDEX uq_open_trip_subscription ON trips (subscription_id) WHERE end_ts IS NULL;

Three types that deserve a comment:

  • TIMESTAMPTZ, not TIMESTAMP. Vallmar changes its clocks twice a year. On the October Sunday when the clocks go back, a TIMESTAMP without a zone turns "02:30" into an ambiguous instant, and the trips of that early morning can come out with a negative duration. TIMESTAMPTZ stores an absolute instant and the ambiguity disappears. The price is remembering to convert to local time when grouping by time slot, which is what we will do explicitly in Q1.
  • Generated INTERVAL. duration is not stored by hand: it is a generated column (04-04). It satisfies rule 4 of 04-03 —derived values are not stored— without giving up the ability to index it, because STORED does take disk space but is computed on its own and can never disagree with its sources.
  • BIGINT on trip_id. With 1.6 million trips a year, INTEGER (2,147 million) would take more than a thousand years to run out. BIGINT is used anyway: changing the type of a primary key in production is one of the most painful migrations there is, and the cost today is four bytes per row.
CREATE TABLE workshop_orders (
    order_id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    bicycle_id INTEGER     NOT NULL REFERENCES bicycles ON DELETE RESTRICT,
    type       VARCHAR(10) NOT NULL CHECK (type IN ('inspection','breakdown')),
    reason     VARCHAR(60) NOT NULL,
    opened_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    closed_at  TIMESTAMPTZ,
    cost       NUMERIC(7,2) CHECK (cost >= 0),
    CONSTRAINT chk_workshop_closure CHECK (closed_at IS NULL OR closed_at >= opened_at)
);
CREATE UNIQUE INDEX uq_open_order ON workshop_orders (bicycle_id) WHERE closed_at IS NULL;

CREATE TABLE charges (
    charge_id       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscriber_id   INTEGER      NOT NULL REFERENCES subscribers ON DELETE RESTRICT,
    concept         VARCHAR(12)  NOT NULL CHECK (concept IN ('subscription','surcharge')),
    subscription_id INTEGER      REFERENCES subscriptions ON DELETE RESTRICT,
    trip_id         BIGINT       REFERENCES trips         ON DELETE RESTRICT,
    amount          NUMERIC(6,2) NOT NULL CHECK (amount > 0),
    charged_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    status          VARCHAR(10)  NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending','charged','failed','refunded')),
    CONSTRAINT chk_charge_source CHECK (
         (concept = 'subscription' AND subscription_id IS NOT NULL AND trip_id IS NULL)
      OR (concept = 'surcharge'    AND trip_id IS NOT NULL AND subscription_id IS NULL))
);

chk_charge_source is an example of the pattern 04-04 called a cross-column coherence constraint: it is not enough for each column to be valid on its own; the combination has to make sense. A charge with concept subscription and a filled-in trip_id is incoherent data, and the schema rejects it.

  1. The constraints that encode the business rules

It is worth seeing the three most interesting ones fail, because a constraint you have never seen fire is one you do not know works.

-- BR2: try to put bike 417, which is already in dock 3 of station 12,
--      also into dock 5 of station 12
UPDATE docks SET bicycle_id = 417 WHERE station_id = 12 AND dock_number = 5;
ERROR:  duplicate key value violates unique constraint "uq_bicycle_in_one_dock"
DETAIL:  Key (bicycle_id)=(417) already exists.
-- BR3: person 8801 already has an annual subscription from 2026-01-01 to 2027-01-01
INSERT INTO subscriptions (subscriber_id, subscription_type, fare_id, validity, amount)
VALUES (8801, 'monthly', 7, daterange('2026-06-01','2026-07-01'), 12.00);
ERROR:  conflicting key value violates exclusion constraint
        "subscriptions_subscriber_id_validity_excl"
DETAIL:  Key (subscriber_id, validity)=(8801, [2026-06-01,2026-07-01))
         conflicts with existing key (subscriber_id, validity)=(8801, [2026-01-01,2027-01-01)).
-- BR4: bike 417 already has an open trip
INSERT INTO trips (subscription_id, bicycle_id, origin_station, origin_dock,
                   fare_id, included_minutes, fraction_minutes, fraction_price)
VALUES (10233, 417, 12, 3, 7, 30, 15, 0.60);
ERROR:  duplicate key value violates unique constraint "uq_open_trip_bicycle"
DETAIL:  Key (bicycle_id)=(417) already exists.

That last one is the most valuable of the three. The rule "a bike cannot be on two open trips" looks like pure application code, and in 90% of systems it is —with the result that, under load, two simultaneous requests cheerfully violate it. A partial unique index turns it into an engine guarantee that no race condition can dodge.

  1. Normalization check and two deliberate denormalizations

We go over the schema with the method from 05-03. For each table: identify the key, list the functional dependencies and check that every non-prime attribute depends on the whole key and on nothing else.

Table Key Problematic dependencies Verdict
districts district_id None BCNF
stations station_id available_bikes is derivable 3NF broken on purpose (see below)
docks (station_id, dock_number) None: status and bicycle_id depend on the whole pair BCNF
bicycles bicycle_id type is also in bike_models See note
subscriptions subscription_id subscription_type is derivable via fare_id See note
trips trip_id The four fare columns depend on fare_id 2NF/3NF broken on purpose
charges charge_id None BCNF

The note on bicycles.type. The dependency model_id → type exists, and model_id is not a key: it is a transitive dependency and therefore a textbook 3NF violation. It is kept for a concrete and verifiable reason: it is the column that makes the hierarchy discriminant work, and a foreign key cannot point at a value you have to go and look up in another table. And it generates no anomalies, because a model never changes type: a mechanical bike does not turn into an electric one. It is the case 05-02 described as "a transitive dependency on an immutable attribute", where the risk of an update anomaly is zero. Even so, it is shielded with a verification trigger on registration.

The note on subscriptions.subscription_type. Same reasoning and same conclusion: fare_id → subscription_type. It is kept because the reporting queries group by subscription type constantly and avoiding a JOIN in 80% of the reports justifies it. It is shielded with a composite foreign key:

ALTER TABLE fares ADD CONSTRAINT uq_fare_type UNIQUE (fare_id, subscription_type);
ALTER TABLE subscriptions ADD CONSTRAINT fk_subscription_fare_coherent
      FOREIGN KEY (fare_id, subscription_type) REFERENCES fares (fare_id, subscription_type);

Now the redundancy is impossible to contradict, which is the only acceptable way to live with a redundancy. It is exactly the same pattern as the bikes' discriminant.

Denormalization 1 — stations.available_bikes

What is broken: the value is derivable with SELECT COUNT(*) FROM docks WHERE station_id = ? AND bicycle_id IS NOT NULL.

Why it is accepted: the mobile app asks for the availability of all 60 stations every time somebody opens the map, and with 24,000 subscribers that is tens of thousands of requests a day, each degenerating into a count over docks. It is the textbook case from 05-04: massive reads, infrequent writes, small value.

How it is maintained: with a trigger on docks, not by hand from the application. Having the application maintain it is what guarantees it will disagree some day.

CREATE OR REPLACE FUNCTION trg_recalculate_available() RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP IN ('UPDATE','DELETE') AND OLD.bicycle_id IS NOT NULL THEN
        UPDATE stations SET available_bikes = available_bikes - 1
         WHERE station_id = OLD.station_id;
    END IF;
    IF TG_OP IN ('UPDATE','INSERT') AND NEW.bicycle_id IS NOT NULL THEN
        UPDATE stations SET available_bikes = available_bikes + 1
         WHERE station_id = NEW.station_id;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tr_docks_available
AFTER INSERT OR UPDATE OF bicycle_id OR DELETE ON docks
FOR EACH ROW EXECUTE FUNCTION trg_recalculate_available();

And, as 05-04 requires, an audit query that runs every night and warns if the counter has drifted:

SELECT s.code, s.available_bikes AS counter,
       COUNT(d.bicycle_id)       AS real_
  FROM stations s
  JOIN docks    d USING (station_id)
 GROUP BY s.station_id, s.code, s.available_bikes
HAVING s.available_bikes <> COUNT(d.bicycle_id);
 code | counter | real_
------+---------+-------
(0 rows)

Denormalization 2 — the frozen fare in trips

What is broken: included_minutes, fraction_minutes, fraction_price and electric_surcharge depend on fare_id, not on trip_id.

Why it is accepted: it is not performance, it is correctness. It is the answer to the fourth question of the initial meeting. If the trip only stored fare_id, any later recomputation would apply the current fare; the day the city council raises the fraction price, the whole history would change amount. Historical data must be reproducible: it is an accounting requirement and sometimes a legal one.

How it is maintained: by copying the fare at the moment of unlocking, inside the transaction, and never touching it again. A BEFORE UPDATE trigger that rejects changes to those four columns is cheap and sensible protection.

Notice the difference between the two: the first is denormalized for speed and has to be watched; the second is denormalized for semantics and watching it would be a mistake, because its value must differ from the current one.

  1. Data load and realistic volume

INSERT INTO districts VALUES
 (1,'Harbor'), (2,'Midtown'), (3,'Upper Vallmar'), (4,'Riverside'), (5,'Industrial');

INSERT INTO subscription_types VALUES
 ('annual',  'Annual subscription with 30 min included per trip', INTERVAL '1 year'),
 ('monthly', 'Monthly subscription with 30 min included',         INTERVAL '1 mon'),
 ('tourist', '3-day subscription with 15 min included',           INTERVAL '3 days');

INSERT INTO fares (subscription_type, validity, fee, included_minutes,
                   fraction_minutes, fraction_price, electric_surcharge) VALUES
 ('annual',  daterange('2026-01-01','2027-01-01'), 45.00, 30, 15, 0.60, 0.35),
 ('monthly', daterange('2026-01-01','2027-01-01'),  9.50, 30, 15, 0.60, 0.35),
 ('tourist', daterange('2026-01-01','2027-01-01'), 15.00, 15, 15, 1.10, 0.50);

-- 60 stations spread across the five districts
INSERT INTO stations (code, name, address, district_id, latitude, longitude, total_docks)
SELECT 'VB-' || lpad(n::text, 3, '0'),
       'Station ' || n,
       n || ' Fictional Street',
       1 + (n % 5),
       40.100000 + (n % 12) * 0.004,
       -3.200000 + (n % 9) * 0.005,
       12 + (n % 5) * 4
  FROM generate_series(1, 60) AS n;

-- The docks of each station (weak entity: generated from total_docks)
INSERT INTO docks (station_id, dock_number)
SELECT s.station_id, g
  FROM stations s, LATERAL generate_series(1, s.total_docks) AS g;
INSERT 0 1512

That 1,512 is the first useful sizing figure: 1,512 docks for 900 bicycles, a 68% average occupancy. That is healthy slack; below 80% rebalancing becomes an operational nightmare.

Table Initial rows Annual growth Notes
districts 5 0 Fixed
stations 60 +5 Growth by municipal plan
docks 1,512 +120 Derived from the stations
bike_models 6 +1
bicycles 900 +90 / −60 Registrations and withdrawals
subscribers 24,000 +4,000
subscriptions 26,500 +30,000 Tourist ones rotate a lot
trips 0 +1,600,000 ≈4,400/day; it is the table of the system
workshop_orders 0 +7,000
charges 0 +180,000 Fees + surcharges

Every performance decision in section 11 refers to those 1.6 million annual trips. The other tables are irrelevant as far as execution plans go, and confusing that is the most usual way to waste an afternoon indexing the wrong thing.

  1. The two critical transactions: unlock and dock

This is where module 6 stops being theory. The scenario to solve is concrete: it is 08:12, the Harbor station has a single free bike and two people press "unlock" 40 milliseconds apart.

sequenceDiagram
    participant A as App person A
    participant B as App person B
    participant PG as PostgreSQL
    A->>PG: BEGIN · SELECT dock with bike FOR UPDATE SKIP LOCKED
    PG-->>A: dock 3 (bike 417) — row locked
    B->>PG: BEGIN · SELECT dock with bike FOR UPDATE SKIP LOCKED
    PG-->>B: 0 rows (skips 3, there are no more)
    A->>PG: UPDATE docks · INSERT trips · COMMIT
    B->>PG: ROLLBACK — "no bicycles left"

Unlocking

BEGIN;

-- 1. Check that the subscription is in force (BR10). No lock: reading is enough.
SELECT subscription_id, fare_id
  FROM subscriptions
 WHERE subscriber_id = 8801 AND status = 'active'
   AND validity @> CURRENT_DATE;

-- 2. Take ONE docked bike from station 12, locking only that row.
--    ORDER BY accumulated_km spreads the wear across the fleet.
SELECT d.station_id, d.dock_number, d.bicycle_id, b.type
  FROM docks    d
  JOIN bicycles b ON b.bicycle_id = d.bicycle_id
 WHERE d.station_id = 12
   AND d.status     = 'operational'
   AND b.status     = 'docked'
 ORDER BY b.accumulated_km
 FOR UPDATE OF d SKIP LOCKED
 LIMIT 1;

-- 3. Free the dock and mark the bike as in use
UPDATE docks    SET bicycle_id = NULL WHERE station_id = 12 AND dock_number = 3;
UPDATE bicycles SET status = 'in_use' WHERE bicycle_id = 417;

-- 4. Open the trip, freezing the fare in force
INSERT INTO trips (subscription_id, bicycle_id, origin_station, origin_dock,
                   fare_id, included_minutes, fraction_minutes,
                   fraction_price, electric_surcharge)
SELECT 10233, 417, 12, 3,
       f.fare_id, f.included_minutes, f.fraction_minutes,
       f.fraction_price,
       CASE WHEN b.type = 'electric' THEN f.electric_surcharge ELSE 0 END
  FROM fares f
  JOIN bicycles b ON b.bicycle_id = 417
 WHERE f.subscription_type = 'annual' AND f.validity @> CURRENT_DATE
RETURNING trip_id;

COMMIT;
 trip_id
---------
  884213
COMMIT

Why SKIP LOCKED and not plain FOR UPDATE. With FOR UPDATE, session B is left waiting for A to commit, and then re-evaluates: since the dock no longer holds a bike, it gets 0 rows. The final result is correct, but B has waited for nothing. With SKIP LOCKED, B ignores the locked row and keeps looking for another bike at the same station; only if there really is none left does it return 0 rows. At a station with 8 bikes and 8 people unlocking at once, the difference is that all 8 succeed in parallel instead of queueing. It is exactly the queue pattern we saw in 06-02, applied to an inventory.

Why FOR UPDATE OF d. Without the OF d, PostgreSQL would also lock the bicycles row, and that is not needed: the row that decides who wins is the dock's. Locking more than necessary multiplies deadlocks.

The isolation level is READ COMMITTED, the default one. There is no need to go up to REPEATABLE READ: there is no read that must repeat stably, and the explicit lock already resolves the race. Raising isolation "just in case" only adds serialization errors that would have to be retried.

Docking

BEGIN;

-- 1. Reserve a free, operational dock at the destination station
SELECT station_id, dock_number
  FROM docks
 WHERE station_id = 34 AND status = 'operational' AND bicycle_id IS NULL
 ORDER BY dock_number
 FOR UPDATE SKIP LOCKED
 LIMIT 1;

-- 2. Close the trip, computing the amount with the FROZEN fare
UPDATE trips t
   SET end_ts              = now(),
       destination_station = 34,
       destination_dock    = 7,
       amount = t.electric_surcharge
              + t.fraction_price
              * GREATEST(0, ceil(
                    (EXTRACT(EPOCH FROM (now() - t.start_ts)) / 60
                     - t.included_minutes) / t.fraction_minutes))
 WHERE t.trip_id = 884213
   AND t.end_ts IS NULL                 -- idempotence: a second docking does nothing
RETURNING amount;

-- 3. Occupy the dock and return the bike to 'docked' status
UPDATE docks    SET bicycle_id = 417 WHERE station_id = 34 AND dock_number = 7;
UPDATE bicycles SET status = 'docked',
                    accumulated_km = accumulated_km + 3.40
 WHERE bicycle_id = 417;

-- 4. If there is a surcharge, generate the charge
INSERT INTO charges (subscriber_id, concept, trip_id, amount)
SELECT s.subscriber_id, 'surcharge', t.trip_id, t.amount
  FROM trips t JOIN subscriptions s USING (subscription_id)
 WHERE t.trip_id = 884213 AND t.amount > 0;

COMMIT;
 amount
--------
   1.55
UPDATE 1
COMMIT

A 68-minute trip on an annual subscription: 68 − 30 included = 38 minutes exceeded, ceil(38/15) = 3 fractions × €0.60 = €1.80… plus the €0.35 electric surcharge. The result above, €1.55, corresponds to 2 fractions (€1.20) plus €0.35, that is, to a 55-minute trip. Check the arithmetic yourself with both cases: verifying by hand the first amount a billing system produces is a habit that saves grief.

The three details that make this transaction robust:

  1. AND t.end_ts IS NULL in the UPDATE's WHERE makes it idempotent. If the app retries the docking because it lost the response over the network, the second attempt affects 0 rows and does not bill again.
  2. The RETURNING lets the application check how many rows it changed. Zero rows is not a silent success: it is an error that has to be handled.
  3. The available_bikes counter is not touched here: the docks trigger updates it. If it were also touched by hand, the counter would go up by two at a time, and that is the most common denormalization bug there is.

  1. Reporting queries: the city council's reports

Q1 — Trips by district and time slot.

SELECT d.name AS district,
       COUNT(*) FILTER (WHERE h BETWEEN  7 AND  9) AS morning_peak,
       COUNT(*) FILTER (WHERE h BETWEEN 10 AND 16) AS midday,
       COUNT(*) FILTER (WHERE h BETWEEN 17 AND 20) AS evening_peak,
       COUNT(*) FILTER (WHERE h > 20 OR h < 7)     AS night,
       COUNT(*)                                    AS total
  FROM (SELECT t.origin_station,
               EXTRACT(HOUR FROM t.start_ts AT TIME ZONE 'Europe/Madrid')::int AS h
          FROM trips t
         WHERE t.start_ts >= DATE '2026-06-01'
           AND t.start_ts <  DATE '2026-07-01') x
  JOIN stations  s ON s.station_id = x.origin_station
  JOIN districts d USING (district_id)
 GROUP BY d.name
 ORDER BY total DESC;
   district    | morning_peak | midday | evening_peak | night | total
---------------+--------------+--------+--------------+-------+--------
 Midtown       |        14820 |  11340 |        16905 |  2115 |  45180
 Harbor        |         9640 |  14210 |        12880 |  3410 |  40140
 Riverside     |         7115 |   6320 |         8090 |  1145 |  22670
 Upper Vallmar |         5980 |   4110 |         6240 |   705 |  17035
 Industrial    |         4210 |   1890 |         4560 |   380 |  11040

You can read a real pattern there: Midtown and Industrial are clearly commuter districts (two peaks, little midday) while the Harbor peaks at midday — that is tourism, not commuting. The AT TIME ZONE 'Europe/Madrid' is not decorative: without it, in summer the slots would come out shifted by two hours.

Q2 — The most frequent origin→destination pairs, with their rank inside the district.

SELECT * FROM (
  SELECT so.name AS origin, sd.name AS destination, dd.name AS destination_district,
         COUNT(*) AS trip_count,
         ROUND(AVG(EXTRACT(EPOCH FROM t.duration) / 60)::numeric, 1) AS avg_min,
         RANK() OVER (PARTITION BY dd.district_id ORDER BY COUNT(*) DESC) AS rank_
    FROM trips     t
    JOIN stations  so ON so.station_id = t.origin_station
    JOIN stations  sd ON sd.station_id = t.destination_station
    JOIN districts dd ON dd.district_id = sd.district_id
   WHERE t.end_ts IS NOT NULL
     AND t.start_ts >= DATE '2026-06-01' AND t.start_ts < DATE '2026-07-01'
   GROUP BY so.station_id, so.name, sd.station_id, sd.name,
            dd.district_id, dd.name
) r
WHERE rank_ <= 2
ORDER BY destination_district, rank_;
       origin        |    destination     | destination_district | trip_count | avg_min | rank_
---------------------+--------------------+----------------------+------------+---------+-------
 Station 7           | Station 22         | Midtown              |       1284 |    14.2 |     1
 Station 41          | Station 22         | Midtown              |        967 |    18.6 |     2
 Station 3           | Station 18         | Industrial           |        712 |    21.4 |     1
 ...

The RANK() OVER (PARTITION BY ...) over an aggregated COUNT(*) is the "top N per group" pattern you practiced in 07-04. Note that the window function is evaluated after the GROUP BY, which is why it can order by COUNT(*).

Q3 — The rebalancing problem. This is the query that really saves money, and the one that best demonstrates why we modeled the trip as an entity with two endpoints.

WITH movements AS (
    SELECT origin_station AS station_id, start_ts AS ts, -1 AS delta
      FROM trips
     WHERE start_ts >= DATE '2026-06-01' AND start_ts < DATE '2026-07-01'
    UNION ALL
    SELECT destination_station, end_ts, +1
      FROM trips
     WHERE end_ts IS NOT NULL
       AND end_ts >= DATE '2026-06-01' AND end_ts < DATE '2026-07-01'
),
by_slot AS (
    SELECT station_id,
           EXTRACT(HOUR FROM ts AT TIME ZONE 'Europe/Madrid')::int / 4 AS block,
           SUM(delta) AS net
      FROM movements
     GROUP BY 1, 2
)
SELECT s.code, s.name, s.total_docks,
       SUM(net) FILTER (WHERE block = 1) AS "04-08",
       SUM(net) FILTER (WHERE block = 2) AS "08-12",
       SUM(net) FILTER (WHERE block = 4) AS "16-20",
       SUM(net)                          AS net_month,
       CASE WHEN SUM(net) < -300 THEN 'empties · restock'
            WHEN SUM(net) >  300 THEN 'fills · remove'
            ELSE 'balanced' END          AS diagnosis
  FROM by_slot JOIN stations s USING (station_id)
 GROUP BY s.station_id, s.code, s.name, s.total_docks
 ORDER BY ABS(SUM(net)) DESC
 LIMIT 5;
  code  |    name    | total_docks | 04-08 | 08-12 | 16-20 | net_month |     diagnosis
--------+------------+-------------+-------+-------+-------+-----------+-------------------
 VB-022 | Station 22 |          24 |   -18 |  +892 |  -774 |      +611 | fills · remove
 VB-007 | Station 7  |          16 |   +31 |  -845 |  +698 |      -498 | empties · restock
 VB-041 | Station 41 |          20 |   +12 |  -602 |  +515 |      -402 | empties · restock
 VB-018 | Station 18 |          28 |    -8 |  +498 |  -401 |      +377 | fills · remove
 VB-003 | Station 3  |          12 |   +22 |  -344 |  +266 |      -281 | balanced

The signs tell the whole story: 7 and 41 are residential (they empty out in the morning, they fill back up in the afternoon) and 22 is a work destination. The net_month column says how many bikes have to be moved by van each month, and the 08-12 against the 16-20 says at what time they have to be moved. The technique —turning two columns of one row into two signed rows by means of UNION ALL— is the canonical way to treat any entity with two endpoints.

Q4 — Revenue by subscription type.

SELECT st.subscription_type,
       COUNT(*) FILTER (WHERE c.concept = 'subscription')           AS fee_count,
       SUM(c.amount) FILTER (WHERE c.concept = 'subscription')      AS eur_fees,
       SUM(c.amount) FILTER (WHERE c.concept = 'surcharge')         AS eur_surcharges,
       SUM(c.amount)                                                AS eur_total,
       ROUND(100.0 * SUM(c.amount) FILTER (WHERE c.concept = 'surcharge')
                   / NULLIF(SUM(c.amount), 0), 1)                   AS pct_surcharge
  FROM charges c
  JOIN subscribers p USING (subscriber_id)
  JOIN subscriptions sb ON sb.subscriber_id = p.subscriber_id
                       AND sb.validity @> c.charged_at::date
  JOIN subscription_types st ON st.subscription_type = sb.subscription_type
 WHERE c.status = 'charged'
   AND c.charged_at >= DATE '2026-01-01'
 GROUP BY st.subscription_type
 ORDER BY eur_total DESC;
 subscription_type | fee_count |  eur_fees | eur_surcharges | eur_total | pct_surcharge
-------------------+-----------+-----------+----------------+-----------+---------------
 annual            |      9840 | 442800.00 |       38215.40 | 481015.40 |           7.9
 tourist           |      8120 | 121800.00 |       71430.80 | 193230.80 |          36.9
 monthly           |      6410 |  60895.00 |       19204.20 |  80099.20 |          24.0

The pct_surcharge of the tourist subscription (36.9%) is the kind of finding that justifies the report: visitors go over the included time almost half the time. Either 15 minutes is too little, or the app does not warn them. It is a business conversation that only exists because the query made it possible.

Q5 — Breakdowns per hour of use.

WITH usage AS (
    SELECT bicycle_id,
           SUM(EXTRACT(EPOCH FROM duration)) / 3600 AS hours
      FROM trips
     WHERE end_ts IS NOT NULL AND start_ts >= DATE '2026-01-01'
     GROUP BY bicycle_id
),
breakdowns AS (
    SELECT bicycle_id, COUNT(*) AS n
      FROM workshop_orders
     WHERE type = 'breakdown' AND opened_at >= DATE '2026-01-01'
     GROUP BY bicycle_id
)
SELECT b.plate, m.manufacturer, m.name AS model, b.type,
       ROUND(u.hours::numeric, 1)                          AS usage_hours,
       COALESCE(k.n, 0)                                    AS breakdowns,
       ROUND((COALESCE(k.n,0) * 100 / u.hours)::numeric, 2) AS breakdowns_per_100h
  FROM usage u
  JOIN bicycles    b USING (bicycle_id)
  JOIN bike_models m USING (model_id)
  LEFT JOIN breakdowns k USING (bicycle_id)
 WHERE u.hours >= 50                    -- discards bikes with an insufficient sample
 ORDER BY breakdowns_per_100h DESC
 LIMIT 5;
  plate  | manufacturer | model  |    type    | usage_hours | breakdowns | breakdowns_per_100h
---------+--------------+--------+------------+-------------+------------+---------------------
 VB-0417 | Norvent      | Urban2 | mechanical |       214.6 |          9 |                4.19
 VB-0388 | Norvent      | Urban2 | mechanical |       188.2 |          7 |                3.72
 VB-0512 | Ciclmar      | E-Vall | electric   |       341.9 |         11 |                3.22
 VB-0041 | Norvent      | Urban2 | mechanical |       255.0 |          8 |                3.05
 VB-0733 | Ciclmar      | E-Vall | electric   |       298.4 |          9 |                3.02

The WHERE u.hours >= 50 is the important part and the one almost nobody includes: without it, a bike with 2 hours of use and one breakdown tops the list with 50 breakdowns/100 h and the report is worthless. Every rate needs a minimum denominator threshold.

  1. Performance: the two queries that degrade

With an empty table everything is fast. The day trips holds 1.6 million rows, two things break. We follow the order of intervention from 06-03: measure first, understand the plan, and only then touch anything.

Degradation 1 — the Q1 monthly report

EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM trips
 WHERE start_ts >= DATE '2026-06-01' AND start_ts < DATE '2026-07-01';
 Finalize Aggregate  (cost=98421.30..98421.31 rows=1) (actual time=2184.402..2189.117 rows=1)
   ->  Gather  (...)
         ->  Partial Aggregate  (...)
               ->  Parallel Seq Scan on trips  (actual time=0.041..2050.883 rows=45180 loops=3)
                     Filter: ((start_ts >= '2026-06-01') AND (start_ts < '2026-07-01'))
                     Rows Removed by Filter: 488153
                     Buffers: shared read=31204
 Planning Time: 0.198 ms
 Execution Time: 2189.204 ms

Diagnosis: Rows Removed by Filter: 488153 for each of the 3 processes. 1.6 million rows are read to keep 45,180. It is the classic symptom from 06-03: a very selective filter (2.8%) resolved with a sequential scan.

Intervention. Here there is a real choice between two indexes:

Index Size Write cost When it wins
B-tree (start_ts) ≈34 MB High: 4,400 insertions/day update it Small ranges, and it also serves "the last trip of X"
BRIN (start_ts) ≈48 KB Almost none Large ranges over data correlated with the physical order

trips is always inserted in chronological order and never reordered: the physical correlation of start_ts is practically 1. It is the exact case BRIN exists for.

CREATE INDEX idx_trips_ts_brin ON trips USING brin (start_ts)
    WITH (pages_per_range = 64);
ANALYZE trips;
 Aggregate  (actual time=118.940..118.941 rows=1)
   ->  Bitmap Heap Scan on trips  (actual time=3.117..114.226 rows=45180 loops=1)
         Recheck Cond: ((start_ts >= '2026-06-01') AND (start_ts < '2026-07-01'))
         Rows Removed by Index Recheck: 2841
         Heap Blocks: lossy=1408
         Buffers: shared hit=1412 read=3
         ->  Bitmap Index Scan on idx_trips_ts_brin  (actual time=0.402..0.402 rows=14080 loops=1)
 Planning Time: 0.211 ms
 Execution Time: 118.987 ms

From 2,189 ms to 119 ms, with a 48 KB index. Rows Removed by Index Recheck: 2841 is normal in BRIN: the index works by blocks, so it returns a few extra blocks and the engine discards the leftover rows. In exchange it takes up a thousand times less space than the B-tree and its maintenance on insertions is negligible.

Degradation 2 — "which trip do I have open?"

It is the most frequent query in the system: the app runs it every time somebody opens it with a bike in progress.

EXPLAIN ANALYZE
SELECT trip_id, start_ts, origin_station
  FROM trips WHERE subscription_id = 10233 AND end_ts IS NULL;
 Seq Scan on trips  (actual time=1893.221..1893.223 rows=1 loops=1)
   Filter: ((end_ts IS NULL) AND (subscription_id = 10233))
   Rows Removed by Filter: 1599999
 Execution Time: 1893.244 ms

And here is the pretty detail: the index that fixes it already exists. It is uq_open_trip_subscription, the partial unique index we created in section 5 to enforce BR5. A partial index over WHERE end_ts IS NULL covers, with 1.6 million rows in the table, only the 900 at most that can be open simultaneously. The only thing missing was an ANALYZE:

 Index Scan using uq_open_trip_subscription on trips  (actual time=0.031..0.033 rows=1 loops=1)
   Index Cond: (subscription_id = 10233)
   Buffers: shared hit=3
 Execution Time: 0.049 ms

From 1,893 ms to 0.049 ms. The lesson is the one 06-03 kept repeating: a well-chosen constraint is also an index, and very often the index you need is one you already created without realizing it.

The three remaining indexes that do have to be created by hand:

CREATE INDEX idx_trips_origin_ts      ON trips (origin_station, start_ts);
CREATE INDEX idx_trips_destination_ts ON trips (destination_station, end_ts)
    WHERE end_ts IS NOT NULL;
CREATE INDEX idx_orders_bicycle_type  ON workshop_orders (bicycle_id, type, opened_at);

The column order in the composite ones follows the rule from 06-03 —equality first, range afterwards— and answers Q2 and Q3. The second one is partial because open trips have no destination and contribute nothing to the index.

  1. Operations: roles, personal data and backups

Roles and minimum permissions

CREATE ROLE vallbici_app      LOGIN PASSWORD '...';  -- the API that serves the mobile app
CREATE ROLE vallbici_workshop LOGIN PASSWORD '...';  -- the maintenance team
CREATE ROLE vallbici_analyst  LOGIN PASSWORD '...';  -- city council reports

GRANT SELECT, INSERT, UPDATE ON docks, trips, bicycles      TO vallbici_app;
GRANT SELECT                 ON stations, fares, subscriptions TO vallbici_app;
GRANT INSERT                 ON charges                     TO vallbici_app;
-- The API CANNOT delete anything, in any table. Not even its own rows.

GRANT SELECT, INSERT, UPDATE ON workshop_orders TO vallbici_workshop;
GRANT SELECT, UPDATE (status) ON bicycles       TO vallbici_workshop;

GRANT SELECT ON ALL TABLES IN SCHEMA public TO vallbici_analyst;
REVOKE SELECT ON subscribers, charges       FROM vallbici_analyst;
GRANT  SELECT ON v_anonymous_trips          TO vallbici_analyst;

The GRANT SELECT, UPDATE (status) ON bicycles is the literal application of the least-privilege principle from 06-04: the workshop changes the status of a bike, and only the status. It cannot touch accumulated_km or plate.

Personal data

The city council's reports do not need to know who made each trip. The view consumed by the analyst profile breaks the link:

CREATE VIEW v_anonymous_trips AS
SELECT t.trip_id, t.start_ts, t.end_ts, t.duration, t.amount,
       t.origin_station, t.destination_station,
       sb.subscription_type,
       p.district_id AS residence_district,
       date_part('year', age(p.birth_date))::int / 10 * 10 AS age_decade
  FROM trips t
  JOIN subscriptions sb USING (subscription_id)
  JOIN subscribers    p USING (subscriber_id);

Two honest observations about this:

  1. The view does not truly anonymize. With district of residence, decade of age and trip pattern, a person with a distinctive route can be re-identifiable. Reducing the risk requires minimum aggregation (not publishing cells with fewer than N people) and that is a policy decision, not a SQL one.
  2. Regulatory compliance is not decided by whoever designs the database. Legal basis for processing, retention periods for telemetry, impact assessment, right to erasure: all of that is reviewed by a data protection or compliance professional. What is a technical responsibility is that the schema allows compliance: that is why the ID document is stored as document_hash and not in the clear, that is why ON DELETE RESTRICT forces an explicit erasure procedure instead of cascade-deleting the accounting history, and that is why the view exists.

Backups

Element Strategy Frequency Objective
Physical base backup pg_basebackup Daily, 02:00 Full restore
Archived WAL archive_command to external storage Continuous PITR: restore to any instant
Logical dump pg_dump -Fc Weekly Recover a single table without restoring everything
Restore drill Restore on a separate machine and run the counter audit Monthly That the backup is actually good for something

The last row is the one most often skipped and the only one that guarantees anything. In the words of 06-04: a backup that has never been restored is not a backup, it is a hope. With PITR configured, the scenario "at 11:40 somebody ran an UPDATE without a WHERE on fares" is solved by restoring to 11:39.

  1. What this schema handles badly

The system works, it meets the eight functional requirements and it answers the five queries. And even so there are three things from the initial commission that this schema handles badly. It is worth being precise, because they are exactly the material of the next lesson.

1. GPS telemetry. The commission asked to store the trace of every trip: positions every 5 seconds, battery level of the electric ones, incidents. An average 14-minute trip is ~170 points. With 1.6 million trips a year, that is a positions table of 272 million rows a year, each with trip_id, ts, lat, lon and little else. It can be done —PostgreSQL can take it— but it is a bad fit: this is data that is written massively, always read whole and by trip, never updated, does not need strict referential integrity and expires within a few months. You are paying the price of the relational model (index per row, WAL per row, visibility per row) for data that uses none of its advantages.

2. The enriched station profile. Our stations table has 11 flat columns. The profile the app wants has photos, access hours that vary per station, accessibility, whether it is under cover, whether it has an air pump, notes from the maintenance contractor, tags for nearby points of interest… and each station has a different subset of those attributes. Modeling that in the relational world leads to one of three outcomes, all bad: 40 null columns, a generic key-value table (the EAV anti-pattern that 04-01 flagged in red) or a new table for every attribute anyone comes up with.

3. Incidents with a variable structure. workshop_orders has a reason VARCHAR(60). But a "brakes" incident needs to record which brake and what measurement; a "battery" one needs charge cycles and voltage; a "vandalism" one needs photos and a police report number. These are different structures per type, and adding a new type should not require an ALTER TABLE in production.

All three have something in common: variable structure or high volume without needing transactions. And all three have something else in common, more important still: none of them is the transactional core. Nobody bills money on the basis of a GPS point. That is what makes it possible to take them out of PostgreSQL without putting at risk what matters, and it is exactly what lesson 08-02 does.

Common Mistakes and Tips

Mistake 1: starting with the CREATE TABLE. It is the mistake almost all the others derive from. The seven questions in section 1 cost twenty minutes of meeting and determined half the schema. If we had not asked them, we would have discovered the need for the frozen fare on the day of the first price rise, with two years of billing already issued.

Mistake 2: not modeling the dock "because a counter is enough". It works until the first broken dock. The general rule: if the client says something has its own status, it is an entity, even if it looks like a number.

Mistake 3: FLOAT for money. It still happens. NUMERIC for everything that is billed, invoiced or added up.

Mistake 4: TIMESTAMP without a zone in a system with a clock change. The October Sunday will produce trips with a negative duration and restoring that night will be a puzzle.

Mistake 5: maintaining a denormalized counter by hand. If available_bikes is updated from the application and from the trigger, the increment is doubled. One of the two, and preferably the trigger.

Mistake 6: raising the isolation level instead of locking the right row. SERIALIZABLE is not a magic solution: it turns a race into a serialization error the application has to retry. A FOR UPDATE SKIP LOCKED on the deciding row is cheaper and more predictable.

Tip 1: write the list of queries before the schema. It is the best detector of missing entities. Q3 is what confirmed that the trip needed its two endpoints as independent foreign keys.

Tip 2: every constraint you write, test it by failing. A constraint you have never seen reject an INSERT is one you do not know is written correctly.

Tip 3: check whether the index already exists before creating it. Degradation 2 solved itself. Unique indexes, partial or not, are full-blown indexes.

Tip 4: document the denormalizations in the schema itself. The comment -- Deliberate denormalization no. 1 stops somebody three years from now from "fixing" the schema by removing it. Better still: COMMENT ON COLUMN.

Exercises

Exercise 1 — Bicycle reservations

The city council wants to allow reserving a bicycle from the app: the person reserves a specific bike at a station and has 10 minutes to get there and unlock it; after that time, the reservation expires and the bike becomes available again. A person can only have one active reservation.

  1. Decide whether the reservation is a new entity or a status of something existing, and justify it.
  2. Write the CREATE TABLE (or the ALTER TABLE) with all the necessary constraints.
  3. Write the reservation transaction, with its concurrency control.
  4. Explain how reservations expire and why that decision.

Exercise 2 — Reduced fare for a social pass

A reduced fare is introduced for people holding a municipal social pass. Eligibility is certified every year and may stop being met. A trip is billed at the reduced fare if the person held a valid social pass at the moment of unlocking.

  1. Model the social pass certification without duplicating the information in subscribers.
  2. Explain why the EXCLUDE constraint on subscriptions is not enough here.
  3. Write the query that computes, for last month, how much revenue the city council has forgone because of the reduced fares.

Exercise 3 — Detecting "ghost" bicycles

A ghost bicycle is one that has spent more than 48 hours on an open trip: it was stolen, the docking system broke or the app failed to close it. Write the query that lists them with the last known station, how long they have been out and the contact name of the person who unlocked them, ordered from oldest to most recent. Add the index that makes it efficient, or explain why none is needed.

Solutions

Solution 1

1. A new entity. Three arguments: (a) it has its own attributes —reservation instant, expiry instant, outcome—; (b) it has history: we will want to know how many reservations expire unused, and an overwritten status leaves no trace; (c) it relates three things (person, bike and dock) with a temporality of its own. A status = 'reserved' in bicycles would allow none of the three.

2. The schema:

CREATE TABLE reservations (
    reservation_id  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscription_id INTEGER     NOT NULL REFERENCES subscriptions ON DELETE RESTRICT,
    bicycle_id      INTEGER     NOT NULL REFERENCES bicycles ON DELETE RESTRICT,
    station_id      INTEGER     NOT NULL,
    dock            SMALLINT    NOT NULL,
    reserved_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ NOT NULL
                    GENERATED ALWAYS AS (reserved_at + INTERVAL '10 minutes') STORED,
    outcome         VARCHAR(10) NOT NULL DEFAULT 'active'
                    CHECK (outcome IN ('active','used','expired','canceled')),
    FOREIGN KEY (station_id, dock) REFERENCES docks (station_id, dock_number)
);

CREATE UNIQUE INDEX uq_active_reservation_subscription ON reservations (subscription_id) WHERE outcome = 'active';
CREATE UNIQUE INDEX uq_active_reservation_bicycle      ON reservations (bicycle_id)      WHERE outcome = 'active';
CREATE INDEX idx_reservations_expire ON reservations (expires_at) WHERE outcome = 'active';

The two partial unique indexes enforce "one active reservation per person" and "one active reservation per bike" — the same pattern as BR4/BR5. The third one is a service index, for the expiry process.

3. The transaction:

BEGIN;
SELECT d.station_id, d.dock_number, d.bicycle_id
  FROM docks d
  JOIN bicycles b ON b.bicycle_id = d.bicycle_id
 WHERE d.station_id = 12 AND d.status = 'operational' AND b.status = 'docked'
   AND NOT EXISTS (SELECT 1 FROM reservations r
                    WHERE r.bicycle_id = d.bicycle_id AND r.outcome = 'active')
 ORDER BY b.accumulated_km
 FOR UPDATE OF d SKIP LOCKED
 LIMIT 1;

INSERT INTO reservations (subscription_id, bicycle_id, station_id, dock)
VALUES (10233, 417, 12, 3);
COMMIT;

The NOT EXISTS anti-join excludes bikes that are already reserved; the partial unique index is the safety net if two requests dodge it. Note that the bike is not marked as reserved in bicycles: the status lives in reservations, in a single place.

4. Expiry. Two options and one choice.

Discarded: a process that every minute marks the overdue ones as expired. It works, but it introduces a window in which the reservation is overdue and still shows as active, and it depends on the process not crashing.

Chosen: implicit expiry at read time. A reservation counts as active if outcome = 'active' AND expires_at > now(); the NOT EXISTS above adds that condition. The cleanup process still exists, but only to keep the history tidy, and its running late does not affect correctness. The general rule: when time determines the validity of a value, the truth must live in the query, not in a process.

Solution 2

1. The model. A certifications table with a validity range, not a boolean column in subscribers:

CREATE TABLE social_passes (
    pass_id       INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    subscriber_id INTEGER   NOT NULL REFERENCES subscribers ON DELETE RESTRICT,
    validity      DATERANGE NOT NULL,
    case_file     VARCHAR(20) NOT NULL UNIQUE,
    EXCLUDE USING gist (subscriber_id WITH =, validity WITH &&)
);
ALTER TABLE fares ADD COLUMN social_pass BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE fares DROP CONSTRAINT fares_subscription_type_validity_excl;
ALTER TABLE fares ADD EXCLUDE USING gist
      (subscription_type WITH =, social_pass WITH =, validity WITH &&);

A has_social_pass BOOLEAN column would be the classic mistake: it does not store since when, it does not store the case file and, above all, it does not let you reconstruct whether they had it on the day of the trip. A certification is a fact with a date, and facts with dates go in rows, not in columns.

2. Why the EXCLUDE on subscriptions is not enough. That EXCLUDE guarantees there are no two overlapping subscriptions for the same person; it says nothing about which fare applies. The social pass is a condition orthogonal to the subscription and with its own validity: a person can have an annual subscription from 1 January to 31 December and a social pass that expires on 30 June. From 1 July onward, the same subscription must generate trips at the normal fare. Since the trip already freezes its fare (BR9), the system stays correct without touching anything else: it is enough for the unlock transaction to choose the fare by consulting social_passes with validity @> CURRENT_DATE.

3. The fiscal cost query:

SELECT COUNT(*)                                     AS reduced_trips,
       SUM(t.amount)                                AS collected,
       SUM(t.amount * (fn.fraction_price / NULLIF(t.fraction_price, 0)))
                                                    AS would_have_collected,
       SUM(t.amount * (fn.fraction_price / NULLIF(t.fraction_price, 0)))
       - SUM(t.amount)                              AS subsidy_cost
  FROM trips t
  JOIN fares fr ON fr.fare_id = t.fare_id AND fr.social_pass
  JOIN fares fn ON fn.subscription_type = fr.subscription_type
               AND NOT fn.social_pass
               AND fn.validity && fr.validity
 WHERE t.end_ts IS NOT NULL
   AND t.start_ts >= date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
   AND t.start_ts <  date_trunc('month', CURRENT_DATE);
 reduced_trips | collected | would_have_collected | subsidy_cost
---------------+-----------+----------------------+--------------
          8412 |   2184.60 |              4369.20 |      2184.60

The NULLIF protects against zero: if the reduced fare had fraction_price = 0, the division would blow up.

Solution 3

SELECT b.plate, b.type,
       s.code AS last_station, s.name,
       t.start_ts,
       date_trunc('minute', now() - t.start_ts) AS time_open,
       p.first_name || ' ' || p.last_name AS contact, p.phone
  FROM trips t
  JOIN bicycles b USING (bicycle_id)
  JOIN stations s ON s.station_id = t.origin_station
  JOIN subscriptions sb USING (subscription_id)
  JOIN subscribers    p USING (subscriber_id)
 WHERE t.end_ts IS NULL
   AND t.start_ts < now() - INTERVAL '48 hours'
 ORDER BY t.start_ts;
  plate  |    type    | last_station |    name    |        start_ts        |  time_open   | contact
---------+------------+--------------+------------+------------------------+--------------+---------
 VB-0233 | mechanical | VB-041       | Station 41 | 2026-06-09 19:12:04+02 | 5 days 03:41 | ...
 VB-0781 | electric   | VB-007       | Station 7  | 2026-06-12 08:33:51+02 | 2 days 14:19 | ...

About the index: none is needed. The end_ts IS NULL filter is enormously selective —at most 900 rows out of 1.6 million— and uq_open_trip_bicycle already covers exactly that partial predicate. The planner can scan it whole (900 entries) and filter afterwards by start_ts; adding an index over (start_ts) WHERE end_ts IS NULL would marginally improve a query that runs once a day and would add maintenance to 4,400 daily insertions. It is not worth it. This is the reasoning 06-03 asked for: an index is justified by the frequency of the query and the cost of writing, not by the size of the table.

Conclusion

You have gone through an entire relational project: from a two-paragraph meeting transcript to a system with eleven tables, twenty constraints that encode real business rules, two correct concurrent transactions, five reporting queries and an operations plan.

What is worth taking away is not the schema —your next project's will be a different one— but the shape of the decisions. Every one of the important ones had the same format: a simple temptation, a question to the client that dismantled it, two or three alternatives laid out in a table and an explicit criterion for choosing. The dock is an entity because it has its own status. The trip is an entity because it is born incomplete and has identity. The hierarchy uses table per subclass because other tables reference the superclass and because the battery serial number has to be mandatory. available_bikes breaks 3NF for speed and is watched; the frozen fare breaks it for semantics and watching it would be a mistake. None of those sentences is an aesthetic preference: they are all arguments that can be discussed and, if need be, rebutted.

You have also seen that the tools of the course are not used one at a time. The EXCLUDE USING gist from 04-04 enforces a business rule from 04-01; the partial unique index that enforces BR5 turns out to be, without changing a line, the index that fixes the most frequent query in the system; the denormalization from 05-04 needs the trigger from 05-04 and the nightly audit from 05-04, all three or none. A production system is this: pieces of the syllabus working at the same time and holding each other up.

And it ends with a list of three failures, which is the most honest part of the case study. GPS telemetry, the enriched station profile and the incidents with a variable structure do not fit well in this schema, and forcing them in there would produce null columns, EAV tables and migrations every time somebody invents a new incident type. In lesson 08-02 we take those three exact pieces —not one more— and model them in MongoDB with the method from 03-03, checking what is gained, what is lost and why the temptation to "migrate everything to Mongo" is the wrong answer. The transactional core you have just built stays where it is: it is the one that bills the money.

© Copyright 2026. All rights reserved