05 · Data Modeling Basics¶
Loading clean rows somewhere (lesson 4) is only half the job — how you shape the tables determines whether the next six months of queries are fast and obvious, or slow and error-prone. This lesson covers the two models you'll actually use: normalized schemas (for operational systems) and star schemas (for analytics), and the real tradeoff between them.
What actually ran
The schema and queries below ran against a real in-memory SQLite database.
Normalization: one fact, one place¶
Normalization means structuring tables so each piece of information is
stored exactly once. The classic argument for it: no update anomalies. If a
product's category is stored in one dim_product row instead of copied into
every sales row that mentions it, changing the category is a single UPDATE.
cur.executescript("""
CREATE TABLE dim_customer (
customer_id INTEGER PRIMARY KEY,
name TEXT, region TEXT
);
CREATE TABLE dim_product (
product_id INTEGER PRIMARY KEY,
name TEXT, category TEXT
);
CREATE TABLE fact_sales (
sale_id INTEGER PRIMARY KEY,
customer_id INTEGER,
product_id INTEGER,
quantity INTEGER,
unit_price REAL,
sale_date TEXT
);
INSERT INTO dim_customer VALUES (1,'Alice','US'), (2,'Bob','EU');
INSERT INTO dim_product VALUES (10,'Widget','Hardware'), (11,'Gadget','Hardware');
INSERT INTO fact_sales VALUES
(1001, 1, 10, 3, 9.99, '2026-08-01'),
(1002, 2, 11, 1, 19.99, '2026-08-01'),
(1003, 1, 11, 2, 19.99, '2026-08-02');
""")
print("-- What if we update a product's category? --")
cur.execute("UPDATE dim_product SET category = 'Electronics' WHERE product_id = 11")
for row in cur.execute("SELECT * FROM dim_product"):
print(row)
-- What if we update a product's category? --
(10, 'Widget', 'Hardware')
(11, 'Gadget', 'Electronics')
One UPDATE, one row changed, every sale referencing product 11 is
automatically "correct" the next time it's joined — because the category was
never duplicated onto the fact rows in the first place.
The star schema: normalization's analytics-friendly cousin¶
The layout above — small dimension tables (dim_customer, dim_product)
describing "who/what/where," surrounding one big fact table
(fact_sales) recording "what happened, how much, when" — is called a star
schema, the standard layout for analytical (OLAP) workloads:
dim_customer dim_product
(customer_id) ---. .---(product_id)
\ /
fact_sales
(customer_id, product_id,
quantity, unit_price, date)
print("-- Revenue by region and category --")
for row in cur.execute("""
SELECT c.region, p.category, SUM(f.quantity * f.unit_price) AS revenue
FROM fact_sales f
JOIN dim_customer c ON f.customer_id = c.customer_id
JOIN dim_product p ON f.product_id = p.product_id
GROUP BY c.region, p.category
"""):
print(row)
-- Revenue by region and category --
('EU', 'Hardware', 19.99)
('US', 'Hardware', 69.94999999999999)
Two things to notice. First, this is exactly the join pattern from lesson 3 —
a star schema is just normalization applied deliberately, with facts (numeric,
additive measurements) separated from dimensions (descriptive attributes you
group and filter by). Second: 69.94999999999999. That's IEEE-754 binary
floating point doing what it always does with decimal fractions — 9.99 * 3
isn't exactly representable. For money, this is not cosmetic: rounding errors
compound across millions of rows. Production financial pipelines store money
as integer cents, or use a fixed-point DECIMAL type, never raw REAL/FLOAT.
Normalized vs. denormalized: the actual tradeoff¶
You could skip the joins and store region and category directly on every
fact_sales row (denormalized). That's not "wrong" — it's a tradeoff:
| Normalized (star schema) | Denormalized (flat wide table) | |
|---|---|---|
| Update a dimension attribute | 1 row changed | Every fact row referencing it must change |
| Storage | Smaller (no repeated text) | Larger (repeated text per fact row) |
| Query complexity | Requires JOINs | Single-table SELECT, no JOINs |
| Query speed at scale | JOINs cost time | Often faster for read-heavy analytics |
| Risk of inconsistency | Low (single source of truth) | High (copies can drift apart) |
Operational databases (the system powering a checkout flow) almost always
normalize — they do frequent small writes and correctness matters more than
raw read speed. Analytical warehouses often deliberately denormalize (or
build wide, pre-joined tables via dbt, Level 2) because they're read-heavy,
rarely updated in place, and every avoided JOIN at query time is real money
saved on a pay-per-query warehouse.
Traps¶
- Normalizing an analytics table "for correctness" and paying for it in every query. A dashboard querying five levels of joined dimension tables every page load is a design smell — that's what star schemas and materialized wide tables exist to prevent.
- Storing money as
FLOAT/REAL. Use integer cents or aDECIMALtype. The69.94999999999999above is exactly the bug that shows up as "why is our reconciliation off by $0.01" in a real finance pipeline. - No surrogate key on dimensions. Using a natural key (like
product_name) as the join key breaks the moment a product is renamed. Use a stableproduct_idand keep the name as a describable, changeable attribute. - Slowly changing dimensions ignored. If Bob moves from
EUtoUS, overwritingdim_customer.regionin place silently rewrites history — all of Bob's past sales now attribute toUSretroactively. Real warehouses use "Slowly Changing Dimension" patterns (versioned rows with effective dates) to preserve history; this course flags the problem now so it's not a surprise later.
Cheat sheet¶
| Term | Meaning |
|---|---|
| Normalization | Each fact stored exactly once; update in one place |
| Fact table | Numeric, additive measurements (quantity, amount) |
| Dimension table | Descriptive attributes you group/filter by |
| Star schema | Fact table + surrounding dimension tables |
| Denormalization | Deliberately duplicating data for read speed |
| Surrogate key | Stable synthetic ID, independent of business data |
| SCD | Slowly Changing Dimension — versioning to preserve history |
How It Actually Works¶
Normalization (splitting data so each fact lives in exactly one place, per Codd's normal forms) optimizes for write safety: updating a customer's address touches one row in one table, so it's impossible for the address to be correct in one place and stale in another. The cost is paid at read time — reconstructing "customer name + order total" requires a join, and a join means the engine must locate matching rows across two physically separate storage structures, which costs disk/network I/O and CPU for the join algorithm itself.
A star schema inverts that tradeoff on purpose. The fact table stores foreign keys to dimensions plus pre-joined measures (quantity, revenue) at native grain, and dimension tables are wide and denormalized (a dim_customer row repeats the customer's region name rather than pointing to a separate region table). This means an analytics query filters/groups by dimension attributes with a single join hop from fact to each dimension, never dimension-to-dimension — the schema is shaped like the query pattern, not like the write pattern, because analytics workloads are read-heavy and rarely mutate a single fact row after it lands.
Exercise¶
Add a dim_date table (date_id, sale_date, day_of_week, month,
quarter) to the schema above, and rewrite the revenue query to group by
quarter instead of raw sale_date strings. Then deliberately break
normalization once: add a customer_region column directly to fact_sales
(denormalized), populate it, and write the same revenue-by-region query
without joining dim_customer at all. Time isn't the point at this scale —
writing both versions is, so you feel the tradeoff in your own hands before a
real warehouse forces the decision on you at scale.