In the previous lesson you started from a client telling you what they needed and arrived at a schema. Here the starting point is the opposite: a table that already exists and is wrong. A flat listing exported from a spreadsheet, a history with the member's name copied into every row, a table where changing one phone number forces you to touch fourteen records. Your job is to prove formally what is wrong with it, predict what it is going to break and fix it without losing information.

How to work through this lesson. Normalization is one of the few parts of databases that can be done without a computer, with pencil and paper, and it is worth doing it that way at least the first few times. The procedure for every exercise is always the same:

  1. Write the functional dependencies that follow from the business rules and from the data.
  2. Compute the closures you need and determine all the candidate keys.
  3. Classify the attributes as prime and non-prime.
  4. Go through the normal forms in order —1NF, 2NF, 3NF, BCNF, 4NF— and stop at the first one that is violated, pointing at the guilty dependency.
  5. Decompose, check that the decomposition is lossless and check whether it preserves the dependencies.

A warning about the data: in several exercises you will see a table with specific rows. Data does not prove a functional dependency, it can only refute it. The fact that in five rows each advisor appears with a single specialty does not prove that advisor → specialty; what proves it is the business rule. On the other hand, if two rows had the same advisor with different specialties, it would be refuted. The data is the check, the rules are the source.

In this lesson there is no design from requirements (that is 07-02) and no execution plans (that is 07-04). Only formal analysis, decomposition and a final denormalization decision.

Before You Begin

You do not need any previous dataset: each exercise brings its own and the ones in block C include the SQL to create the starting table. What is worth having at hand is:

  • The definition of functional dependency and Armstrong's axioms (reflexivity, augmentation, transitivity) from lesson 05-01.
  • The attribute closure algorithm X⁺: start from X, and while there is some dependency Y → Z with Y ⊆ X⁺, add Z to X⁺.
  • The definitions of the normal forms from lesson 05-02, in the formulation we will use here:
Form It holds when...
1NF Every attribute is atomic and there are no repeating groups or lists
2NF It is in 1NF and no non-prime attribute depends partially on a candidate key
3NF It is in 2NF and no non-prime attribute depends transitively on a candidate key
BCNF For every non-trivial dependency X → Y, X is a superkey
4NF It is in BCNF and for every non-trivial multivalued dependency X ↠ Y, X is a superkey

A vocabulary reminder: an attribute is prime if it belongs to some candidate key; non-prime otherwise. A dependency is partial if a non-prime attribute depends on a proper subset of a candidate key; transitive if it goes from the key to a non-prime attribute via another non-prime attribute; and full or complete if it depends on the whole key and not on any part of it.

If you want to run the SQL of block C, any empty PostgreSQL database will do. The INSERT ... SELECT DISTINCT statements work the same in SQLite.

Contents

  1. Block A — Basic: functional dependencies, closures and candidate keys (exercises 1-3)
  2. Block B — Intermediate: diagnosing real tables (exercises 4-6)
  3. Block C — Advanced: complete normalization, BCNF, 4NF and denormalization (exercises 7-10)
  4. Common mistakes and tips
  5. Reinforcement exercises

Block A — Basic: dependencies, closures and keys

Exercise 1: Writing the functional dependencies and computing closures

Difficulty: Basic

Task. BiblioRed offers a photocopying service. Each copy job is recorded in a single table with these attributes:

job_id, member_id, member_email, job_date, pages, paper_type, page_rate, amount

Business rules:

  • Each copy job has an identifier of its own, job_id.
  • Each member has a single email address and no two members share an email.
  • The rate per page depends exclusively on the paper type (plain, recycled, glossy).
  • The amount is the result of multiplying the pages by the rate per page.

You are asked to:

  • (a) Write the set F of functional dependencies.
  • (b) Compute {job_id}⁺ step by step, stating which dependency is used at each step.
  • (c) Compute {member_email}⁺ and {paper_type, pages}⁺.

Hint. "No two members share an email" is a functional dependency in the direction you may not expect.

Solution

(a) The set F:

f1: job_id            → member_id, job_date, pages, paper_type
f2: member_id         → member_email
f3: member_email      → member_id
f4: paper_type        → page_rate
f5: pages, page_rate  → amount

(b) Closure of {job_id} step by step:

Step Dependency applied X⁺ after the step
0 — (start) {job_id}
1 f1 (job_id ⊆ X⁺) {job_id, member_id, job_date, pages, paper_type}
2 f2 (member_id ⊆ X⁺) + member_email
3 f4 (paper_type ⊆ X⁺) + page_rate
4 f5 (pages, page_rate ⊆ X⁺) + amount
5 None adds anything new → end All 8 attributes

(c)

{member_email}⁺       = {member_email, member_id}
{paper_type, pages}⁺  = {paper_type, pages, page_rate, amount}

Expected result

Since {job_id}⁺ contains all the attributes of the relation, job_id is a superkey. And since it is a single attribute, it is also minimal: it is a candidate key.

{member_email}⁺ stops at two attributes: member_email is not a superkey, even though it does determine member_id. {paper_type, pages}⁺ stops at four: it is not one either.

Explanation. Three points worth fixing in your mind:

  • f3 is the dependency most people forget. "No two members share an email" means that the email determines the member: member_email → member_id. It is a UNIQUE constraint translated into the language of functional dependencies, and it is what makes the email an alternate key of the member entity. Ignoring it leads to miscounting the candidate keys in later exercises.
  • f5 is a real dependency, not a formula. The temptation is to say "the amount is computed, it does not depend". It does depend: given specific values of pages and page_rate, the amount is determined. Being computable does not exclude it from the analysis; what happens is that in the final design we will probably solve it with a generated column rather than with a new table.
  • The closure is computed until it stops growing, not until you have gone through the list once. At step 3 page_rate appears, which is what allows f5 to fire at step 4. Had you stopped after one pass, you would have concluded that amount is not in the closure and job_id would not be a superkey.

Exercise 2: Determining all the candidate keys and classifying the attributes

Difficulty: Basic

Task. BiblioRed's room occupancy schedule has been exported to a single table:

OCCUPANCY(room, day, hour, event, capacity, branch)

Business rules:

  • Each room is in a single branch and has a single capacity.
  • In a given room, on a given day and at a given hour, only one event can be held.
  • Each event is held in a single place, on a single day and at a single hour.

You are asked to:

  • (a) Write F.
  • (b) Find all the candidate keys, justifying that there are no more.
  • (c) Classify the attributes as prime and non-prime.

Hint. Start by looking for the attributes that do not appear on the right-hand side of any dependency: they have to be in every key.

Solution

(a)

g1: room            → capacity, branch
g2: room, day, hour → event
g3: event           → room, day, hour

(b) Systematic search for candidate keys.

First, the classification of attributes according to where they appear:

Attribute Does it appear on some left-hand side? Does it appear on some right-hand side? Conclusion
room Yes (g1, g2) Yes (g3) It may or may not be in a key
day Yes (g2) Yes (g3) It may or may not
hour Yes (g2) Yes (g3) It may or may not
event Yes (g3) Yes (g2) It may or may not
capacity No Yes (g1) Never in a key
branch No Yes (g1) Never in a key

Since no attribute stays out of every right-hand side, there is no mandatory core and combinations have to be tried. We compute closures:

{event}⁺           : event →(g3) room, day, hour →(g1) capacity, branch  = EVERYTHING → superkey
{room, day, hour}⁺ : →(g2) event →(g1) capacity, branch                  = EVERYTHING → superkey
{room}⁺            = {room, capacity, branch}                            → no
{room, day}⁺       = {room, day, capacity, branch}                       → no
{day, hour}⁺       = {day, hour}                                         → no
{event, room}⁺     = EVERYTHING, but it contains {event} → not minimal

Candidate keys: {event} and {room, day, hour}.

{event} is minimal because it is a single attribute. {room, day, hour} is minimal because none of its three proper two-attribute subsets is a superkey ({room,day}, {room,hour} and {day,hour} all fall short, as the closures show). Any other set that is a superkey contains one of the two, so it is not minimal.

