Three whole lessons arguing that every fact must be stored exactly once, and now a lesson explaining when it pays to break that rule. It may look like a contradiction, and it is not: it is the difference between knowing a rule and knowing how to use it.

At the end of the previous lesson we ran into the same situation four times. duration_min in events, computed from start_time and end_time. occupied_seats in registrations, computed from companions. A fine's amount, which cannot be recomputed if the by-law changes. The hourly_rate of a room booking, which must stay frozen at the value it had on the day of the invoice. All four are redundancies. All four violate the letter of third normal form. And all four are correct.

Denormalizing means introducing redundancy into a schema on purpose, in order to get something specific that the normalized schema did not give you. The three words that matter are "on purpose", "specific" and "did not give". Without all three it is not denormalization: it is not having done the work.

This lesson closes the module with the opposite decision, taken with the same rigor as the previous ones. What exactly you gain and what you pay. When it is justified and when it is not. The techniques one by one, with SQL over BiblioRed. How you keep consistent what you have duplicated deliberately. And a decision guide with the questions you have to answer before touching anything —and the signs that it is time to undo it.

Contents

  1. What denormalizing is and the golden rule
  2. The first thing to try: indexes, not denormalization
  3. The balance: what you gain and what you pay
  4. When it is justified: the three cases
  5. Technique 1: the redundant or computed column
  6. Technique 2: generated columns versus hand-maintained columns
  7. Technique 3: duplicating an attribute to avoid a JOIN
  8. Technique 4: pre-aggregated summary tables
  9. Technique 5: materialized views
  10. Technique 6: flat history tables
  11. Technique 7: the star schema of data warehouses
  12. How you keep denormalized data consistent
  13. The bridge to NoSQL: document modeling is denormalization raised to a method
  14. Decision guide: the questions beforehand and the signs to undo it

  1. What denormalizing is and the golden rule

Definition. Denormalizing means deliberately modifying a normalized schema to introduce redundancy —duplicated or derived data— with the aim of improving read performance or preserving a historical value, accepting in exchange the cost of keeping that redundancy consistent.

Note three things in that definition.

You start from a normalized schema. You cannot denormalize what was never normalized. A schema that was born redundant because nobody analyzed the dependencies is not denormalized: it is badly designed. The difference is not semantic: in a denormalized schema you know exactly which data is duplicated, why, and who is responsible for maintaining it; in a badly designed schema, you do not.

The aim is specific. "Just in case" and "to make it faster" are not aims. "The monthly management report takes forty seconds and it has to take less than two" is one. "The fine's amount must not change if the by-law goes up" is another.

A cost is accepted, and it has to be named. Every denormalization has a bill, and somebody pays it: whoever writes, whoever maintains the trigger, or the user who one day sees two different figures for the same thing.

The golden rule

Normalize first. Then denormalize on purpose, measuring. Never the other way round.

It is the rule that governs the whole lesson and it is worth breaking down:

"Normalize first" means that the starting point is always the schema in 3NF or BCNF. It is the one that expresses the domain correctly, the one that admits no contradictions, and the one any professional will understand. It is also, and this is forgotten, the only reference against which you can measure whether the denormalization achieved anything.

"On purpose" means documented. Every redundant column must have written next to it: what it duplicates, why, who maintains it and what happens if it goes out of sync. Without that, in two years' time somebody will see it, think it is a mistake, and "fix" it or —worse— let it rot.

"Measuring" means with numbers before and after. If you cannot say "this query used to take 4.2 seconds and now takes 80 milliseconds", you do not know whether the denormalization helped. And very often it does not: the problem was somewhere else.

"Never the other way round" is the important part. Starting with a redundant schema "because it will be faster that way" and normalizing it when it causes trouble is the wrong order, because premature denormalization makes decisions about a usage pattern you do not know yet, and undoing it afterwards is infinitely more expensive than doing it now: there is data, there is code and there are reports depending on it.

  1. The first thing to try: indexes, not denormalization

Before we go on, a warning that prevents most of the unnecessary denormalization you see in production.

When a query is slow, denormalization is not the first thing to try. It is the last.

The correct order of interventions, from least invasive to most, is this:

Order Intervention Reversible Risk to the data
1 Create an appropriate index Yes, DROP INDEX None
2 Rewrite the query (avoid correlated subqueries, SELECT *, functions over indexed columns) Yes None
3 Update the planner's statistics (ANALYZE) Yes None
4 Tune the server configuration (working memory, cache) Yes None
5 Denormalize Hardly ever Yes: inconsistency

The first four do not touch the data, they do not introduce the possibility of the database contradicting itself, and they can be undone in a minute. The fifth is permanent in practice.

And experience is emphatic: the vast majority of slow queries in a normalized schema are fixed with an index. A five-table JOIN with the foreign keys indexed over a few hundred thousand rows is a millisecond operation in PostgreSQL. If it takes seconds, there is almost always a missing index, the query is asking for columns it does not need, or the planner is working with stale statistics.

Indexes, the execution plan, EXPLAIN ANALYZE and how to read it, and query optimization in general are the content of lesson 06-03. It is literally the lesson after this module, and the order is not accidental: first you learn to make the normalized schema fast, and only then do you consider changing it.

Operational rule: do not denormalize any query without first running its EXPLAIN ANALYZE and checking that there is no index that fixes it. If you cannot read an execution plan yet, you are not in a position to decide on a denormalization.

  1. The balance: what you gain and what you pay

Every denormalization is a trade. These are the two pans of the scale, and it is worth having them written down so you can compare in each specific case.

What you gain

Benefit What it consists of How much it can be worth
Fewer JOINs The data read together is in the same table Noticeable with many JOINs or large tables; irrelevant with two small, well-indexed tables
Faster reads Fewer disk pages to read, less work for the planner From 10 % to several orders of magnitude, depending on the case
Precomputed aggregates A report that used to sum ten million rows reads a table of a thousand This is where denormalization really wins: from minutes to milliseconds
Simpler queries Less SQL to write and maintain in the application Real, though almost never a sufficient reason on its own
Historical stability A value stays frozen and does not change even if its source does This is not performance: it is correctness. It is the strongest case of all

What you pay

Cost What it consists of Severity
Redundancy The same fact in two places It is the gateway to everything else
Risk of inconsistency The two places can disagree, and over time they do High. It is exactly what normalization existed to prevent
More expensive writes Every INSERT/UPDATE/DELETE touches more rows and more tables Proportional to the read/write imbalance
More complex writes The maintenance logic has to be written, tested and maintained Medium-high. It is new code that can fail
More space Duplicated data takes up more room Low. It almost never decides anything today
Contention risk A counter in a single row becomes a concurrency bottleneck High and rarely anticipated. See the note below
A schema that is harder to understand The next person will not know whether that column is source or copy Medium. Mitigated by documenting

The note on contention deserves a pause, because it is the cost that is least anticipated. If you add total_loans to the members table and update it on every loan, each front-desk operation has to lock the member's row. With one member borrowing one item at a time, nothing happens. But if tomorrow you add total_loans to branches, every operation at the North branch competes for the same row, and at peak time the front desk serializes. Locking mechanisms and isolation levels are lesson 06-02; for now just take away that a global counter is a denormalization with a concurrency cost that can be far worse than the JOIN it was avoiding.

  1. When it is justified: the three cases

