Course 3 of 6 · chinook & f1db datasets

Data Modeling for Performance

Schema design from relational foundations through constraints, indexes, and safe schema evolution.

8Modules
6Core
1Advanced
1Architect
Module 01

The Relational Model

Core · 5 topics

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

01
Normal forms and the three database anomaliesIdentify the three anomalies that redundant data storage creates.
02
First through fifth normal formsApply each normal form to eliminate a specific class of redundancy.
03
Contrasting schemas: chinook (enforced FKs) vs f1db (no FKs)Compare how two real datasets differ in their use of foreign key constraints.
04
Constraint inventory via pg_constraintAudit a live database schema to see which constraints are declared.
05
Foreign key enforcement trade-offsPredict the data integrity risks of omitting foreign key constraints.

Free preview: The Three Database Anomalies

Normal forms
Understand how each normal form systematically narrows redundancy.
Module 01 · Insertion anomaly
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.

Module 02

Primary Keys and Constraints

Core · 6 topics

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

01
Natural vs surrogate primary keysChoose between natural and surrogate keys for a table's primary key.
02
UUID generation with gen_random_uuid() and uuidv7()Select the right UUID generation strategy for your workload.
03
Check constraintsEnforce column-level business rules with declarative check constraints.
04
Generated stored columns (GENERATED ALWAYS AS STORED)Add computed columns that stay in sync with their source data automatically.
05
NOT NULL migration patternSafely add a NOT NULL column to a live table without breaking existing rows.
06
Exclusion constraints with btree_gistPrevent overlapping bookings or contracts at the database level.

Free preview: Foreign Keys & Planner Trust · Range Types & Temporal Constraints

B-tree page layout for primary keys
See how key choice affects B-tree write patterns and index bloat.
Module 02 · Exclusion constraint
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.

Module 03

Relationships at Scale

Core · 5 topics

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

01
Hierarchy chains and fan-outAvoid inflated counts by aggregating before joining across relationships.
02
Junction tablesModel many-to-many relationships without creating duplicate associations.
03
Self-referential relationships and recursive CTEsTraverse hierarchical data stored as a self-referential table structure.
04
Nullable foreign keysPredict which rows disappear from joins when foreign keys are nullable.
05
Foreign key actions: CASCADE, SET NULL, RESTRICTChoose the right referential action for each parent-child relationship.

Free preview: Foreign Keys & Planner Trust

Foreign key actions ERD
Understand how schema-level actions replace application-level integrity code.
Module 03 · Junction table query
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.

Module 04

Index Design and Access Patterns

Core · 6 topics

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

01
Index types: B-tree, GiST, GIN, SP-GiST, BRIN, Hash, RUM, BloomSelect the right index type for each query's access pattern.
02
Fillfactor and HOT updatesReduce index bloat by tuning fillfactor for frequently updated tables.
03
Unique constraints and partial unique indexesEnforce conditional uniqueness on a filtered subset of rows.
04
Composite indexes, partial indexes, covering indexes (INCLUDE)Design covering indexes that serve queries entirely from the index.
05
Functional / expression indexesSpeed up function-based filters without rewriting the application query.
06
Index column options: ASC/DESC, NULLS FIRST/LAST, operator classesAlign index sort options so the planner eliminates the sort step.

Free preview: Foreign Keys & Planner Trust

Index types
Match each index type to the queries it was designed to accelerate.
Module 04 · Partial index
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.

Module 05

Modeling for Analytics

Core · 5 topics

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

01
The star joinDesign a star schema that accelerates multi-dimensional analytical queries.
02
Intentional denormalisation for historical accuracyDecide when to denormalize deliberately to preserve historical values over time.
03
Materialized view vs live queryChoose between a live query and a materialized view based on freshness needs.
04
Three-level MV dependency chain with concurrent refreshBuild a multi-level refresh pipeline that never blocks concurrent reads.
05
Arrays to reduce row count (array_agg)Collapse per-group rows into arrays to simplify downstream processing.

Free preview: JSONB, Arrays & Ranges

Star schema: fact table with dimension tables
See how a star schema organizes fact and dimension tables for fast aggregation.
Module 05 · Star join revenue
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.

Module 06

Evolving Schemas Safely

Core · 6 topics

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

01
Adding a column without a table rewrite (PostgreSQL 11+)Add columns with defaults to large tables without triggering a table rewrite.
02
Adding constraints safelyAdd a constraint to a live table without locking out concurrent writes.
03
Building indexes without downtimeBuild a new index on a production table without taking it offline.
04
Production role hierarchyDesign a role hierarchy that limits each credential to its minimum needed access.
05
Three-step pattern: add column → backfill → enforce NOT NULLExecute a zero-downtime NOT NULL migration across add, backfill, and enforce steps.
06
Rolling back schema deploymentsStructure schema deployments so each step can be rolled back independently.
I/O patterns for safe schema changes
See how background builds replace single blocking scans for indexes and constraints.
Module 06 · NOT VALID constraint
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.

Module 07 · Advanced

Advanced Modeling Trade-offs

Advanced · 4 topics

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

01
The EAV anti-pattern and JSONB as a replacementRecognise when the EAV pattern creates performance and maintainability problems.
02
Normalising free-text fields: trigram similarity and many-to-many schemaNormalise free-text fields by clustering similar spellings into a canonical form.
03
Modeling band membership with tstzrange exclusion constraintsModel non-overlapping time periods and enforce them at the database level.
04
Multirange variant (tstzmultirange) for multiple stint windowsRepresent multiple time periods per entity in a single queryable column.

Free preview: JSONB, Arrays & Ranges · Range Types & Temporal Constraints

EAV anti-pattern vs JSONB
Compare EAV and JSONB approaches for storing flexible attribute sets.
Module 07 · Trigram similarity
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.

Module 08 · Architect

Data Architecture

Architect · 7 topics

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

01
JSONB document model vs relational modelDecide when a document model fits better than a fully normalised relational schema.
02
Querying JSONB: ->, ->>, #>, path operators, @?, @@Query deeply nested JSONB documents using PostgreSQL path operators.
03
Containment with GIN index (@>)Index JSONB columns so containment queries run without full-table scans.
04
JSONB as an audit trail (to_jsonb + trigger)Build an append-only audit trail that captures full row snapshots automatically.
05
Building and decomposing JSONB (jsonb_build_object, jsonb_agg)Construct and aggregate JSONB documents directly from relational query results.
06
SQL/JSON path language (PostgreSQL 12+)Navigate nested JSONB documents using standard SQL/JSON path expressions.
07
Expression index on extracted JSONB fieldsMake specific JSONB field lookups as fast as querying a regular column.

Free preview: JSONB, Arrays & Ranges

Document model vs relational model
Understand the trade-off between document storage and relational normalisation.
Module 08 · Materialized view + refresh
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.

All 6 courses. One package.

Core, Advanced, and Architect tiers — 48 modules across 6 PostgreSQL courses.

Get All 6 Courses Browse All 6 Courses