(c)

  • Prime attributes (they belong to some candidate key): event, room, day, hour.
  • Non-prime attributes: capacity, branch.

Expected result

Concept Value
Candidate keys {event} and {room, day, hour}
Primary key (design choice) {event}, being shorter and more stable
Prime event, room, day, hour
Non-prime capacity, branch

Explanation. The method worth automating is this one:

  1. Attributes that never appear on the right → they are in every candidate key (here there are none).
  2. Attributes that never appear on the left → they are in no candidate key (here, capacity and branch; that is why they are non-prime with no further checking needed).
  3. The rest have to be tried, starting with the small sets.

The frequent mistake is stopping at the first candidate key that shows up. It is very easy to see {room, day, hour} —the one suggested by reading the task— and not notice that {event} is one too, because g3 is a dependency that reads "backwards" from the way you think about a schedule. And that second key changes the diagnosis: capacity and branch depend partially on {room, day, hour} (because room is a proper subset) and transitively on {event}. The table is not even in 2NF.

Another mistake: counting {event, room} as a candidate key because its closure is everything. It is a superkey, not a candidate key: room is superfluous. Minimality is part of the definition.


Exercise 3: Classifying dependencies as partial, transitive and full

Difficulty: Basic

Task. Using the two relations from exercises 1 and 2, classify each of these dependencies with respect to the candidate key indicated. The categories are: full (or complete), partial and transitive. State also which normal form each one violates.

# Relation Reference key Dependency
1 REPROGRAPHICS {job_id} job_id → job_date
2 REPROGRAPHICS {job_id} paper_type → page_rate
3 REPROGRAPHICS {job_id} member_id → member_email
4 OCCUPANCY {room, day, hour} room → capacity
5 OCCUPANCY {room, day, hour} room, day, hour → event
6 OCCUPANCY {event} room → branch

Solution

# Classification Normal form violated Reasoning
1 Full None job_date depends on the whole key, which is a single attribute. There is no non-empty proper subset it could depend on
2 Transitive 3NF job_id → paper_type → page_rate, and paper_type is non-prime
3 Transitive 3NF job_id → member_id → member_email, with member_id non-prime
4 Partial 2NF room is a proper subset of the key {room, day, hour} and capacity is non-prime
5 Full None It is the key itself determining a prime attribute; no proper subset of the key determines event
6 Transitive 3NF With respect to {event}: event → room → branch. The same dependency is partial with respect to the other key

Expected result

REPROGRAPHICS is in 2NF but not in 3NF (two transitive dependencies: paper_type → page_rate and member_id → member_email).

OCCUPANCY is only in 1NF: room → capacity and room → branch are partial with respect to {room, day, hour}.

Explanation. The point to internalize is in rows 4 and 6: the same dependency is classified differently depending on which key you take as the reference. room → branch is partial with respect to {room, day, hour} and transitive with respect to {event}. It is not a contradiction: they are two descriptions of the same problem.

From that comes the operational rule that saves time: it is enough for a dependency to violate a normal form with respect to one candidate key for the relation not to be in that normal form. It does not have to violate it with respect to all of them. That is why the correct working order is: find all the candidate keys first, and only then evaluate the normal forms.

Second point: REPROGRAPHICS is in 2NF automatically, without checking anything, because its only candidate key has a single attribute. A one-attribute key has no non-empty proper subsets, so there can be no partial dependencies. Every relation with a single, simple candidate key is in 2NF by construction. Checking it by hand is wasted time; recognizing it is a legitimate shortcut.

Third point: row 5 is a reminder that a dependency whose right-hand side is a prime attribute never violates 2NF or 3NF, because both forms speak explicitly of non-prime attributes. It could violate BCNF, which does not make that distinction.


Block B — Intermediate: diagnosing real tables

Exercise 4: Flat listing of registrations for BiblioRed events

Difficulty: Intermediate

Task. The cultural activities department works with this table, exported from a spreadsheet. Each row is a member's registration for an event.

event_id event_title event_date room room_capacity branch speaker speaker_email member_id member_name member_email seats
101 Book club: The Pillars of the Earth 2026-03-12 Blue Room 30 Central Rosa Calduch [email protected] 14 Marta Alsina [email protected] 2
101 Book club: The Pillars of the Earth 2026-03-12 Blue Room 30 Central Rosa Calduch [email protected] 11 Clara Ferrán [email protected] 1
101 Book club: The Pillars of the Earth 2026-03-12 Blue Room 30 Central Rosa Calduch [email protected] 13 Sonia Mestre [email protected] 3
103 Creative writing workshop 2026-05-09 South Classroom 25 South Aitor Lemus [email protected] 14 Marta Alsina [email protected] 1
103 Creative writing workshop 2026-05-09 South Classroom 25 South Aitor Lemus [email protected] 13 Sonia Mestre [email protected] 1
105 Book club: Norwegian Wood 2026-07-16 East Room 50 East Rosa Calduch [email protected] 15 Iván Pereda [email protected] 1

Business rules: each event has a single main speaker, is held in a single room and on a single date; each room belongs to a branch and has a capacity; each member has a name and a unique email; each speaker has a unique email.

You are asked for:

  • (a) The candidate key and the functional dependencies.
  • (b) Which normal form it is in and why, pointing at the guilty dependency.
  • (c) To predict a specific modification anomaly and a deletion one, naming the exact row and datum of the table above.

Solution

(a) Candidate key: {event_id, member_id}. A row is "this member, at this event", and nothing shorter identifies a row.

h1: event_id, member_id → seats
h2: event_id            → event_title, event_date, room, speaker
h3: room                → room_capacity, branch
h4: speaker             → speaker_email
h5: member_id           → member_name, member_email
h6: member_email        → member_id
h7: speaker_email       → speaker

Prime attributes: event_id, member_id, member_email (because {event_id, member_email} is also a candidate key, by h6). All the rest are non-prime.

(b) The table is in 1NF and not in 2NF.

The guilty dependencies are h2 and h5:

  • event_id → event_title is partial: event_id is a proper subset of the key {event_id, member_id} and event_title is non-prime. The same goes for event_date, room and speaker.
  • member_id → member_name is partial for the same reason.

Since it does not reach 2NF, there is no point asking about 3NF yet. But it is worth noting that, once 2NF is resolved, two transitive dependencies would still be pending: h3 (event_id → room → room_capacity, branch) and h4 (event_id → speaker → speaker_email).

(c) Specific anomalies.

Modification anomaly. Rosa Calduch changes her email to [email protected]. Her email appears in three rows: the three of event 101 (with members 14, 11 and 13) and the one of event 105 (member 15) — four rows in total. All four have to be updated. If the operator filters by event_id = 101 and updates only those three, the result is a table where the same speaker has two different emails: [email protected] at event 101 and [email protected] at event 105. The table becomes internally contradictory and there is no database constraint that prevents it, because speaker_email is not the key of anything.

Deletion anomaly. Iván Pereda (member 15) cancels his registration for event 105 and the last row is deleted. With it disappears all the information about event 105: that it was called "Book club: Norwegian Wood", that it took place on July 16, 2026, that it was held in the East Room of the East branch, that the room has a capacity of 50 and that Rosa Calduch moderated it. The event existed, but the database no longer knows so. And as a bonus, if that was the only row where the East Room appeared, we also lose that its capacity is 50 and that it belongs to the East branch.

Expected result

Question Answer
Normal form 1NF (it does not reach 2NF)
Guilty dependency event_id → event_title, event_date, room, speaker (partial); also member_id → member_name, member_email
Modification anomaly Changing Rosa Calduch's email requires touching 4 rows; if 3 are touched, there are two emails for the same person
Insertion anomaly Event 107 ("Autumn storytelling") cannot be registered until somebody signs up
Deletion anomaly Deleting the row of member 15 at event 105 makes the whole event disappear