Of all the reasons people give for denormalizing, only three survive scrutiny.

Case 1: a very unbalanced read/write ratio

Denormalization trades write cost for read speed. It only pays off if you read far more than you write.

In BiblioRed, a material's public page —title, author, availability per branch— is consulted from the web catalog around 40,000 times a day. The data it shows changes, at most, when a new copy comes in: two or three times a week. The ratio is tens of thousands of reads per write, and there a well-maintained copy pays for itself.

At the other extreme, the loans table is written to constantly during front-desk hours and is read mostly by member. Denormalizing it to speed up a report that runs once a month is a bad deal.

The numeric criterion: if the read/write ratio does not reach 10:1, it almost never pays off. Above 100:1, it starts to be interesting. And that ratio has to be measured, not estimated.

Case 2: expensive aggregates over many rows

This is the case where denormalization wins hands down and no other technique comes close.

Management wants a dashboard with loans by branch and month for the last eight years. On the normalized schema, that is a GROUP BY over 84,000 rows of loans with three JOINs. Today it takes a few seconds. When BiblioRed has been running twenty years and has millions of loans, it will take minutes, and the dashboard will be opened once every morning... by each of the fifteen managers.

The key point is that the data for closed months never changes. Recomputing the total for March 2019 on every query is throwing work away. Precomputing it once and storing it is the obvious decision. It is technique 4 in section 8.

Case 3: historical data that must stay frozen

And this is the most important of the three cases, because it is not a question of performance but of correctness. Here denormalization is not a compromise: it is the only correct answer.

Consider BiblioRed's fine 900: issued on 12 March 2026 to member 14 for a 35-day late return, amount €3.50, rate in force €0.10/day. In April, the city council raises the rate to €0.15/day.

How much does Marta Alsina owe for that fine? €3.50. She was notified in writing, it is on the receipt, and it cannot change. If fines did not store the amount and computed it with a JOIN to the rates table, in April that fine would retroactively become €5.25. That is not a design problem: it is a billing error.

The same reasoning applies to:

  • The member's name at the moment of payment. If a receipt says "Received from Marta Alsina" and she changes her surname, the receipt already issued does not change. The name on the receipt is a historical value, not a live reference to members.
  • A copy's purchase price. What was paid is what was paid.
  • An order's shipping address, in e-commerce. The order was shipped there, even if the customer has since moved.

This is exactly the pattern we called frozen historical duplication in lesson 03-03 when talking about document modeling, and classified as "category B": a duplicated field that is correct by semantics, not a copy that has to be propagated. The discipline it demands is different from that of a live copy: it is never updated, and precisely for that reason it carries no risk of inconsistency.

The test for telling them apart: ask yourself "if the original value changes tomorrow, must this one change too?". If the answer is no, it is not a performance denormalization: it is a different piece of data that happened to coincide with the original at the moment of creation, and storing it is the right thing to do.

  1. Technique 1: the redundant or computed column

The most common one and the easiest to get wrong. It consists of storing in one table a value that can be obtained by counting or summing rows from another.

The case: the member's page in the front-desk application shows how many loans they have in total and how many are open. Normalized:

SELECT m.member_id, m.first_name, m.last_name,
       COUNT(*)                                    AS total_loans,
       COUNT(*) FILTER (WHERE l.return_date IS NULL) AS open_loans
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
WHERE m.member_id = 14
GROUP BY m.member_id, m.first_name, m.last_name;

With an index on loans(member_id) this is instantaneous for one member. There is nothing to denormalize here, and it is important to say so: the temptation to add the counter shows up before you have checked whether it is needed.

Where the problem does appear is in the listing of the 12,000 members of the North branch with their number of loans, which the application pages 50 at a time. There the aggregate is computed over the whole table on every page.

The denormalization:

ALTER TABLE members
    ADD COLUMN total_loans INTEGER NOT NULL DEFAULT 0,
    ADD COLUMN open_loans  INTEGER NOT NULL DEFAULT 0;

-- Initial load from the source of truth
UPDATE members m
SET total_loans = c.total,
    open_loans  = c.open_
FROM (
    SELECT member_id,
           COUNT(*) AS total,
           COUNT(*) FILTER (WHERE return_date IS NULL) AS open_
    FROM loans GROUP BY member_id
) c
WHERE c.member_id = m.member_id;

The query becomes a plain SELECT over members, with no JOIN and no aggregation.

What has to be documented and not forgotten:

Question Answer for this case
What does it duplicate? An aggregate over loans
Which is the source of truth? loans, always. If they disagree, loans is right
Who maintains it? See section 12: application, trigger or batch
What happens if it goes out of sync? The page shows a wrong number. Low impact, but visible
How is it detected? A periodic audit query
How is it recomputed? The UPDATE ... FROM above

That last row is the most important one and the most often forgotten: every denormalized column needs a documented procedure for recomputing it from scratch. It is the safety net, and one day it will be used.

The audit query:

-- Detect members whose counter does not match reality
SELECT m.member_id, m.total_loans AS stored, COUNT(l.loan_id) AS real_
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
GROUP BY m.member_id, m.total_loans
HAVING m.total_loans <> COUNT(l.loan_id);

If that query returns rows, the maintenance mechanism has a hole. Scheduling it as a nightly check takes five minutes and saves months of bewilderment.

  1. Technique 2: generated columns versus hand-maintained columns

Not every derived column carries the same risk. There is an enormous difference between the ones the DBMS maintains and the ones your code maintains, and choosing well is the most profitable decision in this lesson.

Generated columns: the DBMS guarantees consistency

We already know them from module 4 and we examined them in 05-03:

-- In events
duration_min INTEGER GENERATED ALWAYS AS
                 (EXTRACT(EPOCH FROM (end_time - start_time)) / 60) STORED

-- In registrations
occupied_seats SMALLINT GENERATED ALWAYS AS (1 + companions) STORED

The word ALWAYS is the guarantee: PostgreSQL recomputes the value on every INSERT and every UPDATE, and rejects any attempt to write it by hand:

UPDATE registrations SET occupied_seats = 99 WHERE event_id = 210 AND member_id = 14;
ERROR:  cannot insert a non-DEFAULT value into column "occupied_seats"
DETAIL:  Column "occupied_seats" is a generated column.

It is a denormalization with no risk of inconsistency. It violates 3NF in the letter, and not in the spirit, because the danger 3NF prevents has been eliminated by another mechanism.

Its limitation is important: a generated column can only depend on columns of its own row and use deterministic functions. It cannot count rows in another table, or query branches, or use now(). That is why total_loans in members cannot be a generated column: it depends on another table.

Hand-maintained columns: the risk is yours

When a generated column will not do, maintenance becomes somebody's responsibility, and that is where the problems in section 12 start.

The decision table

Situation Solution Risk
Derived from columns of the same row, deterministic function Generated column ALWAYS ... STORED None
Derived from columns of the same row, but must be frozen in time Normal column + value computed on insert Low: it is never touched again
Derived from another table, tolerates seconds of lag Normal column + trigger Medium
Derived from another table, tolerates hours of lag Normal column + batch process Medium, but controlled
Aggregate over millions of rows Summary table or materialized view See sections 8 and 9

