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:
- Write the functional dependencies that follow from the business rules and from the data.
- Compute the closures you need and determine all the candidate keys.
- Classify the attributes as prime and non-prime.
- 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.
- 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 fromX, and while there is some dependencyY → ZwithY ⊆ X⁺, addZtoX⁺. - 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
- Block A — Basic: functional dependencies, closures and candidate keys (exercises 1-3)
- Block B — Intermediate: diagnosing real tables (exercises 4-6)
- Block C — Advanced: complete normalization, BCNF, 4NF and denormalization (exercises 7-10)
- Common mistakes and tips
- 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
Fof 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:
f3is the dependency most people forget. "No two members share an email" means that the email determines the member:member_email → member_id. It is aUNIQUEconstraint 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.f5is 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 ofpagesandpage_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_rateappears, which is what allowsf5to fire at step 4. Had you stopped after one pass, you would have concluded thatamountis not in the closure andjob_idwould 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)
(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 minimalCandidate 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:
- Attributes that never appear on the right → they are in every candidate key (here there are none).
- Attributes that never appear on the left → they are in no candidate key (here,
capacityandbranch; that is why they are non-prime with no further checking needed). - 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 → speakerPrime 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_titleis partial:event_idis a proper subset of the key{event_id, member_id}andevent_titleis non-prime. The same goes forevent_date,roomandspeaker.member_id → member_nameis 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_phoneNormal 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), withmember_idnon-prime.fine_id → member_id → branch_id → branch_name, branch_phone, withbranch_idnon-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 → Ychains.
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
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} → noThe 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 containclient:advisoris 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_idStep 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 originalsExplanation. 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 restBoth 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.
{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:
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:
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 ofZ, andYandZhave 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:
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 anUPDATEofcover_urlfrom 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:
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 → addressNormal 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 BNMgets an overhaul and its maximum load goes to 1,300 kg. The value1400appears 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 Opticson 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 driverC7(Aitor Lemus) did it and that it used vehicle8820 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.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
