Data Modeling I — Relational & Normalization
Before you move, transform, or query a single row, you decide what shape the data lives in. That decision quietly governs how easy every downstream task will be. This chapter teaches relational modeling — entities, keys, and normalization — by designing the source schema for our GPU-rental marketplace from scratch. It's the highest-leverage hour in the whole course.
You need the Course 2 stack: Postgres running in Docker from your mini-griddp repo. Bring it up with docker compose up -d, then open a SQL prompt with docker compose exec postgres psql -U postgres. Keep that prompt beside this page — you'll create real tables in the hands-on section. If Postgres won't start, revisit Course 2, Chapter 05 before continuing.
Why the shape of data matters
Imagine someone hands you the marketplace's data as one giant spreadsheet: every row is a rental, and each row also repeats the customer's name, email, and tier, plus the GPU's model and hourly price. It looks convenient — everything's in one place. Then the questions start.
A customer changes their email. Now you have to find and update it in every rental row they ever made — miss one and your data disagrees with itself. A new customer signs up but hasn't rented yet: there's nowhere to put them, because a row only exists when a rental exists. You delete an old rental and accidentally erase the only record that a GPU model ever existed. These are the three classic anomalies — update, insert, and delete — and they all come from one root cause: the same fact is stored in many places.
A good model stores each fact exactly once, in the one place it belongs, and links facts together by reference. When you get that right, downstream work gets easy: queries are simple, joins are predictable, and metrics are trustworthy because there's a single source of truth for "what is this customer's tier?" When you get it wrong, every report becomes a fight against your own data, and no amount of clever SQL or fancy tooling downstream will fully save you. That's why modeling comes first in this course, and why it's the skill that most separates engineers who ship reliable platforms from those who ship fragile ones.
Changing a model on a whiteboard costs minutes. Changing it after a hundred pipelines, dashboards, and downstream tables depend on it costs weeks. The shape of your data is the decision you'll revisit least and regret most — so it's worth slowing down to get right.
Entities & relationships
Relational modeling starts by naming the things your business cares about. Each distinct kind of thing is an entity, and it becomes a table. The facts you record about each thing are its attributes, and they become columns. The ways entities connect to each other are relationships.
For our GPU-rental marketplace, three entities fall out almost immediately:
- Customer — a person or company who rents GPUs. Attributes: name, email, signup date, tier.
- GPU — a model of card available to rent. Attributes: model name, memory, hourly price.
- Rental — one booking: a customer renting a particular GPU for a span of time. Attributes: start time, end time, hours used.
Now the relationships. A single customer can have many rentals, but each rental belongs to exactly one customer — that's a one-to-many relationship (one customer, many rentals). Likewise one GPU model can appear in many rentals. The rental entity sits in the middle, connecting a customer to a GPU. In fact, customers and GPUs have a many-to-many relationship with each other (any customer can rent any GPU, repeatedly), and rental is the junction that resolves it into two one-to-many links.
We draw this as an entity-relationship diagram (ERD). The notation below uses "crow's foot" marks: a single bar (──) means "one," and the splayed foot (< / >) means "many." Read each line as "one customer has many rentals."
Beginners often try to cram a many-to-many relationship into a single table with repeated columns. Don't. Resolve it with a junction table — here rental — that holds one row per pairing plus any facts about that specific pairing (start time, hours). This pattern shows up everywhere: students↔courses, orders↔products, users↔roles.
Keys: primary, foreign, surrogate
A model is only as solid as its keys. A primary key (PK) is the column (or columns) that uniquely identifies each row in a table — no two rows may share it, and it can never be null. It's how you point at exactly one customer or exactly one rental. A foreign key (FK) is a column in one table that holds the primary-key value of a row in another table; it's the wire that links tables together. In the ERD above, rental.customer_id is a foreign key referencing customer.customer_id. The database can enforce that link, refusing to insert a rental for a customer that doesn't exist.
When choosing a primary key you face a fork: use a natural key (a real-world attribute that's already unique, like an email address or a GPU model name) or a surrogate key (a meaningless, system-generated id like an auto-incrementing integer). Both work; here's how they compare.
| Natural key | Surrogate key | |
|---|---|---|
| What it is | A real attribute that's already unique (email, ISBN, model name) | A generated id with no business meaning (1, 2, 3… or a UUID) |
| Example here | email identifies a customer | customer_id auto-generated by the DB |
| Pro | No extra column; meaningful on its own | Never changes; compact; uniform across tables |
| Con | Can change (people change emails!) — and a changing PK ripples into every FK | Meaningless to humans; needs a join to interpret |
| Best for | Truly stable, externally-governed ids (country code, currency code) | Almost everything else — the default in practice |
The killer problem with natural keys is that "real world" facts change, and a primary key should never change — because every foreign key pointing at it would have to change too. An email feels unique and permanent until a customer updates it. A surrogate customer_id stays 42 forever, no matter how often the email, name, or tier changes. That stability is why surrogate integer (or UUID) keys are the common default for source systems. You can still put a UNIQUE constraint on the natural attribute (email) to enforce no duplicates — you just don't make it the primary key.
Normalization: 1NF → 3NF
Normalization is a step-by-step process for organizing columns into tables so that each fact lives in exactly one place. Each "normal form" removes a specific kind of redundancy. You don't need to memorize the formal definitions — you need the intuition, which is captured by a classic line: every non-key column should depend on the key, the whole key, and nothing but the key.
Let's normalize a real mess. Here's the denormalized "rentals" spreadsheet from the intro — one wide table where everything is jammed together:
| customer_email | customer_name | tier | gpus_rented | tier_discount |
|---|---|---|---|---|
| ada@lab.io | Ada | pro | A100; H100 | 10% |
| ada@lab.io | Ada | pro | A100 | 10% |
| kade@io.dev | Kade | free | T4; A100 | 0% |
This table has every anomaly. Watch each normal form clean up one problem:
- 1NF — atomic values, no repeating groups. The
gpus_rentedcell holds a list (A100; H100). That's not atomic — you can't filter or join on it cleanly. First normal form says each cell holds one value and there are no repeating groups. Fix: split the list so each rented GPU is its own row. - 2NF — no partial dependencies on a composite key. Second normal form matters when your key is made of multiple columns. It says every non-key column must depend on the whole key, not just part of it. If the key were
(customer_email, gpu), thencustomer_namedepends only oncustomer_email— half the key — which is a partial dependency. Fix: move customer attributes into their own table keyed by customer. - 3NF — no transitive dependencies. Third normal form says non-key columns must depend on the key directly, not through another non-key column. Here
tier_discountdepends ontier, andtierdepends on the customer — sotier_discountonly depends on the key transitively, throughtier. That's why the 10% is copied on every Ada row. Fix: pulltier → discountinto its own little table.
After normalizing to 3NF, the one messy table becomes four clean ones, each owning a single kind of fact:
Now Ada's discount is stored once in tier; her email is stored once in customer; a new customer can be inserted with zero rentals; and deleting a rental can't erase a GPU. Every anomaly is gone — not by being careful, but by structure.
When to normalize (and when not to)
Here's the nuance that trips people up: normalization is the right default for source systems, but the wrong default for analytics. The two have opposite priorities.
| OLTP source system (this chapter) | Analytics warehouse (next chapter) | |
|---|---|---|
| Job | Record transactions as they happen | Answer analytical questions over history |
| Workload | Many small writes & point lookups | Big reads scanning many rows |
| Optimize for | Correctness & fast, anomaly-free writes | Fast reads & simple queries |
| So you… | Normalize (3NF) — no duplicated facts | Denormalize — pre-join for speed |
A normalized model is fantastic for the marketplace's live database: when a customer changes their email, you update one row, and there's never any disagreement. But analytics asks questions like "revenue by customer tier per month," which would force a chain of joins across four tables, run over and over. For that, you deliberately denormalize — pre-joining and duplicating data into wide, read-friendly tables — trading the write-time safety you don't need for the read-time speed you do.
It's tempting to "simplify" by collapsing your OLTP tables to avoid joins. Resist it. The moment you duplicate a fact in a system that takes live writes, you've reintroduced the update anomaly — and now your production data can silently disagree with itself. Normalize the source; denormalize downstream, in copies, where nothing writes back. That separation is exactly the journey the rest of this course follows.
Hands-on: build the normalized schema
Time to make it real. Open your Postgres prompt (docker compose exec postgres psql -U postgres) and type this DDL. Notice how the design choices above show up in syntax: GENERATED ALWAYS AS IDENTITY gives us surrogate keys, PRIMARY KEY and REFERENCES wire up the keys, and UNIQUE guards the natural attribute (email) without making it the PK.
-- A clean schema to work in, so we don't collide with Course 2's tables
CREATE SCHEMA IF NOT EXISTS marketplace;
SET search_path TO marketplace;
-- tier: the lookup table that kills the transitive dependency (3NF)
CREATE TABLE tier (
tier TEXT PRIMARY KEY, -- natural key: a short, stable code
discount NUMERIC(4,2) NOT NULL -- e.g. 0.10 = 10% off
);
-- customer: surrogate PK; email is unique but NOT the key
CREATE TABLE customer (
customer_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE, -- natural attribute, enforced unique
name TEXT NOT NULL,
tier TEXT NOT NULL REFERENCES tier(tier), -- FK → tier
signup_date DATE NOT NULL DEFAULT CURRENT_DATE
);
-- gpu: one row per rentable model
CREATE TABLE gpu (
gpu_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
model TEXT NOT NULL UNIQUE,
memory_gb INT NOT NULL,
hourly_price NUMERIC(8,2) NOT NULL
);
-- rental: the junction, with two FKs and facts about the booking itself
CREATE TABLE rental (
rental_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customer(customer_id), -- FK → customer
gpu_id INT NOT NULL REFERENCES gpu(gpu_id), -- FK → gpu
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
hours NUMERIC(6,2)
);Now seed a few rows. Because the keys are enforced, the database will reject any rental that points at a customer or GPU that doesn't exist — a guarantee a spreadsheet can never give you.
INSERT INTO tier (tier, discount) VALUES
('free', 0.00),
('pro', 0.10);
INSERT INTO customer (email, name, tier) VALUES
('ada@lab.io', 'Ada', 'pro'),
('kade@io.dev', 'Kade', 'free');
INSERT INTO gpu (model, memory_gb, hourly_price) VALUES
('T4', 16, 0.35),
('A100', 40, 2.10),
('H100', 80, 4.50);
-- A rental, joining a customer to a GPU. We look up the surrogate ids by
-- their natural attributes so we don't have to hard-code 1, 2, 3.
INSERT INTO rental (customer_id, gpu_id, started_at, ended_at, hours)
SELECT c.customer_id, g.gpu_id,
'2026-06-20 09:00+00', '2026-06-20 12:00+00', 3.0
FROM customer c, gpu g
WHERE c.email = 'ada@lab.io' AND g.model = 'A100';
-- Read it back with the joins the model makes easy:
SELECT c.name, c.tier, t.discount, g.model, r.hours,
g.hourly_price * r.hours * (1 - t.discount) AS charged
FROM rental r
JOIN customer c ON c.customer_id = r.customer_id
JOIN tier t ON t.tier = c.tier
JOIN gpu g ON g.gpu_id = r.gpu_id;The final query returns one row: Ada | pro | 0.10 | A100 | 3.00 | 5.67 — that's 2.10 × 3 hours × (1 − 0.10) = 5.67. Every value comes from the one place it's stored: the price from gpu, the discount from tier, the hours from rental. Change Ada's email or the pro discount in a single row and this calculation stays correct everywhere. If an INSERT ever errors with violates foreign key constraint, that's the database protecting you — it means you pointed at an id that doesn't exist.
✓ Check yourself
- Can you name the three anomalies (update, insert, delete) and explain what causes all of them?
- Can you explain why a surrogate key is usually preferred over a natural key as a primary key?
- Given a wide table, can you spot a column that depends on a non-key column (a 3NF violation)?
- Can you say why you normalize a source system but denormalize for analytics?
Exercise — Find the 3NF violation and split the table
The marketplace stores GPU inventory in one table. Identify what violates 3NF, then split it into normalized tables with the right keys.
| gpu_id | model | vendor | vendor_country | hourly_price |
|---|---|---|---|---|
| 1 | T4 | NVIDIA | USA | 0.35 |
| 2 | A100 | NVIDIA | USA | 2.10 |
| 3 | MI300 | AMD | USA | 3.20 |
Think first: Which column doesn't depend directly on gpu_id?
Answer. vendor_country is the 3NF violation: it depends on vendor (a non-key column), not on the primary key gpu_id. That's a transitive dependency — and it's why "USA" is duplicated on every NVIDIA row. Pull the vendor facts into their own table, keyed by vendor:
-- vendor now owns the vendor→country fact, stored once
CREATE TABLE vendor (
vendor TEXT PRIMARY KEY,
vendor_country TEXT NOT NULL
);
-- gpu keeps only facts that depend on gpu_id, plus an FK to vendor
CREATE TABLE gpu_v2 (
gpu_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
model TEXT NOT NULL UNIQUE,
vendor TEXT NOT NULL REFERENCES vendor(vendor), -- FK, not a copy
hourly_price NUMERIC(8,2) NOT NULL
);
INSERT INTO vendor VALUES ('NVIDIA','USA'), ('AMD','USA');
INSERT INTO gpu_v2 (model, vendor, hourly_price) VALUES
('T4','NVIDIA',0.35), ('A100','NVIDIA',2.10), ('MI300','AMD',3.20);Now if NVIDIA's country ever needs correcting, you update one row in vendor instead of hunting down every GPU they make. Same structure, no duplication, no anomaly. If you spotted vendor_country as the offender on your own, you've got the core instinct of normalization.
Next
You've built a normalized source that's correct and anomaly-free — perfect for live writes, painful for analytics. Next we flip the priorities and reshape this same data for fast reads. → Data Modeling II — Dimensional Modeling