And the detail we already flagged in 05-03 and that is worth nailing down: a historical amount cannot be a generated column, even though it looks like one. amount = rate × days is a formula, yes, but if rate changes, a generated column would recompute the amount of the old fines. It has to be a normal column, computed once when the fine is issued and never again. The difference between duration_min —derived from start_time and end_time, which belong to the row itself and do not change— and an amount derived from a mutable external value is exactly this.

  1. Technique 3: duplicating an attribute to avoid a JOIN

The simplest technique: copy a column from table A into table B so that you do not have to join them.

The case: the list of active loans at the front desk shows the material's title. Normalized it takes three JOINs, because books is a view over materials + materials_book from the hierarchy in 04-03:

SELECT l.loan_id, l.loan_date, m.title
FROM loans l
JOIN copies c    ON c.copy_id     = l.copy_id
JOIN materials m ON m.material_id = c.material_id
WHERE l.return_date IS NULL AND c.branch_id = 2;

The denormalization:

ALTER TABLE loans ADD COLUMN material_title VARCHAR(200);

UPDATE loans l
SET material_title = m.title
FROM copies c
JOIN materials m ON m.material_id = c.material_id
WHERE c.copy_id = l.copy_id;

The query comes down to a SELECT over loans with a single JOIN to copies for the branch filter.

And now the honest part: in this particular case it is almost certainly not worth it. With indexes on the foreign keys, three JOINs over a few tens of thousands of rows are milliseconds. A live copy has been introduced —if somebody corrects a badly cataloged title, it has to be propagated to every loan— in exchange for a benefit that will probably not be noticed. It is the perfect example of a denormalization that looks reasonable and is not.

When would it be worth it? When the duplicated attribute satisfies at least one of these two conditions:

  • It is immutable. An edition's ISBN never changes. Copying it is free: there is nothing to propagate. It is "category A" from 03-03.
  • It must be frozen. The material's title at the moment of the loan, for a receipt or a history. That is category B: copied once and never touched.

If the attribute is live —it can change and the copy must follow it— duplication demands propagation, and then you have to ask whether the JOIN you avoid is worth the mechanism you add. Most of the time, it is not.

  1. Technique 4: pre-aggregated summary tables

This is where denormalization stops being a debatable compromise and becomes the obvious solution.

The case: management's dashboard with loans by branch and month since 2018.

CREATE TABLE loans_monthly_summary (
    year_             SMALLINT NOT NULL,
    month_            SMALLINT NOT NULL,
    branch_id         INTEGER  NOT NULL,
    total_loans       INTEGER  NOT NULL,
    distinct_members  INTEGER  NOT NULL,
    avg_loan_days     NUMERIC(5,2),
    late_loans        INTEGER  NOT NULL,
    closed            BOOLEAN  NOT NULL DEFAULT FALSE,
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT pk_loans_monthly_summary PRIMARY KEY (year_, month_, branch_id),
    CONSTRAINT chk_summary_month CHECK (month_ BETWEEN 1 AND 12),
    CONSTRAINT fk_summary_branch FOREIGN KEY (branch_id)
        REFERENCES branches (branch_id) ON UPDATE CASCADE
);

The load, which is a single aggregate query:

INSERT INTO loans_monthly_summary
    (year_, month_, branch_id, total_loans, distinct_members,
     avg_loan_days, late_loans, closed)
SELECT EXTRACT(YEAR  FROM l.loan_date)::SMALLINT,
       EXTRACT(MONTH FROM l.loan_date)::SMALLINT,
       c.branch_id,
       COUNT(*),
       COUNT(DISTINCT l.member_id),
       AVG(l.return_date - l.loan_date),
       COUNT(*) FILTER (WHERE l.return_date > l.due_date),
       TRUE
FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
WHERE l.loan_date < date_trunc('month', CURRENT_DATE)   -- closed months only
GROUP BY 1, 2, 3
ON CONFLICT (year_, month_, branch_id) DO UPDATE
   SET total_loans      = EXCLUDED.total_loans,
       distinct_members = EXCLUDED.distinct_members,
       avg_loan_days    = EXCLUDED.avg_loan_days,
       late_loans       = EXCLUDED.late_loans,
       updated_at       = now();

The dashboard goes from aggregating 84,000 rows (and growing) to reading a table of some 400 records —8 years × 12 months × 4 branches—. The performance difference is not 20 %: it is three orders of magnitude, and it cannot be achieved any other way.

Three design details that make this technique work well:

The closed column. It tells finished months —which will never change— apart from the current month, which will. Closed ones are computed once and forgotten; only the current month needs refreshing. It is what makes the maintenance cost almost zero.

The updated_at column. Anyone looking at the table knows how old the data is. Without it, nobody can judge whether a figure is reliable.

The ON CONFLICT ... DO UPDATE. It lets you run the load as many times as necessary without duplicating anything. A process that can be repeated with no side effects is infinitely easier to operate than one that has to be run exactly once.

And the rule that is not negotiable: the summary table is derived, never the source of truth. If loans_monthly_summary and loans disagree, loans is right and the summary is regenerated. The day somebody starts correcting figures directly in the summary, the denormalization has become a second data system inconsistent with the first.

  1. Technique 5: materialized views

A materialized view is a summary table the DBMS manages for you: it is defined with a query, PostgreSQL stores the result on disk, and it is refreshed when you ask it to.

CREATE MATERIALIZED VIEW mv_material_availability AS
SELECT m.material_id,
       m.title,
       c.branch_id,
       b.name                                          AS branch_name,
       COUNT(*)                                        AS total_copies,
       COUNT(*) FILTER (WHERE c.status = 'available')  AS available,
       COUNT(*) FILTER (WHERE c.status = 'on_loan')    AS on_loan
FROM materials m
JOIN copies   c ON c.material_id = m.material_id
JOIN branches b ON b.branch_id   = c.branch_id
GROUP BY m.material_id, m.title, c.branch_id, b.name;

-- A unique index is mandatory in order to refresh without locking (see below)
CREATE UNIQUE INDEX uq_mv_availability
    ON mv_material_availability (material_id, branch_id);

It is queried like any other table:

SELECT branch_name, available
FROM mv_material_availability
WHERE material_id = 4021 AND available > 0;

The refresh

This is the critical point, and the difference between the two forms is large:

-- Locks the view: nobody can read it while it runs
REFRESH MATERIALIZED VIEW mv_material_availability;

-- Does not lock: readers keep seeing the previous version until it finishes.
-- Requires the UNIQUE index above. It is slower, but it is the one used in production.
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_material_availability;

When it is refreshed

Strategy How When to use it
Scheduled A system job every N minutes or hours The usual one. Data that tolerates lag: catalog, reports
After a batch At the end of the nightly import process When the source changes at known moments
On demand The user clicks "refresh report" Heavy reports that are requested rarely
By trigger A TRIGGER on the base table fires the refresh Almost never. Refreshing the whole view on every write cancels out the benefit

For BiblioRed's web catalog, every 10 minutes is more than enough: the site saying "2 available" when there has been 1 for the last three minutes is acceptable, because the member is going to check at the front desk anyway.

Materialized view versus summary table