Explanation. The value of the exercise is in part (c), and that is why the task demands naming the exact row and datum. Saying "there is redundancy" or "there may be inconsistencies" is not a diagnosis: it is a description of the smell. A diagnosis is "changing Rosa Calduch's email forces you to update four rows, and one of them being left unupdated is enough for the table to assert two incompatible things".

Notice too that the insertion anomaly shows up all by itself when you try to register something that does not have children yet. Event 107 is scheduled for October and has no registrations yet: in this table it is inexpressible, except by putting in a row with a null member_id, which would break the primary key. That impossibility is the clearest sign that two different entities have been put into the same table.

A modeling nuance worth seeing: we have assumed "a single main speaker per event". If BiblioRed allowed several speakers per event —and in fact it does: event 104 has two—, h2 would stop being true and the table would not even be cleanly in 1NF, because we would have a disguised repeating group. That case is exactly the one in exercise 9.


Exercise 5: Fine history with the member's data

Difficulty: Intermediate

Task. The collections department maintains this table for its monthly report:

fine_id member_id member_name branch_id branch_name branch_phone reason amount status
1 14 Marta Alsina 1 Central 935550001 late_return 2.20 paid
3 15 Iván Pereda 2 North 935550002 late_return 1.60 pending
4 14 Marta Alsina 1 Central 935550001 loss 24.00 pending
5 16 Nuria Bastos 3 South 935550003 damage 6.50 paid
7 13 Sonia Mestre 1 Central 935550001 late_return 5.00 pending

branch_id is the branch the member is registered at.

You are asked for: the candidate key, the dependencies, the normal form and the guilty dependency, and one specific anomaly of each kind with the exact row and datum.

Hint. The key is a single attribute. That automatically rules out one kind of violation and forces you to look for the other.

Solution

Candidate key: {fine_id}.

k1: fine_id   → member_id, reason, amount, status
k2: member_id → member_name, branch_id
k3: branch_id → branch_name, branch_phone

Normal form: 2NF, not 3NF.

It is in 2NF automatically: the candidate key is a single attribute, so there can be no partial dependencies.

It is not in 3NF. There are two transitive chains, and both are guilty:

  • fine_id → member_id → member_name (and → branch_id), with member_id non-prime.
  • fine_id → member_id → branch_id → branch_name, branch_phone, with branch_id non-prime. This one is a two-hop transitivity, and it is still transitivity.

Specific anomalies.

