Course 3 of 6 · chinook & f1db datasets
Schema design from relational foundations through constraints, indexes, and safe schema evolution.
Normal forms eliminate update, insertion, and deletion anomalies by storing each fact in exactly one place. This module connects relational theory to real PostgreSQL schema decisions using the Chinook and F1 datasets.
Book reference: Chapter 29 — Normalization
Free preview: The Three Database Anomalies
begin;
-- f1db has no FK constraints: invalid
-- foreign keys insert silently.
-- This is an insertion anomaly.
insert into f1db.results
(resultid, raceid, driverid,
constructorid, number,
grid, positionorder,
points, laps, statusid)
values
(999999, 99999, 99999,
99999, 0, 0, 0, 0, 0, 1);
select resultid, raceid, driverid
from f1db.results
where resultid = 999999;
rollback;An INSERT with nonexistent foreign key values succeeds silently in f1db, which has no FK constraints.
Natural vs surrogate keys, UUID generation strategies, GENERATED AS IDENTITY, and CHECK constraints. The right primary key choice affects join performance, index size, and schema evolution for decades.
Book reference: Chapter 29 — Normalization
Free preview: Foreign Keys & Planner Trust · Range Types & Temporal Constraints
begin;
-- Confirm existing contracts:
select driver_name, team, valid
from lab.driver_contracts
order by driver_name, lower(valid);
-- Attempt an overlapping contract.
-- The exclusion constraint rejects it.
\set ON_ERROR_STOP off
insert into lab.driver_contracts
(driver_name, team, valid)
values
('Hamilton', 'RedBull',
'[2020-01-01, 2026-01-01)');
rollback;An overlapping driver contract insert is rejected by a tsrange exclusion constraint.
Foreign key ON DELETE and ON UPDATE actions encode referential integrity rules directly in the schema. Junction tables, self-referential FKs, and deferrable constraints handle the edge cases.
Book reference: Chapter 29 — Normalization
Free preview: Foreign Keys & Planner Trust
select case
when length(t.name) < 54
then t.name
else substring(t.name from 1 for 54)
|| '...'
end as track,
ar.name as artist,
count(pt.playlist_id) as playlists
from chinook.playlist_track pt
join chinook.track t using(track_id)
join chinook.album al using(album_id)
join chinook.artist ar using(artist_id)
group by t.track_id, t.name, ar.name
order by playlists desc
limit 5;Top 5 Chinook tracks by playlist count, with artist name.
B-tree, GiST, GIN, BRIN, partial, and covering indexes each suit specific access patterns. Composite index column order and operator class selection determine whether a query hits the index at all.
Book reference: Chapter 29 — Normalization
Free preview: Foreign Keys & Planner Trust
begin;
-- A partial index covers only US invoices —
-- smaller and faster for US-only queries.
create index chinook_invoice_usa_idx
on chinook.invoice (customer_id)
where billing_country = 'USA';
explain (analyze, buffers)
select i.invoice_id, i.total
from chinook.invoice i
where i.billing_country = 'USA'
and i.customer_id = 17;
rollback;A partial index covers only the subset of rows a query targets, making it smaller and faster than a full index.
Star schemas, JSONB for semi-structured data, and range types for temporal coverage. This module shows when to denormalize deliberately and how to document the trade-off in the schema itself.
Book reference: Chapter 32 — Denormalization
Free preview: JSONB, Arrays & Ranges
select g.name as genre,
c.country,
date_trunc(
'year', i.invoice_date
)::date as year,
count(distinct i.invoice_id)
as invoices,
sum(il.unit_price
* il.quantity)
::numeric(10,2) as revenue
from chinook.invoice_line il
join chinook.invoice i using(invoice_id)
join chinook.customer c using(customer_id)
join chinook.track t using(track_id)
join chinook.genre g using(genre_id)
group by g.name, c.country,
date_trunc('year', i.invoice_date)
order by revenue desc
limit 15;A star join across one fact table and four dimension tables produces a multi-dimensional revenue breakdown in one query.
Adding NOT NULL columns to large tables, renaming constraints without downtime, and building indexes CONCURRENTLY. The three-step pattern for zero-downtime constraint changes in production.
Book reference: Chapter 29 — Normalization
begin;
-- NOT VALID: skip scanning existing rows.
-- New inserts/updates are checked immediately.
alter table chinook.track
add constraint track_ms_positive
check (milliseconds > 0)
not valid;
-- Second pass: validate existing rows
-- with a weaker lock (safe on large tables).
alter table chinook.track
validate constraint track_ms_positive;
-- Verify new rows are now rejected:
do $$
begin
insert into chinook.track
(track_id, name, media_type_id,
milliseconds, unit_price)
values (99999, 'Silent', 1, -1, 0.99);
exception
when check_violation then
raise notice 'Caught: %', sqlerrm;
end;
$$;
rollback;NOT VALID + VALIDATE CONSTRAINT adds a check constraint on a live table in two phases, each holding a weaker lock.
EAV vs JSONB vs typed columns — when each wins and what you give up. Exclusion constraints for temporal non-overlap, trigram indexes for fuzzy search, and audit trail patterns.
Book reference: Chapter 32 — Denormalization
Free preview: JSONB, Arrays & Ranges · Range Types & Temporal Constraints
with known(artist) as (
values ('Brian May'),
('Freddie Mercury'),
('Jimi Hendrix'),
('J.C. Fogerty')
)
select k.artist as known_artist,
v.spelling as found_spelling,
round(
similarity(k.artist, v.spelling)
::numeric, 2
) as sim,
v.tracks
from known k
cross join lateral (
select t.composer::text as spelling,
count(*) as tracks
from chinook.track t
where t.composer is not null
and similarity(
k.artist,
t.composer::text
) > 0.7
group by t.composer
) v
order by k.artist, sim desc;CROSS JOIN LATERAL + pg_trgm surfaces multiple free-text spellings of the same artist, revealing the cost of an uncontrolled text field.
Schema design as a systems concern — separation of concerns between schemas, multi-tenant patterns, audit log strategies, and how schema choices cascade into application architecture and scaling decisions.
Book reference: Chapter 32 — Denormalization
Free preview: JSONB, Arrays & Ranges
begin;
create materialized view
tweet.message_counters as
select messageid,
count(*) filter(
where action = 'rt')
- count(*) filter(
where action = 'de-rt') as rts,
count(*) filter(
where action = 'fav')
- count(*) filter(
where action = 'de-fav') as favs
from tweet.activity
group by messageid;
-- Required for REFRESH CONCURRENTLY:
create unique index on
tweet.message_counters(messageid);
-- Concurrent refresh keeps view readable
-- while old rows are replaced:
refresh materialized view concurrently
tweet.message_counters;
rollback;A materialized view over an event-sourced activity table with REFRESH CONCURRENTLY demonstrates the full cache-management lifecycle.
Core, Advanced, and Architect tiers — 48 modules across 6 PostgreSQL courses.