Materialized view Summary table
Definition A single CREATE MATERIALIZED VIEW statement CREATE TABLE + a load process
Refresh REFRESH, always complete (in PostgreSQL) Tailored: only the current month, only what changed
Risk of the logic diverging None: the query is in the definition It exists: the INSERT can drift from what was intended
Scalability Limited: refreshing the whole thing costs more every time Good: only what is needed gets refreshed
Effort Minimal Medium

Practical rule: always start with a materialized view. If the complete refresh takes too long —and with years of history it eventually will— migrate to a summary table with an incremental refresh. It is the order that minimizes the work.

(Note: SQLite has no materialized views. The equivalent is a normal table populated by the application. It is one of the differences to keep in mind when choosing between PostgreSQL and SQLite, as we saw in 01-02.)

  1. Technique 6: flat history tables for reporting

A variant of the summary table that does not aggregate but flattens: it stores one row per fact, but with all the columns you need already resolved, with no JOINs.

CREATE TABLE flat_loan_history (
    loan_id        INTEGER      NOT NULL,
    loan_date      DATE         NOT NULL,
    return_date    DATE,
    days_on_loan   INTEGER,
    -- Member details AT THE MOMENT of the loan
    member_id      INTEGER      NOT NULL,
    member_name    VARCHAR(140) NOT NULL,
    member_branch  VARCHAR(60)  NOT NULL,
    -- Material details AT THE MOMENT of the loan
    material_id    INTEGER      NOT NULL,
    material_title VARCHAR(200) NOT NULL,
    material_type  VARCHAR(20)  NOT NULL,
    author_name    VARCHAR(140),
    -- Copy details
    copy_code      VARCHAR(10)  NOT NULL,
    loan_branch    VARCHAR(60)  NOT NULL,
    CONSTRAINT pk_flat_loan_history PRIMARY KEY (loan_id)
);

Each row carries twelve columns that in the normalized schema would require five JOINs. Any report —loans by author and year, by material type and branch, by member age band— is resolved with a GROUP BY over a single table.

The important part of this technique is the phrase "at the moment of the loan". The values are copied when the loan is closed and are never updated again. If Marta Alsina transfers to the South branch in 2027, the loans she made in 2026 still say "North", which is the historical truth. A report on the North branch's activity in 2026 must count them.

Here is the key difference from the previous techniques: this is not a copy that has to be kept in sync. It is a record of what was true then. There is no risk of inconsistency because there is nothing to propagate, and that is why this is one of the safest denormalizations there is.

-- It is populated when the loan is closed, with the values in force at that moment
INSERT INTO flat_loan_history
SELECT l.loan_id, l.loan_date, l.return_date,
       l.return_date - l.loan_date,
       m.member_id, m.first_name || ' ' || m.last_name, mb.name,
       mt.material_id, mt.title, mt.type,
       a.first_name || ' ' || a.last_name,
       c.code, cb.name
FROM loans l
JOIN members   m  ON m.member_id    = l.member_id
JOIN branches  mb ON mb.branch_id   = m.branch_id
JOIN copies    c  ON c.copy_id      = l.copy_id
JOIN branches  cb ON cb.branch_id   = c.branch_id
JOIN materials mt ON mt.material_id = c.material_id
LEFT JOIN authors a ON a.author_id  = mt.author_id
WHERE l.loan_id = 5001;

  1. Technique 7: the star schema of data warehouses

All the previous techniques are one-off denormalizations on a normalized schema. The star schema is something else: it is a complete data model designed from the start to denormalize, systematically and as a matter of policy.

Here we pick up the OLTP versus OLAP distinction from lesson 01-02:

OLTP (operational BiblioRed) OLAP (analytical warehouse)
What for Recording loans, registering members Analyzing ten years of activity
Operations Many, small, concurrent Few, enormous, sequential
Writes Constant Only the periodic load
Priority Integrity, low latency Bulk read performance
Design Normalized (3NF) Denormalized (star)

Facts and dimensions

The star schema organizes the data into two kinds of table:

  • Fact table: one row per measurable event, with the numeric metrics that are going to be aggregated and foreign keys to the dimensions. It is enormous —millions or billions of rows— and very narrow.
  • Dimension tables: the context you want to filter and group by. They are small, wide and deliberately denormalized: a dimension is not decomposed even if it has transitive dependencies.
-- DIMENSION: the material. Note that author, publisher and type are
-- FLATTENED here, instead of being in separate tables. That is intentional.
CREATE TABLE dim_material (
    material_key       INTEGER      PRIMARY KEY,
    material_id        INTEGER      NOT NULL,
    title              VARCHAR(200) NOT NULL,
    type               VARCHAR(20)  NOT NULL,
    author_name        VARCHAR(140),
    author_nationality VARCHAR(40),      -- transitive via author: accepted
    publisher          VARCHAR(80),
    publication_year   SMALLINT,
    language           VARCHAR(20)
);

-- DIMENSION: the branch, with its geography flattened
CREATE TABLE dim_branch (
    branch_key  INTEGER     PRIMARY KEY,
    branch_id   INTEGER     NOT NULL,
    name        VARCHAR(60) NOT NULL,
    city        VARCHAR(60) NOT NULL,   -- transitive via postal code: accepted
    postal_code VARCHAR(5)  NOT NULL,
    district    VARCHAR(60)
);

-- DIMENSION: time. Every way of looking at a date, precomputed
CREATE TABLE dim_date (
    date_key     INTEGER PRIMARY KEY,     -- 20260409
    date         DATE    NOT NULL,
    year_        SMALLINT NOT NULL,
    quarter      SMALLINT NOT NULL,
    month_       SMALLINT NOT NULL,
    month_name   VARCHAR(12) NOT NULL,
    day_of_week  SMALLINT NOT NULL,
    is_holiday   BOOLEAN NOT NULL DEFAULT FALSE
);

-- FACT: a loan. Narrow, extremely long, only keys and metrics
CREATE TABLE fact_loan (
    loan_key         BIGINT  PRIMARY KEY,
    date_key         INTEGER NOT NULL REFERENCES dim_date (date_key),
    member_key       INTEGER NOT NULL REFERENCES dim_member (member_key),
    material_key     INTEGER NOT NULL REFERENCES dim_material (material_key),
    branch_key       INTEGER NOT NULL REFERENCES dim_branch (branch_key),
    days_on_loan     SMALLINT,
    days_late        SMALLINT NOT NULL DEFAULT 0,
    surcharge_amount NUMERIC(6,2) NOT NULL DEFAULT 0,
    num_loans        SMALLINT NOT NULL DEFAULT 1
);

It is called a "star" because the diagram has the fact table in the center and the dimensions around it:

flowchart TD
    DF["dim_date"] --> H
    DS["dim_member"] --> H
    DM["dim_material"] --> H
    DSU["dim_branch"] --> H
    H["<b>fact_loan</b><br/>metrics + keys"]

And analytical queries become trivial to write and extremely fast to run:

-- Loans and late returns by author nationality and quarter, 2025
SELECT d.year_, d.quarter, m.author_nationality,
       SUM(f.num_loans) AS loans,
       AVG(f.days_late) AS avg_days_late
FROM fact_loan f
JOIN dim_date     d ON d.date_key     = f.date_key
JOIN dim_material m ON m.material_key = f.material_key
WHERE d.year_ = 2025
GROUP BY d.year_, d.quarter, m.author_nationality
ORDER BY loans DESC;