Modification. The Central branch changes its phone number to 935550099. The value 935550001 appears in three rows: fines 1, 4 and 7. All three have to be updated. If an UPDATE with WHERE member_id = 14 updates only fines 1 and 4, fine 7 (Sonia Mestre's, also from Central) still says 935550001 and the table asserts that the Central branch has two phone numbers. With 12,000 members and four branches, the number of rows to touch for a single phone change runs into the thousands.

Insertion. BiblioRed opens a fifth branch, "West", phone 935550005. There is no way whatsoever of recording it in this table until a member registered at West receives a fine. The branch's data exists only as an accompaniment to a fine.

Deletion. Fine 5 (Nuria Bastos, damage, €6.50) is voided and the row is deleted. With it disappears the fact that a South branch exists, that its phone is 935550003, that member 16 is called Nuria Bastos and that she is registered at South. It is the only row in the table where that branch appears.

Expected result

Question Answer
Candidate key {fine_id}
Normal form 2NF (not 3NF)
Guilty dependencies member_id → member_name, branch_id and branch_id → branch_name, branch_phone
Row and datum of the modification anomaly The phone 935550001 in fines 1, 4 and 7
Row and datum of the deletion anomaly Deleting fine 5 removes the existence of the South branch and of member 16

Decomposition to 3NF (the result only; the complete procedure is in exercise 7):

BRANCHES(branch_id, branch_name, branch_phone) MEMBERS(member_id, member_name, branch_id) FINES(fine_id, member_id, reason, amount, status)

Explanation. This is the textbook case of a 3NF violation, and it is worth seeing why the correct diagnosis is 2NF and not "1NF" or "3NF":

  • 1NF is not the diagnosis because all the attributes are atomic: there are no lists, no repeating groups, no cells with several values.
  • It does not reach 3NF because there are non-prime attributes that depend on other non-prime attributes.
  • The shortcut from the hint: with a simple candidate key, 2NF is guaranteed and all the attention must go to looking for key → X → Y chains.

A detail that gets overlooked: chained transitivity. branch_phone is two hops from the key (fine_id → member_id → branch_id → branch_phone). It is still a 3NF violation, and the correct decomposition is not to put the branch inside the fines table: it is to recognize that there are three hidden entities —fine, member and branch— and to pull them out in cascade. A frequent mistake is to decompose only one level and end up with MEMBERS(member_id, member_name, branch_id, branch_name, branch_phone), which is still not in 3NF.


Exercise 6: The case that is in 3NF but not in BCNF

Difficulty: Intermediate

Task. A consultancy firm in Vallmar assigns advisors to clients by specialty:

client specialty advisor
Solé Bakery tax Rita Bonet
Solé Bakery labor Marc Vidal
Ferrer Garage tax Rita Bonet
Ferrer Garage accounting Nuria Gasch
Vilamar Optics labor Marc Vidal
Vilamar Optics tax Sergi Prat

Business rules:

  • Each advisor works in a single specialty.
  • For each client and each specialty there is exactly one advisor assigned.
  • A client can have several advisors (one per specialty) and an advisor can handle several clients.

You are asked for: the dependencies, all the candidate keys, the prime attributes, the exact normal form and the guilty dependency, and the anomalies.

Hint. Check first whether any attribute is non-prime. The answer changes the whole analysis.

Solution

m1: client, specialty → advisor    (second rule)
m2: advisor           → specialty  (first rule)

Candidate keys. We compute closures:

{client, specialty}⁺ = {client, specialty, advisor}  = EVERYTHING → superkey
{client, advisor}⁺   : advisor →(m2) specialty       = EVERYTHING → superkey
{client}⁺            = {client}                      → no
{specialty}⁺         = {specialty}                   → no
{advisor}⁺           = {advisor, specialty}          → no

The two two-attribute superkeys are minimal (none of their proper subsets is one). Candidate keys: {client, specialty} and {client, advisor}.

Prime attributes: client, specialty, advisor. All three. There is no non-prime attribute.

Normal form: 3NF, but not BCNF.

  • 2NF: it holds vacuously. The definitions of 2NF and 3NF speak of non-prime attributes, and here there are none. With no non-prime attributes there can be no partial or transitive dependencies of non-prime attributes.
  • 3NF: it holds for the same reason.
  • BCNF: it does not hold. The definition of BCNF does not distinguish prime from non-prime: it demands that the determinant of every non-trivial dependency be a superkey. The guilty dependency is m2: advisor → specialty, because {advisor}⁺ = {advisor, specialty} does not contain client: advisor is not a superkey.

Anomalies.

Insertion. The firm hires Lidia Serna, a specialist in corporate law. There is no way of recording it: the primary key requires a client, and Lidia does not have one yet. The fact "Lidia Serna is a corporate specialist" is inexpressible.

Deletion. Vilamar Optics terminates the contract and its two rows are deleted. With the row (Vilamar Optics, tax, Sergi Prat) disappears the only record that Sergi Prat is a tax advisor: it is his only client in the table.

Modification. Rita Bonet retrains and moves from tax to accounting. Her specialty appears in two rows (Solé Bakery and Ferrer Garage). If only one is updated, the table asserts that Rita Bonet is tax for one client and accounting for another, violating the first business rule.

Expected result

Concept Value
Candidate keys {client, specialty}, {client, advisor}
Prime attributes All three
Normal form 3NF — not BCNF
Guilty dependency advisor → specialty (determinant that is not a superkey)

Explanation. This exercise exists because it is the counterexample proving that 3NF is not enough. Everybody reaches 3NF and considers the job done; this table is in textbook 3NF and still has the three classic anomalies.

The alarm signal to learn to recognize: a dependency whose left-hand side is a single attribute that is also part of a key, but which on its own is not a key. Here, advisor is prime (it is in {client, advisor}) but it is not a superkey. That configuration —a prime determinant that is not a superkey— is exactly the gap through which 3NF lets redundancy pass.

The other lesson: overlapping candidate keys. The two keys share the attribute client. Whenever two candidate keys overlap, it is worth checking BCNF explicitly, because that is the situation in which 3NF and BCNF diverge. If the candidate keys are disjoint or there is only one, 3NF and BCNF coincide in practice almost always.

The decomposition of this table —and the serious problem it brings— is exercise 8.


Block C — Advanced: complete normalization

Exercise 7: From 1NF to 3NF, step by step and with the migration SQL

Difficulty: Advanced

Task. BiblioRed manages its supplier orders with this table, exactly as the person who retired left it:

CREATE TABLE orders_flat (
    order_id        INTEGER,
    order_date      DATE,
    supplier_id     VARCHAR(4),
    supplier_name   VARCHAR(80),
    supplier_tax_id VARCHAR(12),
    supplier_city   VARCHAR(40),
    supplier_phones VARCHAR(60),   -- several phone numbers separated by commas!
    lines           TEXT           -- several lines in a single cell!
);

INSERT INTO orders_flat VALUES
 (5001,'2026-02-10','P1','Ponent Distribution','B12345678','Lleida','973551111, 610222333',
  '9788401337208 x3 @21.90; 9788401339097 x2 @19.50'),
 (5002,'2026-03-04','P2','Marina Books','B87654321','Vallmar','935559999',
  '9788483835609 x4 @12.95; 9788401337208 x1 @22.50'),
 (5003,'2026-05-20','P1','Ponent Distribution','B12345678','Lleida','973551111, 610222333',
  '9788483835609 x2 @13.20');

Catalog data: 9788401337208 is "The Pillars of the Earth" by Ken Follett; 9788401339097, "The Map of Time" by Félix J. Palma; 9788483835609, "Norwegian Wood" by Haruki Murakami.

Take this table from 1NF to 3NF, showing the state of the data after each normal form and writing the SQL of the decomposition.

Solution

Step 0 → 1NF: atomize.

The table is not even in 1NF: supplier_phones is a comma-separated list and lines is a complete repeating group inside a cell. 1NF demands atomic values and no repeating groups.

Result after 1NF, with the phone numbers in their own table and one row per order line:

ORDERS_1NF(order_id, order_date, supplier_id, supplier_name, supplier_tax_id, supplier_city, isbn, title, author, units, unit_price)

order_id order_date supplier_id supplier_name supplier_tax_id supplier_city isbn title author units unit_price
5001 2026-02-10 P1 Ponent Distribution B12345678 Lleida 9788401337208 The Pillars of the Earth Ken Follett 3 21.90
5001 2026-02-10 P1 Ponent Distribution B12345678 Lleida 9788401339097 The Map of Time Félix J. Palma 2 19.50
5002 2026-03-04 P2 Marina Books B87654321 Vallmar 9788483835609 Norwegian Wood Haruki Murakami 4 12.95
5002 2026-03-04 P2 Marina Books B87654321 Vallmar 9788401337208 The Pillars of the Earth Ken Follett 1 22.50
5003 2026-05-20 P1 Ponent Distribution B12345678 Lleida 9788483835609 Norwegian Wood Haruki Murakami 2 13.20

And SUPPLIER_PHONES(supplier_id, phone) with four rows: (P1, 973551111), (P1, 610222333), (P2, 935559999).

Analysis. Candidate key of ORDERS_1NF: {order_id, isbn}. Dependencies:

n1: order_id, isbn  → units, unit_price
n2: order_id        → order_date, supplier_id
n3: isbn            → title, author
n4: supplier_id     → supplier_name, supplier_tax_id, supplier_city
n5: supplier_tax_id → supplier_id

Step 1NF → 2NF: remove the partial dependencies.

Guilty: n2 and n3. Both have as determinant a proper subset of the key and as dependents non-prime attributes.

-- Order headers (pulls out n2 and, for now, drags the supplier data along)
CREATE TABLE orders_2nf AS
SELECT DISTINCT order_id, order_date, supplier_id,
       supplier_name, supplier_tax_id, supplier_city
FROM orders_1nf;

-- Book catalog (pulls out n3)
CREATE TABLE books_2nf AS
SELECT DISTINCT isbn, title, author
FROM orders_1nf;

-- Order lines: only what depends on the whole key
CREATE TABLE lines_2nf AS
SELECT order_id, isbn, units, unit_price
FROM orders_1nf;

State of the data after 2NF:

orders_2nf (3 rows)

order_id order_date supplier_id supplier_name supplier_tax_id supplier_city
5001 2026-02-10 P1 Ponent Distribution B12345678 Lleida
5002 2026-03-04 P2 Marina Books B87654321 Vallmar
5003 2026-05-20 P1 Ponent Distribution B12345678 Lleida

books_2nf (3 rows)

isbn title author
9788401337208 The Pillars of the Earth Ken Follett
9788401339097 The Map of Time Félix J. Palma
9788483835609 Norwegian Wood Haruki Murakami

lines_2nf (5 rows)

order_id isbn units unit_price
5001 9788401337208 3 21.90
5001 9788401339097 2 19.50
5002 9788483835609 4 12.95
5002 9788401337208 1 22.50
5003 9788483835609 2 13.20

Step 2NF → 3NF: remove the transitive dependencies.

orders_2nf still has order_id → supplier_id → supplier_name, supplier_tax_id, supplier_city. It is transitive: supplier_id is non-prime in that relation.

CREATE TABLE suppliers AS
SELECT DISTINCT supplier_id, supplier_name, supplier_tax_id, supplier_city
FROM orders_2nf;

CREATE TABLE orders AS
SELECT order_id, order_date, supplier_id
FROM orders_2nf;

Final 3NF schema, with keys and constraints:

CREATE TABLE suppliers (
    supplier_id     VARCHAR(4) PRIMARY KEY,
    supplier_name   VARCHAR(80) NOT NULL,
    supplier_tax_id VARCHAR(12) NOT NULL UNIQUE,   -- n5: alternate key
    supplier_city   VARCHAR(40) NOT NULL
);

CREATE TABLE supplier_phones (
    supplier_id VARCHAR(4)  NOT NULL REFERENCES suppliers(supplier_id) ON DELETE CASCADE,
    phone       VARCHAR(15) NOT NULL,
    PRIMARY KEY (supplier_id, phone)
);

CREATE TABLE books (
    isbn   CHAR(13) PRIMARY KEY,
    title  VARCHAR(200) NOT NULL,
    author VARCHAR(120) NOT NULL
);

CREATE TABLE orders (
    order_id    INTEGER PRIMARY KEY,
    order_date  DATE NOT NULL,
    supplier_id VARCHAR(4) NOT NULL REFERENCES suppliers(supplier_id) ON DELETE RESTRICT
);

CREATE TABLE order_lines (
    order_id   INTEGER  NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
    isbn       CHAR(13) NOT NULL REFERENCES books(isbn)      ON DELETE RESTRICT,
    units      SMALLINT NOT NULL CHECK (units > 0),
    unit_price NUMERIC(8,2) NOT NULL CHECK (unit_price >= 0),
    PRIMARY KEY (order_id, isbn)
);

Expected result

Table Rows Content
suppliers 2 P1 Ponent Distribution (Lleida), P2 Marina Books (Vallmar)
supplier_phones 3 P1 has two, P2 has one
books 3 The three ISBNs of the catalog
orders 3 5001, 5002, 5003
order_lines 5 The five lines

Check that the decomposition is lossless: rebuilding the original must give exactly the five rows of ORDERS_1NF.

SELECT o.order_id, o.order_date, s.supplier_id, s.supplier_name,
       ol.isbn, b.title, ol.units, ol.unit_price
FROM order_lines ol
JOIN orders    o ON o.order_id    = ol.order_id
JOIN suppliers s ON s.supplier_id = o.supplier_id
JOIN books     b ON b.isbn        = ol.isbn
ORDER BY o.order_id, ol.isbn;
-- 5 rows, identical to the originals

Explanation. Four things this exercise teaches that you do not see in toy examples:

1. unit_price stays in order_lines, and that is correct. The temptation is to move it out to the book catalog, "because the price belongs to the book". It does not: look at the data. "The Pillars of the Earth" was bought at €21.90 in order 5001 and at €22.50 in 5002, from different suppliers. And "Norwegian Wood" at €12.95 and at €13.20. The price depends on the whole line —{order_id, isbn}—, not on the ISBN. It is a full dependency, and therefore it stays where it is. Moving it out would have been a loss of information, not a normalization.

2. SELECT DISTINCT is the heart of the migration. Each CREATE TABLE ... AS SELECT DISTINCT projects the columns of the new relation and deduplicates. It is exactly the projection of relational algebra. If after the DISTINCT the new table has more rows than expected, that is the sign that the data contradicts the functional dependency: for instance, if suppliers came out with three rows, it would mean that in some order "P1" appears with a different city, and the data would have to be cleaned before putting on the primary key.

3. The phone numbers come out at 1NF, not at 3NF. A multivalued attribute is not a functional dependency problem: it is an atomicity problem. It is solved in the first step, by creating a table whose primary key is (supplier_id, phone).

4. supplier_tax_id → supplier_id (n5) does not generate a new table. It is a dependency between two attributes that already live together in suppliers, and it means that the tax ID is an alternate key. It is materialized with UNIQUE, not with a decomposition. Confusing "there is a dependency" with "we must decompose" is a frequent mistake: you only decompose when the dependency violates the normal form you are aiming for.

About SQLite: CREATE TABLE ... AS SELECT works the same, but it does not allow declaring keys or constraints in the same statement, and ALTER TABLE ADD CONSTRAINT does not exist. There the pattern is to create the final tables with their complete DDL and then INSERT INTO ... SELECT DISTINCT ....


Exercise 8: BCNF without dependency preservation

Difficulty: Advanced

Task. Go back to the table of exercise 6, ADVISORY(client, specialty, advisor) with m1: {client, specialty} → advisor and m2: advisor → specialty.

  • (a) Decompose to BCNF, removing the guilty dependency.
  • (b) Prove that the decomposition is lossless.
  • (c) Prove that it does not preserve the dependencies and identify which one is lost.
  • (d) Show with specific data what can slip into the decomposed schema that the original prevented.
  • (e) Decide what to do and justify the decision.

Solution

(a) Decomposition. The standard procedure for reaching BCNF: given the guilty dependency X → Y (here advisor → specialty), you decompose into X ∪ Y and into R − Y.

R1(advisor, specialty)   ← the guilty dependency, now with advisor as the key
R2(client, advisor)      ← the rest

Both are in BCNF: in R1 the only dependency is advisor → specialty and advisor is the key; in R2 the only dependency is the trivial one and the key is {client, advisor}, the whole relation.

With the data of exercise 6:

R1 (4 rows)

advisor specialty
Rita Bonet tax
Marc Vidal labor
Nuria Gasch accounting
Sergi Prat tax

R2 (6 rows)

client advisor
Solé Bakery Rita Bonet
Solé Bakery Marc Vidal
Ferrer Garage Rita Bonet
Ferrer Garage Nuria Gasch
Vilamar Optics Marc Vidal
Vilamar Optics Sergi Prat

(b) Lossless. The criterion: a binary decomposition of R into R1 and R2 is lossless if the common attributes functionally determine all the attributes of at least one of the two.

R1 ∩ R2 = {advisor}
{advisor} → specialty            (by m2)
{advisor} → {advisor, specialty} = R1     ✓

{advisor} is a key of R1, so the decomposition is lossless. Check with data: the JOIN of R1 and R2 on advisor returns exactly the 6 original rows, not one more.

SELECT r2.client, r1.specialty, r2.advisor
FROM r2 JOIN r1 ON r1.advisor = r2.advisor;
-- 6 rows, identical to the original

(c) It does not preserve the dependencies. The projection of F onto the two relations gives:

F₁ (over R1) = { advisor → specialty }
F₂ (over R2) = { }   (no non-trivial dependency)
F₁ ∪ F₂ = { advisor → specialty }

The dependency m1: {client, specialty} → advisor has been lost. It does not follow from F₁ ∪ F₂: the closure of {client, specialty} under F₁ ∪ F₂ is {client, specialty} and does not contain advisor.

And what is worse in practice: it cannot be checked by looking at a single table. R1 knows nothing about clients; R2 knows nothing about specialties. Verifying it requires joining the two.

(d) What slips in now. We insert into R2 a row that is perfectly legal for the decomposed schema:

INSERT INTO r2 (client, advisor) VALUES ('Solé Bakery', 'Sergi Prat');

No constraint complains: R2 only requires that the pair not be repeated, and it is not repeated. But on rebuilding:

client specialty advisor
Solé Bakery tax Rita Bonet
Solé Bakery tax Sergi Prat
...

Solé Bakery has two tax advisors. The business rule "for each client and specialty there is exactly one advisor" is broken, and the original 3NF schema prevented it with its primary key. BCNF has removed the redundancy at the cost of losing a guarantee.

(e) The decision. There are three defensible ways out and one that is not:

Option What is done What is gained What is lost
1. Stay in 3NF A single table ADVISORY(client, specialty, advisor) with PK (client, specialty) Rule m1 is guaranteed by the primary key The redundancy and the three anomalies of exercise 6
2. BCNF + programmed check R1 and R2, plus a trigger on R2 that queries R1 Zero redundancy Complexity; the rule depends on code, not on the schema
3. "Rebuilt" BCNF (recommended) R1(advisor, specialty) with PK advisor; R2(client, advisor, specialty) with a composite FK to R1 and UNIQUE(client, specialty) Both rules guaranteed by the engine One redundant column (specialty in R2), but shielded by the composite FK
4. "Bare" BCNF R1 and R2 and nothing else Nothing Rule m1, with no substitute. Do not do this

Option 3 deserves to be seen written out, because it is the pattern that solves most of these cases in practice:

CREATE TABLE advisors (
    advisor   VARCHAR(80) PRIMARY KEY,
    specialty VARCHAR(20) NOT NULL,
    CONSTRAINT uq_advisor_spec UNIQUE (advisor, specialty)  -- for the composite FK
);

CREATE TABLE assignments (
    client    VARCHAR(80) NOT NULL,
    advisor   VARCHAR(80) NOT NULL,
    specialty VARCHAR(20) NOT NULL,
    PRIMARY KEY (client, advisor),
    -- The specialty of the assignment MUST be the advisor's
    CONSTRAINT fk_assign_advisor FOREIGN KEY (advisor, specialty)
        REFERENCES advisors (advisor, specialty) ON UPDATE CASCADE,
    -- One single advisor per client and specialty
    CONSTRAINT uq_assign_client_spec UNIQUE (client, specialty)
);

-- Now the attempt from part (d) fails:
INSERT INTO assignments VALUES ('Solé Bakery','Sergi Prat','tax');
-- ERROR: duplicate key value violates unique constraint "uq_assign_client_spec"

Expected result

Question Answer
BCNF decomposition R1(advisor, specialty) + R2(client, advisor)
Lossless? Yes: R1 ∩ R2 = {advisor} and advisor is a key of R1
Dependency preserving? No: {client, specialty} → advisor is lost
Practical consequence A client can end up with two advisors of the same specialty
Recommended decision Option 3: BCNF with specialty replicated and protected by a composite FK + UNIQUE

Explanation. This is the exercise that separates whoever has memorized the normal forms from whoever understands them. The underlying theorem is blunt:

Every relation admits a lossless, dependency-preserving decomposition to 3NF. Not every relation admits a dependency-preserving decomposition to BCNF.

BCNF is stricter and sometimes it is too strict: it removes the redundancy at the price of a business rule no longer being verifiable inside a single table. When that happens, normalization stops being a technical decision and becomes a business decision: which hurts more, the redundancy or the risk of inconsistent data?

In practice, the answer is almost always that the risk of inconsistency hurts more, because redundancy can be watched with an audit query and inconsistency, once it has happened, is very expensive to clean up. That is why option 3 —replicating the attribute and shielding it with a composite foreign key, the same trick that closed the hierarchy of the auto repair shop in 07-02— is the compromise that wins most often: you pay one repeated column, but the engine guarantees that it never gets out of step.

Option 1 (staying in 3NF) is perfectly respectable when the volume is small and the table is rarely queried. What is never respectable is option 4: decomposing, losing the dependency and not telling anybody.


Exercise 9: Fourth normal form and multivalued dependencies

Difficulty: Advanced

Task. To prepare the material for events, BiblioRed used to keep this table:

EVENT_RESOURCES(event_id, speaker, material)

where the speakers taking part in an event and the materials exhibited at it are recorded. The speakers and the materials are independent of each other: which materials are exhibited does not depend on which speaker is talking, and vice versa.

Data for event 104 ("Book launch: The Wishing Box"), with two speakers and two materials:

event_id speaker material
104 Delia Marchetti The Wishing Box
104 Delia Marchetti Vallmar Science 42
104 Rosa Calduch The Wishing Box
104 Rosa Calduch Vallmar Science 42

You are asked to:

  • (a) Determine the candidate key and check whether it is in BCNF.
  • (b) Write the multivalued dependencies.
  • (c) Decompose to 4NF and show the tables with data.
  • (d) Quantify the saving if the event had 3 speakers and 4 materials.

Solution

(a) The candidate key is {event_id, speaker, material}: the whole relation. There is no non-trivial functional dependency —neither event_id → speaker (there are two), nor speaker → material (each speaker appears with both materials)—, so the only way of identifying a row is with all three attributes.

Since every attribute is prime and there are no non-trivial functional dependencies, the table is in BCNF. And yet the redundancy is glaring.

(b) Multivalued dependencies. A multivalued dependency X ↠ Y says that the set of values of Y associated with a value of X is independent of the remaining attributes. Here:

event_id ↠ speaker
event_id ↠ material

Both are non-trivial (speaker does not contain event_id, and event_id ∪ speaker ≠ R) and event_id is not a superkey. Therefore the table is not in 4NF.

The empirical sign of a multivalued dependency: the table contains the Cartesian product of two independent sets. 2 speakers × 2 materials = 4 rows, and all 4 have to be there. If the row (104, Rosa Calduch, Vallmar Science 42) were missing, the table would be asserting something false: that Rosa Calduch has something to do with which material is exhibited.

(c) Decomposition to 4NF.

CREATE TABLE event_speakers AS
SELECT DISTINCT event_id, speaker FROM event_resources;

CREATE TABLE event_materials AS
SELECT DISTINCT event_id, material FROM event_resources;

event_speakers (2 rows)

event_id speaker
104 Delia Marchetti
104 Rosa Calduch

event_materials (2 rows)

event_id material
104 The Wishing Box
104 Vallmar Science 42

The decomposition is lossless by Fagin's theorem: if X ↠ Y holds in R, then R decomposes losslessly into R[X ∪ Y] and R[X ∪ (R − Y)]. The JOIN on event_id rebuilds exactly the 4 original rows.

(d) The saving. With 3 speakers and 4 materials:

Schema Rows
EVENT_RESOURCES (one table) 3 × 4 = 12
event_speakers + event_materials 3 + 4 = 7

And with 5 speakers and 20 materials: 100 rows against 25. The growth is multiplicative in the unnormalized schema and additive in the normalized one.

Expected result

Concept Value
Candidate key {event_id, speaker, material} (the whole relation)
BCNF? Yes
4NF? No: event_id ↠ speaker and event_id ↠ material with event_id not a superkey
Decomposition event_speakers(event_id, speaker) + event_materials(event_id, material)
Saving with 3×4 From 12 rows to 7

Explanation. 4NF is the first normal form that cannot be diagnosed by looking at functional dependencies, and that is why it slips past so often. The table is in BCNF —the functional analysis says it is perfect— and it still has a brutal update anomaly: adding a third speaker to event 104 forces you to insert two rows, one per material, and forgetting one leaves the table in a state asserting a relationship that does not exist.

The practical criterion for detecting a multivalued dependency, more useful than the formal definition:

If, on adding a new value of Y, you have to insert one row for each existing value of Z, and Y and Z have nothing to do with each other, there is a multivalued dependency.

When there is NO multivalued dependency, which is the symmetric mistake. If in BiblioRed each speaker presented their own material —Delia Marchetti her novel and Rosa Calduch the magazine—, then speaker and material would be related, there would be no Cartesian product, the table would be in 4NF and decomposing it would lose information: on rebuilding with a JOIN, false pairs would appear. The independence of the two sets is a business rule, not something deduced from the data, and it is the first thing to confirm before decomposing.

Note: in BiblioRed's real schema this decomposition is already done —participations and events_materials are exactly the two tables of part (c)—. The exercise shows where that design decision comes from.


Exercise 10: Justified denormalization

Difficulty: Advanced

Task. BiblioRed's public catalog shows, for each material, how many available copies there are in each branch. It is the most-executed query in the system: some 400 times a minute at peak hours. With 40,000 copies, the current query

SELECT c.branch_id, count(*) AS available
FROM copies c
WHERE c.material_id = 902 AND c.status = 'available'
GROUP BY c.branch_id;

takes 380 ms even with an index, because the catalog runs it once for each material on the results page (up to 20 materials) and that is almost 8 seconds per page.

The status changes of a copy, on the other hand, are around 3,000 a day: loans, returns, withdrawals.

You are asked to:

  • (a) Propose what to denormalize and why that option and not another.
  • (b) Write the SQL of the denormalized structure.
  • (c) Explain how consistency is maintained.
  • (d) List what is accepted in exchange.

Solution

(a) What to denormalize: a summary table.

Let us compare the three options from the catalog in 05-04:

Option Description Assessment
Computed column in materials materials.n_available Insufficient: the question is per branch, and a scalar column cannot store four values
Materialized view CREATE MATERIALIZED VIEW ... REFRESH Discarded: REFRESH MATERIALIZED VIEW recomputes all 40,000 copies. With 3,000 changes a day it would have to be refreshed every few minutes, and between refreshes the catalog lies about availability
Summary table with triggers availability(material_id, branch_id, n_available) Chosen: it is updated incrementally (one row per change), not by full recomputation, and reading it is a direct primary-key access

(b) The structure.

CREATE TABLE availability (
    material_id INTEGER  NOT NULL REFERENCES materials(material_id) ON DELETE CASCADE,
    branch_id   INTEGER  NOT NULL REFERENCES branches(branch_id)    ON DELETE CASCADE,
    n_available INTEGER  NOT NULL DEFAULT 0 CHECK (n_available >= 0),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (material_id, branch_id)
);

-- Initial load from the source of truth
INSERT INTO availability (material_id, branch_id, n_available)
SELECT material_id, branch_id, count(*)
FROM copies
WHERE status = 'available'
GROUP BY material_id, branch_id;

The catalog query becomes:

SELECT branch_id, n_available
FROM availability
WHERE material_id = 902 AND n_available > 0;

One primary-key access: less than 1 ms, against the 380 ms of the GROUP BY.

(c) How consistency is maintained: a trigger on copies.

CREATE OR REPLACE FUNCTION fn_sync_availability() RETURNS trigger AS $$
BEGIN
    -- Subtract from the previous status, if it was available
    IF (TG_OP = 'UPDATE' OR TG_OP = 'DELETE') AND OLD.status = 'available' THEN
        UPDATE availability
           SET n_available = n_available - 1, updated_at = now()
         WHERE material_id = OLD.material_id AND branch_id = OLD.branch_id;
    END IF;

    -- Add to the new status, if it is available
    IF (TG_OP = 'UPDATE' OR TG_OP = 'INSERT') AND NEW.status = 'available' THEN
        INSERT INTO availability (material_id, branch_id, n_available)
        VALUES (NEW.material_id, NEW.branch_id, 1)
        ON CONFLICT (material_id, branch_id)
        DO UPDATE SET n_available = availability.n_available + 1,
                      updated_at  = now();
    END IF;

    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tg_sync_availability
AFTER INSERT OR UPDATE OF status, branch_id, material_id OR DELETE ON copies
FOR EACH ROW EXECUTE FUNCTION fn_sync_availability();

And a nightly reconciliation query comparing the summary with the source of truth and reporting any mismatch:

WITH real_ AS (
    SELECT material_id, branch_id, count(*) AS n
    FROM copies WHERE status = 'available'
    GROUP BY material_id, branch_id
)
SELECT COALESCE(r.material_id, a.material_id) AS material_id,
       COALESCE(r.branch_id, a.branch_id)     AS branch_id,
       COALESCE(r.n, 0)           AS should_be,
       COALESCE(a.n_available, 0) AS stored
FROM real_ r
FULL OUTER JOIN availability a
  ON a.material_id = r.material_id AND a.branch_id = r.branch_id
WHERE COALESCE(r.n, 0) <> COALESCE(a.n_available, 0);
-- It must return 0 rows. If it returns any, there is a mismatch to investigate.

(d) What is accepted in exchange.

Accepted cost Magnitude
Slower writes Each status change fires 1 or 2 extra UPDATEs. 3,000 a day is negligible against 400 reads/minute
Contention on hot rows All the loans of "The Map of Time" at Central compete for the same row of availability. It is a serialization point; in a system with much more volume the counter would have to be sharded
Risk of mismatch A bulk UPDATE that bypasses the trigger, an error in the function or a partial restore leave the summary out of date. That is why the nightly reconciliation is not optional
Maintenance complexity There is business code in the database. Whoever modifies the schema of copies has to know the trigger exists
One datum with two sources copies is the source of truth; availability is a copy. Any doubt is always resolved in favor of copies

Expected result

Metric Before After
Query for one material 380 ms < 1 ms
Catalog page (20 materials) ~7.6 s ~20 ms
Cost per status change 1 UPDATE 1 UPDATE + 1-2 on availability
Normal forms of the schema 3NF 3NF plus one documented derived table

Explanation. Denormalization is an engineering decision, not a design failure, and that is why it has to be justified with numbers. The three numbers that justify this one are: 400 reads per minute, 3,000 writes a day and 380 ms per query. Without those three, the proposal would be an opinion.

The rule governing the decision: denormalize when the read/write ratio is very high and the cost of the query is structural (a GROUP BY over many rows is not fixed with a better index). Here the ratio is about 200 reads for every write.

Three design details worth not losing:

  • ON CONFLICT ... DO UPDATE (PostgreSQL's "upsert") saves you having to check whether the row exists. It is essential: the first available copy of a material at a branch has no previous row.
  • The trigger is declared AFTER ... OF status, branch_id, material_id. Restricting the columns prevents an UPDATE of cover_url from firing useless work.
  • CHECK (n_available >= 0) is a canary. If the counter tries to go below zero, there is a bug in the logic and it is better for the transaction to fail loudly than to leave the summary lying in silence.

And the final warning: the availability table must never be used to take transactional decisions. To know whether a specific copy can be loaned out you go to copies with a lock, not to the summary. Confusing a derived datum with the source of truth is the most common way for a denormalization to end up lending the same copy twice.


Common Mistakes and Tips

1. Deducing the functional dependencies from the data. Data can only refute a dependency, never prove it. The fact that in six rows each advisor has a single specialty does not prove advisor → specialty; the business rule proves it. Always ask "can this change?" before writing an arrow.

2. Stopping at the first candidate key. It is the mistake of exercise 2. Before evaluating normal forms you have to have all the candidate keys, because a single one of them is enough for the relation to fail a normal form.

3. Confusing superkey with candidate key. A superkey determines every attribute; a candidate key is a minimal superkey. {event, room} is a superkey and is not a candidate key.

4. Computing the closure in a single pass. You have to iterate until the set stops growing. Many dependencies only fire after another one has added its attribute.

5. Skipping 2NF when the key is simple. It is not a mistake, it is a legitimate shortcut: with a single one-attribute candidate key, 2NF always holds. What is a mistake is assuming the same for 3NF.

6. Decomposing every dependency that shows up. supplier_tax_id → supplier_id does not require a new table: it is an alternate key and it is solved with UNIQUE. You only decompose what violates the normal form you are aiming for.

7. Moving out of the detail table an attribute that depends on the whole key. The unit_price of exercise 7 depends on {order_id, isbn}, not on the ISBN. Moving it out to the catalog destroys information. Always check whether the value changes between rows with the same candidate determinant.

8. Decomposing to BCNF without checking dependency preservation. It is exercise 8. Before applying the algorithm, project F onto the resulting relations and check what you have lost.

9. Confusing a functional dependency with a multivalued one. If the two sets are independent (an obligatory Cartesian product), it is multivalued and you have to decompose. If they are related, decomposing invents rows on rebuilding.

10. Denormalizing with no numbers and no reconciliation. A denormalization with no prior measurement is a superstition, and one with no reconciliation process is a time bomb.

A method tip. Always work in this order and do not change it: dependencies → closures → all the candidate keys → prime and non-prime → normal forms in order. Skipping a step makes the diagnosis come out wrong half the time, and the worst of it is that it comes out wrong in a plausible way.

A checking tip. After each decomposition, count the rows of the new tables and rebuild the original with a JOIN. If on rebuilding you get more rows than the originals, the decomposition is lossy (you have generated spurious tuples). If you get fewer, you have lost data. Only if you get exactly the same ones is it right.

Exercises

No hints. Write the complete analysis for each one.

Exercise A: Complete normalization of the room schedule

Go back to OCCUPANCY(room, day, hour, event, capacity, branch) from exercise 2, with:

g1: room            → capacity, branch
g2: room, day, hour → event
g3: event           → room, day, hour

Take it to BCNF, showing the decomposition step by step, checking that it is lossless and saying whether it preserves the dependencies. Write the final schema with its keys and its constraints.

Exercise B: Diagnosis of the delivery route sheet

A delivery company in Vallmar uses this table:

route_id route_date driver_id driver_name vehicle_plate vehicle_load_kg stop_number client address packages
R-100 2026-07-06 C4 Nuria Gasch 4471 KLM 900 1 Solé Bakery Marina St 12 6
R-100 2026-07-06 C4 Nuria Gasch 4471 KLM 900 2 Ferrer Garage Bosque Ave 40 2
R-101 2026-07-06 C7 Aitor Lemus 8820 BNM 1400 1 Vilamar Optics Plaza Mayor 3 1
R-102 2026-07-07 C4 Nuria Gasch 8820 BNM 1400 1 Solé Bakery Marina St 12 4

Rules: each route is done by one driver with one vehicle on one date; within a route the stops are numbered; each client has a single address; each vehicle has a maximum load. Determine the candidate keys, the exact normal form, the guilty dependency and one anomaly of each kind naming the row and the datum.

Exercise C: Denormalization decision for the management dashboard

BiblioRed's management wants a dashboard that is opened 30 times a day and shows, for each of the last 24 months: total loans, distinct members who borrowed something, fines issued and amount collected. The current query scans loans, fines and payments in full —some 900,000 records— and takes 4 seconds. The data of closed months never changes. Propose the denormalization, justify it, say how it is maintained and what is accepted in exchange. Compare explicitly with the solution of exercise 10 and explain why the choice here is different.

Solutions

Solution A

Initial state. Candidate keys {event} and {room, day, hour}; prime: event, room, day, hour; non-prime: capacity, branch. It is only in 1NF, because g1 is partial with respect to {room, day, hour}.

Step to 2NF/3NF. The guilty determinant is room. It is pulled out:

ROOMS(room, capacity, branch)              -- key: {room}
SCHEDULE(room, day, hour, event)           -- keys: {room,day,hour} and {event}

ROOMS is in BCNF: its only dependency is room → capacity, branch and room is the key.

SCHEDULE keeps g2 and g3. Its two candidate keys are still {room, day, hour} and {event}; all its attributes are prime, so it is in 3NF. And in BCNF? The two remaining dependencies are g2 and g3, and in both the determinant is a candidate key, hence a superkey. SCHEDULE is in BCNF.

Lossless. ROOMS ∩ SCHEDULE = {room}, and room → capacity, branch makes {room} the key of ROOMS. It meets the criterion.

Dependency preservation. g1 lives entirely in ROOMS; g2 and g3 live entirely in SCHEDULE. F₁ ∪ F₂ = F: they are all preserved. This case is the usual one and contrasts with exercise 8: here BCNF comes for free.

Final schema:

CREATE TABLE rooms (
    room     VARCHAR(40) PRIMARY KEY,
    capacity SMALLINT NOT NULL CHECK (capacity > 0),
    branch   VARCHAR(40) NOT NULL
);

CREATE TABLE schedule (
    event VARCHAR(120) PRIMARY KEY,            -- candidate key chosen as primary
    room  VARCHAR(40) NOT NULL REFERENCES rooms(room) ON UPDATE CASCADE,
    day   DATE NOT NULL,
    hour  TIME NOT NULL,
    CONSTRAINT uq_schedule_slot UNIQUE (room, day, hour)   -- the other candidate key
);

The important thing about the final schema: both candidate keys end up declared, one as PRIMARY KEY and the other as UNIQUE. Declaring only one of the two would leave g2 without a guarantee and would allow two events in the same room at the same time.

Solution B

Candidate keys. A row is a stop within a route: {route_id, stop_number}. Check: {route_id, stop_number}⁺ reaches client, and from there address; route_id gives route_date, driver_id, vehicle_plate; and from there driver_name and vehicle_load_kg. Complete closure. Nothing shorter achieves it.

Dependencies:

d1: route_id, stop_number → client, packages
d2: route_id              → route_date, driver_id, vehicle_plate
d3: driver_id             → driver_name
d4: vehicle_plate         → vehicle_load_kg
d5: client                → address

Normal form: 1NF, not 2NF. The guilty dependency is d2: route_id is a proper subset of the key and route_date, driver_id and vehicle_plate are non-prime. d3, d4 and d5 are transitive as well and would be resolved in the step to 3NF.

Anomalies with the exact row and datum:

  • Modification. Vehicle 8820 BNM gets an overhaul and its maximum load goes to 1,300 kg. The value 1400 appears in two rows: the one of R-101 and the one of R-102. If only the R-101 one is updated, the table asserts that the same vehicle has two different maximum loads depending on the day.
  • Insertion. A new driver is hired, C9 / Berta Quintana. They cannot be recorded until they are assigned a route with at least one stop. The same goes for a new client: their address does not exist until they have a delivery.
  • Deletion. The stop at Vilamar Optics on route R-101 is cancelled and that row is deleted. Since it is the only row of R-101, the whole route disappears: that it existed on July 6, that driver C7 (Aitor Lemus) did it and that it used vehicle 8820 BNM. And with it we also lose that Vilamar Optics is at Plaza Mayor 3, because it is its only appearance... unless another one remained, which is not the case here.

Decomposition to 3NF: clients(client, address), drivers(driver_id, driver_name), vehicles(vehicle_plate, vehicle_load_kg), routes(route_id, route_date, driver_id, vehicle_plate), stops(route_id, stop_number, client, packages).

Solution C

Proposal: a summary table of closed months, populated by a monthly process.

CREATE TABLE monthly_summary (
    year_            SMALLINT NOT NULL,
    month_           SMALLINT NOT NULL CHECK (month_ BETWEEN 1 AND 12),
    loans            INTEGER  NOT NULL,
    distinct_members INTEGER  NOT NULL,
    fines_issued     INTEGER  NOT NULL,
    fines_amount     NUMERIC(12,2) NOT NULL,
    collected        NUMERIC(12,2) NOT NULL,
    closed           BOOLEAN NOT NULL DEFAULT TRUE,
    computed_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (year_, month_)
);

How it is maintained. A scheduled process on the 1st of each month inserts the row of the month just closed with a single pass over the source tables. The current month is not stored: it is computed on the fly, because it is the only one that changes. The dashboard joins the 23 summary rows with the live computation of the current month by means of a UNION ALL.

Why the choice is different from the one in exercise 10. The decisive difference is the volatility of the datum:

Exercise 10 (availability) Exercise C (monthly summary)
Does the already-computed datum change? Yes, 3,000 times a day No: a closed month is immutable
Read frequency 400/min 30/day
Suitable mechanism Incremental trigger Scheduled process (or materialized view)
Risk of mismatch High: every change can fail Very low: it is computed once, over data that no longer moves
Tolerance to staleness Zero: the catalog would lie Total: nobody expects July's figure on July 1 at 00:00

A trigger on loans to maintain the monthly summary would be disproportionate: it would add work to every loan in order to serve 30 reads a day. And a materialized view, which in exercise 10 was a bad idea because it recomputes everything, is perfectly reasonable here: it is refreshed once a month and the full recomputation bothers nobody.

What is accepted in exchange: the current month is still computed on the fly (some 200 ms, acceptable); you have to watch that no retroactive correction touches an already closed month, and if there is one, recompute that row explicitly; and the dashboard needs the UNION ALL, which is a complication of the query that did not exist before.

Conclusion

You have gone through normalization end to end with ten exercises: you have written sets of functional dependencies from business rules, computed closures step by step, found all the candidate keys of relations with overlapping keys, classified attributes as prime and non-prime, and seen that one and the same dependency is called partial or transitive depending on the key you take as the reference.

In the diagnosis block you have put a first and last name on the anomalies: not "there is redundancy", but "Rosa Calduch's email is in four rows and one of them being left unupdated is enough". That precision is what turns an analysis into an argument you can use to convince somebody that a table has to be redone.

And in the advanced block you have done the complete job: the migration from 1NF to 3NF with its decomposition SQL and its rebuild check; the awkward case in which reaching BCNF costs you a dependency and you have to choose between redundancy and guarantee —with the composite foreign key pattern as the best compromise—; the fourth normal form and multivalued dependencies, which no amount of functional analysis detects; and denormalization reasoned with numbers, with its maintenance mechanism and its mandatory reconciliation.

If you take away one single idea, let it be this: normalizing is not applying rules, it is making each fact live in a single place. The normal forms are the formal way of checking whether you have managed it, and denormalization is the conscious decision to break that rule at a specific point, with a measurement in front of you and a monitoring process behind you.

The module's last lesson, 07-04, Advanced Queries and Transactions Exercises, is the most demanding of the four and the one that most resembles real production work. You will go back to BiblioRed to write window functions —the most-loaned material of each branch, the month-against-month comparison with LAG, the running totals—, a recursive CTE, queries over jsonb in the event survey responses, and a manual pivot. Then come the transactions: the complete loan one with its error handling, SAVEPOINT in a batch process, and five exercises with two psql sessions in parallel where you will reproduce a lost update, see the difference between READ COMMITTED and REPEATABLE READ, cause a deadlock on purpose, implement the optimistic locking of the last seat and consume a queue with SKIP LOCKED. And to close, four exercises on indexes and execution plans. Have both terminals ready.

© Copyright 2026. All rights reserved