A single level of JOIN, with no chains. On the normalized schema, getting from a loan to the author's nationality requires traversing loans → copies → materials → authors.

Why analytics denormalizes as a matter of policy

Four reasons, and all four are solid:

  1. There are no concurrent writes. The warehouse is loaded in batches from the operational system. The main cost of denormalization —keeping consistency in the face of writes— simply does not exist.
  2. The source of truth is somewhere else. If the warehouse gets corrupted, it is reloaded from the OLTP. The redundancy cannot cause an unrecoverable loss.
  3. The data is historical and immutable. A 2019 loan does not change. And when the context changes —a material is recataloged— the right thing to do is keep the old value for the old facts, which is exactly what denormalization gives you.
  4. The query pattern is known and stable. You know in advance what you are going to group by, and the model is designed for that.

In short: in OLAP all the conditions that justify denormalizing hold at once, and none of those that argue against it. That is why there it is the rule and not the exception.

(There is a variant called snowflake that does normalize the dimensions —pulling author out of dim_material into its own table, for example—. It saves space and complicates the queries. The majority view in the industry is star unless the dimensions are gigantic. Data warehouse design is a discipline of its own; here we only care about recognizing it as systematic denormalization and understanding why it is justified.)

  1. How you keep denormalized data consistent

Every denormalization that is not frozen history creates an obligation: keeping the copy in sync with the source. There are three ways of meeting it and you have to choose consciously.

Option A: in the application

The code that writes the loan also updates the counter:

BEGIN;
    INSERT INTO loans (member_id, copy_id, loan_date, due_date)
    VALUES (14, 3081, CURRENT_DATE, CURRENT_DATE + 21);

    UPDATE members
    SET total_loans = total_loans + 1,
        open_loans  = open_loans + 1
    WHERE member_id = 14;
COMMIT;

In favor: the logic is where the team can see it, it is easy to debug and to test.

Against, and this is a serious problem: it only takes one write path forgetting for the copy to start diverging. And there are more write paths than it seems: the web application, the front-desk application, the nightly import process, the correction script somebody ran by hand one Tuesday afternoon. Every one of them has to remember.

Option B: with a trigger (TRIGGER)

A trigger is a function the DBMS runs automatically when an event happens on a table. The decisive advantage: it does not matter who writes or from where.

-- The function that does the work
CREATE OR REPLACE FUNCTION fn_update_member_counter()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        UPDATE members
        SET total_loans = total_loans + 1,
            open_loans  = open_loans
                          + CASE WHEN NEW.return_date IS NULL THEN 1 ELSE 0 END
        WHERE member_id = NEW.member_id;

    ELSIF TG_OP = 'DELETE' THEN
        UPDATE members
        SET total_loans = total_loans - 1,
            open_loans  = open_loans
                          - CASE WHEN OLD.return_date IS NULL THEN 1 ELSE 0 END
        WHERE member_id = OLD.member_id;

    ELSIF TG_OP = 'UPDATE' THEN
        -- Return: it goes from open to closed
        IF OLD.return_date IS NULL AND NEW.return_date IS NOT NULL THEN
            UPDATE members SET open_loans = open_loans - 1
            WHERE member_id = NEW.member_id;
        END IF;
    END IF;

    RETURN NULL;   -- AFTER trigger: the returned value is ignored
END;
$$ LANGUAGE plpgsql;

-- The trigger that invokes it
CREATE TRIGGER trg_member_counter
    AFTER INSERT OR UPDATE OR DELETE ON loans
    FOR EACH ROW
    EXECUTE FUNCTION fn_update_member_counter();

A line-by-line reading of the code, because this is the first time a trigger appears in the course:

  • RETURNS TRIGGER marks the function as suitable for being invoked by a trigger.
  • TG_OP is a special variable holding the operation: 'INSERT', 'UPDATE' or 'DELETE'.
  • NEW is the new row (it exists in INSERT and UPDATE); OLD is the previous one (it exists in UPDATE and DELETE).
  • AFTER ... FOR EACH ROW means it runs once per affected row, after the change is applied. If an UPDATE touches 500 rows, the trigger runs 500 times.
  • Everything happens inside the same transaction as the original operation: if the counter UPDATE fails, the loan INSERT is rolled back too. That atomicity is precisely what makes this option reliable, and it is the material of lesson 06-01.

In favor: impossible to bypass. Consistency guaranteed by the DBMS.

Against: it is business logic hidden in the database, invisible to whoever reads the application code; it complicates debugging; and it costs on every write. A bulk UPDATE of 100,000 rows runs the trigger 100,000 times.

Option C: by batch process

A scheduled job recomputes the copy periodically:

-- Every night at 3:00
UPDATE members m
SET total_loans = COALESCE(c.total, 0),
    open_loans  = COALESCE(c.open_, 0)
FROM (
    SELECT member_id, COUNT(*) AS total,
           COUNT(*) FILTER (WHERE return_date IS NULL) AS open_
    FROM loans GROUP BY member_id
) c
WHERE c.member_id = m.member_id;

In favor: zero cost on writes, simple code, and —this is valuable— it corrects any divergence by itself, wherever it came from.

Against: the data is stale between runs. You have to decide whether that is acceptable, and say so in the interface ("data as of yesterday").

The comparison

Application Trigger Batch
Consistency guarantee Low: depends on every path doing it High: the DBMS enforces it Medium: exact after each run
Latency Immediate Immediate Until the next cycle
Cost on writes Medium Medium-high None
Operating cost Low Low Medium: there is a process to watch
Visibility for the team High Low: you have to go looking for it Medium
Bypassed if you write from elsewhere Yes No Not applicable: it self-corrects
Self-correcting No No Yes
Recommended for Non-critical copies with a single write path Critical copies that must always be exact Aggregates and reports that tolerate lag

The combination that works best in practice: pick one of the three for maintenance and always add the batch one as an audit. Even if you use a trigger, schedule the nightly check query. If it ever returns rows, you will know there is a hole before a user discovers it.

  1. The bridge to NoSQL: document modeling is denormalization raised to a method

If while reading section 7 you thought "this looks a lot like embedding documents", you have seen exactly what there is to see.

The document modeling of lesson 03-03 —embedding instead of referencing, duplicating fields on purpose, designing the aggregate around the query that will read it— is denormalization. It is not a similar technique: it is the same decision, taken for the same reasons, with the same consequences.

Compare it point by point:

In the relational world (this lesson) In the document world (03-03)
Duplicating an attribute to avoid a JOIN Embedding a subdocument to avoid a second query
Pre-aggregated summary table Counter fields inside the aggregate document
Redundant column maintained by a trigger Duplicated field with propagation when the original is updated
Frozen historical value (a fine's amount) Duplicated field of category B: frozen history
Copied immutable attribute (the ISBN) Duplicated field of category A: immutable, free
Source of truth versus derived copy The canonical collection versus the read aggregate

The difference is not in the technique but in the default starting point. In a relational system, the norm is to normalize and to denormalize in specific, justified cases. In a document system, the norm is to aggregate —denormalize— and to normalize (reference) in specific, justified cases. The axis is the same; what changes is where the neutral point sits.

And the discipline it demands is identical. In 03-03 we established that every duplicated field must be classified —immutable, frozen history, or live with propagation— and that if it is not written down, in six months nobody will know whether it has to be updated. It is exactly the documentation that section 5 of this lesson demands for every redundant column. The rule is the same in both worlds: duplicate what you display, never what you use to decide; and always write down which is the source of truth.

This also explains something that in module 3 could sound contradictory. When we said that MongoDB "does not need JOINs" we were not saying that the problem the JOIN solves had disappeared: we were saying that it is paid up front, on the write, in the form of maintained duplication. It is the same trade as in section 3 of this lesson, with the same pans on the scale.

  1. Decision guide: the questions beforehand and the signs to undo it

The seven questions before denormalizing

Answer them in writing. If any of them has no answer, do not denormalize yet.

1. Have I measured the problem? How long does the query take now, with production data and real volume? If you do not have the number, you do not have a problem: you have a suspicion.

2. Have I tried an index? EXPLAIN ANALYZE of the query, a review of the existing indexes, ANALYZE on the tables. This is lesson 06-03 and it is mandatory before going any further.

3. What is the specific aim? "This report must open in under two seconds" is an aim. "Go faster" is not, because you cannot tell whether it has been achieved.

4. What is the read/write ratio? Measured, not estimated. Below 10:1, denormalization rarely pays off.

5. Is the copy immutable, frozen history or live? It is the question that saves the most. The first two are almost free. Only the third demands a propagation mechanism, and only then do you have to answer the next two.

6. Who maintains the copy and what happens if it fails? Application, trigger or batch (section 12). And the failure scenario: a wrong number on a screen, or a wrong amount on an invoice? The severity decides the mechanism.

7. How is divergence detected and repaired? The audit query and the recomputation procedure, written down and scheduled. If you do not have them, the denormalization is not finished.

The decision tree

flowchart TD
    A["Slow query<br/>or value that must be frozen"] --> B{"Is it a historical value<br/>that must be frozen?"}
    B -->|Yes| C["Store it. It is not<br/>optional denormalization:<br/>it is the right thing"]
    B -->|No| D{"Have you measured<br/>with EXPLAIN ANALYZE?"}
    D -->|No| E["Measure it first<br/>→ 06-03"]
    D -->|Yes| F{"Does an index<br/>fix it?"}
    F -->|Yes| G["Create the index.<br/>End of problem"]
    F -->|No| H{"Is it an aggregate<br/>over many rows?"}
    H -->|Yes| I["Materialized view<br/>or summary table"]
    H -->|No| J{"Read/write ratio<br/>greater than 10:1?"}
    J -->|No| K["Do not denormalize.<br/>Review the query"]
    J -->|Yes| L{"Is the copied value<br/>immutable?"}
    L -->|Yes| M["Duplicate. Almost zero cost"]
    L -->|No| N["Duplicate + propagation<br/>mechanism + audit.<br/>Document it"]

The signs that it is time to undo it

A denormalization is not forever. These six signs indicate that it needs reviewing, and probably reverting:

1. The audit queries return rows regularly. The maintenance mechanism has a hole that has not been closed. Every divergence detected is a value somebody saw wrong before the audit caught it.

2. Nobody remembers why that column is there. If the documentation does not exist or nobody can find it, the denormalization is no longer deliberate: it is debt.

3. The copy has become the source of truth. The symptom is somebody correcting a value in the copy instead of in the original. From then on there are two data systems contradicting each other and no way of deciding which one rules.

4. Writes have become the bottleneck. The system optimized reads and now the front desk waits. Measure again: the balance may have shifted.

5. The original reason has gone away. Nobody uses the report that justified the summary table any more. The new version of PostgreSQL runs that JOIN a hundred times faster. An index was added that solves the case. Review your denormalizations at least once a year: some of them expire.

6. The maintenance logic has become more complex than the JOIN it was avoiding. If the trigger has forty lines and three special cases in order to save a two-table JOIN, the trade no longer makes sense.

How it is undone

With the same discipline it was done with, and in the reverse order to 05-03: the reads are switched first so that they use the normalized schema, you check that they give the same results, the maintenance mechanism is removed, and only at the end is the column or the table dropped. Saving a copy beforehand, always.

Common Mistakes and Tips

Denormalizing without having measured. It is mistake number one and the cause of most of the unnecessary redundancy out there in production. "This is going to be slow when it grows" is a prediction, not a measurement, and predictions about performance fail constantly: the bottleneck is almost never where you expected.

Denormalizing before trying an index. That is the whole of section 2. A CREATE INDEX is reversible, free in terms of risk, and instantaneous; a denormalization is permanent in practice. Start with 06-03.

Starting out denormalized "just in case". It breaks the golden rule. You do not yet know which queries will dominate, or at what volume, or with what write pattern. And undoing it afterwards is far more expensive than doing it now.

Not documenting the source of truth. Every duplicated value has to have written down which of the two copies rules. Without that, the day they disagree —and they will— nobody will know which to correct, and somebody will pick wrong.

Treating the copy as the source of truth. The symptom is a direct UPDATE on the summary table to "make a figure add up". That UPDATE fixes nothing: it creates a permanent divergence that the next refresh will wipe out, or worse, will not.

Putting a trigger that refreshes an entire materialized view on every write. It completely cancels out the benefit and turns every INSERT into a global recomputation. Materialized views are refreshed on a schedule.

Forgetting the recomputation procedure. Every denormalization needs a documented UPDATE/INSERT that rebuilds it from scratch. One day it will have to be run, in a hurry, and that will not be the moment to write it.

Confusing "frozen value" with "denormalized value". They are not the same and confusing them leads to two opposite mistakes: propagating a value that should have stayed still (and falsifying a history), or failing to propagate a live copy (and showing stale data). The question that separates them is in section 4: if the original changes tomorrow, must this one change too?

Never reviewing. Denormalizations expire. An annual review of all the ones in the schema, with the measurements repeated, usually finds at least one that is no longer needed.

Exercises

Exercise 1: Deciding whether to denormalize

For each of these four BiblioRed cases, decide whether you would denormalize or not. Justify it with the questions from section 14 and, if you do denormalize, say which technique you would use and which maintenance mechanism.

a) An event's detail page shows the room's name and its capacity. It is consulted around 300 times a day. The normalized query takes 4 milliseconds.

b) The annual management report crosses the 84,000 loans with members, materials, authors and branches, grouping by author and year. It takes 38 seconds and 15 people open it every morning throughout January.

c) The receipt printed when a fine is paid shows the member's name, the amount and the reason.

d) The web catalog shows, for each material, how many copies are available at each branch. It is consulted 40,000 times a day and the data changes with every loan and every return.

Exercise 2: Detecting and repairing a divergence

Six months ago BiblioRed added the column members.total_loans, maintained by the front-desk application (option A in section 12). Today, the manager of the North branch says that a member's page shows 12 loans and their history only has 9.

You are asked to:

  • a) Write the audit query that finds all the members whose counter does not add up, showing the difference.
  • b) Write the UPDATE that repairs the counter for all of them.
  • c) Propose three plausible causes of the divergence, bearing in mind that maintenance is in the application.
  • d) Propose the change of mechanism that would stop it happening again, and say what you gain and what you pay.

Exercise 3: Designing a summary table

BiblioRed's management asks for an events activity dashboard with these figures, by branch and month: number of events held, total number of confirmed registrations, offered seats, average occupancy as a percentage, and average rating from the event reports.

You are asked to:

  • a) Write the CREATE TABLE for the summary table, with its primary key and the control columns recommended in section 8.
  • b) Write the INSERT ... SELECT that loads it from events, rooms, registrations and event_reports, covering closed months only.
  • c) Decide the refresh mechanism and justify it.
  • d) Write the audit query that checks that one row of the summary matches the operational data.

Solutions

Solution 1

a) Do not denormalize. Question 1 settles it: 4 milliseconds is not a problem. With 300 queries a day, the total CPU time devoted to that JOIN is a little over a second a day. Introducing a copy of room_name and room_capacity into events would in addition duplicate a live value —the capacity changes if the room is refurbished, and in fact that is the update anomaly example we used in 04-01—, so it would demand a complete propagation mechanism. High cost, zero benefit.

b) Do denormalize: a summary table or a materialized view. It is case 2 of section 4 in its purest form. 38 seconds × 15 people = almost ten minutes of accumulated waiting a day, over data that does not change: the loans of closed years are immutable. And question 2 is not going to save it: no index significantly speeds up a GROUP BY that walks all 84,000 rows anyway.

The right technique is a summary table with author-year granularity and a closed column, refreshed once a month by a batch process. Past years are computed once in their lifetime. The maintenance cost tends to zero and the query goes from 38 seconds to a few milliseconds.

c) Yes, but this is not an optional denormalization: it is correctness. It is case 3 of section 4. The receipt issued on 12 March says what it says and cannot change: not if the member changes their name, not if the by-law raises the rate, not if the fine is reclassified. All three values must be copied into the payments table (or into a receipts table) at the moment the receipt is issued, and never touched again.

No maintenance mechanism is needed —precisely because nothing is propagated— and there is no risk of inconsistency. It is the safest denormalization there is and the only one of the four where it would be a mistake not to do it.

d) Do denormalize: a materialized view. It is case 1 of section 4. The read/write ratio is overwhelming: 40,000 queries a day against a few hundred loans and returns. And the normalized query is a grouped COUNT over copies for each material, which on the catalog page runs many times.

The technique is the mv_material_availability from section 9, with a scheduled refresh every 5 or 10 minutes. The key is accepting the lag: the web catalog saying "2 available" when there is 1 left is tolerable, because the member will check the real availability when they ask for it. What would not be tolerable is using that view to decide whether to grant a loan: for that you have to query copies, which is the source of truth. It is the exact application of the rule from 03-03: duplicate what you display, never what you use to decide.

Solution 2

a) Audit query:

SELECT m.member_id,
       m.first_name || ' ' || m.last_name AS member,
       m.total_loans                      AS stored,
       COUNT(l.loan_id)                   AS real_,
       m.total_loans - COUNT(l.loan_id)   AS difference
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
GROUP BY m.member_id, m.first_name, m.last_name, m.total_loans
HAVING m.total_loans <> COUNT(l.loan_id)
ORDER BY abs(m.total_loans - COUNT(l.loan_id)) DESC;

The LEFT JOIN is indispensable: with an inner JOIN, members with no loans at all would disappear from the result, and they are precisely the ones who can have a wrongly positive counter.

b) Repair:

BEGIN;

UPDATE members m
SET total_loans = COALESCE(c.total, 0)
FROM (
    SELECT m2.member_id, COUNT(l.loan_id) AS total
    FROM members m2
    LEFT JOIN loans l ON l.member_id = m2.member_id
    GROUP BY m2.member_id
) c
WHERE c.member_id = m.member_id
  AND m.total_loans <> COALESCE(c.total, 0);

-- Check before committing: it must return 0 rows
SELECT COUNT(*) FROM (
    SELECT m.member_id FROM members m
    LEFT JOIN loans l ON l.member_id = m.member_id
    GROUP BY m.member_id, m.total_loans
    HAVING m.total_loans <> COUNT(l.loan_id)
) x;

COMMIT;

The COALESCE covers members with no loans, whose COUNT over the LEFT JOIN gives 0 but whose subset might not show up. And running the check inside the transaction, before the COMMIT, is the correct practice: if the number is not zero, you ROLLBACK.

c) Three plausible causes, all of them characteristic of maintenance in the application:

  1. A write path that does not update the counter. The nightly process importing loans from the neighboring library, or the correction script somebody ran by hand, inserted into loans without touching members. It is the most frequent cause.
  2. A DELETE that was not accounted for. When Iván Pereda asked for his history to be deleted, the loans rows were deleted but the code only decremented the counter in the return case, not in the deletion case.
  3. A partially committed transaction. If the INSERT and the UPDATE were not inside the same transaction, a failure between the two leaves the loan inserted and the counter not incremented. It is a subtle bug and it produces off-by-one divergences that are very hard to trace afterwards.

d) Change of mechanism: move to a trigger (option B), and add the batch audit.

The trigger from section 12 runs wherever the write comes from: the web application, the front desk, the import process or somebody's psql on a Tuesday afternoon. It eliminates causes 1 and 2 at the root. And since it runs inside the same transaction as the original operation, it eliminates 3 as well.

What you gain: consistency guaranteed by the DBMS, not by the discipline of every team that writes.

What you pay: an added cost on every write to loans —noticeable if a bulk UPDATE is ever run—; business logic that lives in the database and cannot be seen by reading the application code; and one more plpgsql function to test and maintain.

And in any case, the query from part a) gets scheduled as a nightly check anyway. Even with a trigger: if the trigger is ever disabled for a bulk load and somebody forgets to re-enable it, the audit will catch it that same night.

Solution 3

a) The summary table:

CREATE TABLE events_monthly_summary (
    year_                  SMALLINT     NOT NULL,
    month_                 SMALLINT     NOT NULL,
    branch_id              INTEGER      NOT NULL,
    events_held            INTEGER      NOT NULL DEFAULT 0,
    confirmed_regs         INTEGER      NOT NULL DEFAULT 0,
    offered_seats          INTEGER      NOT NULL DEFAULT 0,
    avg_occupancy_pct      NUMERIC(5,2),
    avg_rating             NUMERIC(3,2),
    closed                 BOOLEAN      NOT NULL DEFAULT FALSE,
    updated_at             TIMESTAMPTZ  NOT NULL DEFAULT now(),
    CONSTRAINT pk_events_monthly_summary PRIMARY KEY (year_, month_, branch_id),
    CONSTRAINT chk_ev_summary_month CHECK (month_ BETWEEN 1 AND 12),
    CONSTRAINT chk_ev_summary_occupancy
        CHECK (avg_occupancy_pct IS NULL OR avg_occupancy_pct BETWEEN 0 AND 100),
    CONSTRAINT chk_ev_summary_rating
        CHECK (avg_rating IS NULL OR avg_rating BETWEEN 0 AND 5),
    CONSTRAINT fk_ev_summary_branch FOREIGN KEY (branch_id)
        REFERENCES branches (branch_id) ON UPDATE CASCADE
);

The closed and updated_at columns are the control ones the brief asked for. The CHECKs are not decoration: a derived table with a 340 % occupancy rate gives away a bug in the load query, and it is better for the INSERT to catch it than a director in a meeting.

b) The load:

INSERT INTO events_monthly_summary
    (year_, month_, branch_id, events_held, confirmed_regs,
     offered_seats, avg_occupancy_pct, avg_rating, closed)
SELECT EXTRACT(YEAR  FROM e.start_time)::SMALLINT AS year_,
       EXTRACT(MONTH FROM e.start_time)::SMALLINT AS month_,
       r.branch_id,
       COUNT(DISTINCT e.event_id),
       COALESCE(SUM(i.confirmed), 0),
       SUM(e.offered_seats),
       CASE WHEN SUM(e.offered_seats) > 0
            THEN 100.0 * COALESCE(SUM(i.confirmed), 0) / SUM(e.offered_seats)
            ELSE NULL END,
       AVG(rep.average_rating),
       TRUE
FROM events e
JOIN rooms r ON r.room_id = e.room_id
LEFT JOIN LATERAL (
    SELECT SUM(reg.occupied_seats) AS confirmed
    FROM registrations reg
    WHERE reg.event_id = e.event_id
      AND reg.status IN ('confirmed','attended')
) i ON TRUE
LEFT JOIN event_reports rep ON rep.event_id = e.event_id
WHERE e.status = 'held'
  AND e.start_time < date_trunc('month', CURRENT_DATE)  -- closed months only
GROUP BY 1, 2, r.branch_id
ON CONFLICT (year_, month_, branch_id) DO UPDATE
   SET events_held       = EXCLUDED.events_held,
       confirmed_regs    = EXCLUDED.confirmed_regs,
       offered_seats     = EXCLUDED.offered_seats,
       avg_occupancy_pct = EXCLUDED.avg_occupancy_pct,
       avg_rating        = EXCLUDED.avg_rating,
       updated_at        = now();

Three decisions worth commenting on:

  • The LATERAL subquery for the registrations avoids the classic mistake of multiplying rows when you join two detail tables (registrations and reports) against the same events table. Without it, an event with 20 registrations and one report would produce 20 rows and the average rating would be counted 20 times. It is exactly the "inventing rows when joining" problem from 05-03, in its aggregation form.
  • e.status = 'held' excludes cancelled and scheduled events, which should not count as activity.
  • occupied_seats instead of counting registrations: the generated column in registrations already includes the companions, which is what really takes up capacity.

c) Refresh mechanism: a batch process, monthly, on the 1st of each month.

The justification lies in the nature of the data. Closed months never change: an event held in March with its registrations and its report is a done deal. Refreshing more often would be pointless work. And since the dashboard is a management tool looked at monthly, data "as of the end of last month" is exactly what is needed.

The ON CONFLICT ... DO UPDATE additionally lets you re-run the load safely if an event report is filled in late.

A trigger here would be a serious mistake: recomputing monthly aggregates on every registration is a permanent cost for a benefit consumed once a month.

d) Audit query for a particular row (March 2026, North branch):

WITH operational AS (
    SELECT COUNT(DISTINCT e.event_id) AS events,
           SUM(e.offered_seats)       AS seats
    FROM events e
    JOIN rooms r ON r.room_id = e.room_id
    WHERE r.branch_id = 2
      AND e.status = 'held'
      AND e.start_time >= '2026-03-01' AND e.start_time < '2026-04-01'
),
summary AS (
    SELECT events_held AS events, offered_seats AS seats
    FROM events_monthly_summary
    WHERE year_ = 2026 AND month_ = 3 AND branch_id = 2
)
SELECT o.events AS events_operational, s.events AS events_summary,
       o.seats  AS seats_operational,  s.seats  AS seats_summary,
       (o.events = s.events AND o.seats = s.seats) AS matches
FROM operational o CROSS JOIN summary s;
 events_operational | events_summary | seats_operational | seats_summary | matches
--------------------+----------------+-------------------+---------------+---------
                 14 |             14 |               420 |           420 | t

If matches is f, the summary table has drifted and the load for that month has to be re-run. Scheduling this check for the previous month, run weekly, is enough: the data for closed months should not move, and if it does then somebody is correcting historical data, which is something worth knowing about.

Conclusion

This module started with a promise from module 4: to put the BiblioRed schema through a formal examination we had avoided until then. It is done, and it is worth looking at the whole journey.

In 05-01 we built the instruments. The three anomalies —insertion, update and deletion— stopped being a footnote and became three failures we provoked with SQL over the loans sheet. We learned to write their cause as a functional dependency X → Y, to tell full dependencies from partial and transitive ones, to derive with Armstrong's axioms, and to compute the closure X⁺ in order to find candidate keys with an algorithm instead of with intuition.

In 05-02 we went through the catalog. First normal form and the atomicity that depends on usage; second and partial dependencies; third and transitive ones; Boyce-Codd with its one-line definition and its small print about dependency preservation; fourth and independent multivalued dependencies; fifth and the honesty of saying it almost never shows up. And the criterion that governs everything: up to 3NF/BCNF always, beyond that only if the case calls for it.

In 05-03 we did the work. From a thirteen-column spreadsheet to nine tables in BCNF, step by step, with the data in front of us and the migration SQL. We learned that INSERT ... SELECT DISTINCT is the canonical way to migrate, that a duplicate key error is the new schema doing its job, that Heath's condition is what separates a correct decomposition from one that invents rows, and that in production you normalize in phases and not in one go. And we found a real defect in the module 4 schema: fines violated 3NF through loan_id → member_id, which made it possible to charge one member for another's fine.

And in this lesson we have closed the circle with the opposite decision. Denormalizing is not the opposite of normalizing: it is what you do after normalizing, on a schema that is already correct, in order to get something specific that the schema did not give you. We have seen the seven techniques —redundant column, generated column, duplicated attribute, summary table, materialized view, flat history, star schema—, the three ways of maintaining consistency with their guarantees and their costs, and the seven questions to answer in writing before touching anything. We have also seen that the strongest case for denormalizing is not performance but correctness: a fine's amount, the name on a receipt and the rate on an invoice are frozen historical values, and storing them is not a concession, it is the only correct answer. And we have recognized that the document modeling of 03-03 is this same discipline moved to the center of the method, with the same categories and the same obligations.

The golden rule sums up the whole module: normalize first, then denormalize on purpose, measuring, and never the other way round. A normalized schema that has been stepped back from at two specific, documented, measured and audited points is a good schema. A redundant schema that never went through normalization is not a denormalized schema: it is an undesigned one.

That closes module 5, Normalization. BiblioRed's schema has passed the examination, with one defect found and fixed and four denormalizations now justified and written down. We know that the structure is correct and that the data cannot contradict itself. What we still do not know is what happens when two people at the front desk record a loan of the same copy at the very same instant, or what happens if the server shuts down halfway through an operation, or how long a query really takes when the table has ten million rows, or who can read the members' phone numbers. In module 6, Transactions, Performance and Security, we stop looking at the schema and start looking at the system in operation: transactions and the ACID properties that guarantee an operation happens in full or not at all (06-01); concurrency and the isolation levels that decide what each user sees while another writes (06-02); indexes and execution plans, which are —remember— the first thing to try before denormalizing (06-03); and security, permissions and backups, which are what separates a database from an accident waiting to happen (06-04).

© Copyright 2026. All rights reserved