?

Day 0340 minPreface+60 XP

Data Modeling for Analytics.

System Design Mastery Series — Analytics Pipeline Week

4 stages27 code

watch it first · newThe whole lesson in a 18-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Yesterday, we learned when to use streaming versus batch processing. Our Flink jobs and Spark jobs are now producing aggregated metrics. But where do these metrics go? How do we store them for fast queries?

The query performance problem
Your batch job computed daily revenue by category
  • 365 days × 1000 categories = 365,000 rows
  • Query: "Show revenue by category for 2024"
  • Result: 50ms — Fast!
But the CEO asks
  • "Break it down by country" (200 countries)
  • "And by customer segment" (10 segments)
  • "And by channel" (5 channels)
Now you have
  • 365 × 1000 × 200 × 10 × 5 = 3.65 BILLION rows
  • Same query now takes 45 seconds
  • Dashboard unusable

The problem isn't compute — it's how you model the data.

Today, we'll learn to design data models that make analytics queries fast, even over billions of rows.

ExplainerThe whole lesson: data modeling for analytics

Daily revenue by category is 365,000 rows and answers in 50 ms, but broken down by country, segment and channel it becomes 3.65 billion rows and 45 seconds, and normalized OLTP tables add a JOIN per hop. A star schema puts measures (quantity, revenue, discount, profit) in a fact table at line-item grain, surrounded by denormalized dimensions (product, date, customer, store) joined by surrogate keys, with NULLs mapped to an Unknown row (key -1). Columnar storage reads only the needed columns (1 of 4: 75% less I/O), date partitions let a one-day query skip 364 others, a rollup turns a 60-second query into 50 ms, and partitioning by customer_id would create 10 million tiny partitions.

Part I · Stage 1 of 4 · 26 min

Part I: Foundations#

Chapter 1: OLTP vs OLAP#

1.1 Two Different Worlds#

OLTP vs OLAP

OLTP (Online Transaction Processing)

Purpose: Run the business Examples: Orders, payments, user signups

Characteristics:

  • Many small transactions
  • Read and write single rows
  • Normalized (3NF) to avoid duplication
  • Optimized for write speed
  • Row-oriented storage

Query patterns:

  • "Get order #12345"
  • "Update user's email"
  • "Insert new payment"
  • Touches few rows, many columns

OLAP (Online Analytical Processing)

Purpose: Understand the business Examples: Revenue reports, funnel analysis, trends

Characteristics:

  • Few large queries
  • Read many rows, few columns
  • Denormalized for query speed
  • Optimized for read speed
  • Column-oriented storage

Query patterns:

  • "Total revenue by month for 2024"
  • "Conversion rate by country"
  • "Top 100 products by sales"
  • Touches many rows, few columns
Your turn · checkpoint 1not answered

What would you give up for one scan?

"Revenue by country" on the normalized schema joins orders to users to countries, and gets slower as data grows. What would you change so it becomes a single scan and GROUP BY, and what does that cost you?

Think it through, or jot it down. Then compare.

One strong answer

  • Denormalize: copy country (and segment) onto the rows you scan, so the query reads one table with no join chain and no shuffles.
  • The cost: the same values are stored many times, which takes more storage and makes updates harder.
  • Why it's worth it here: OLAP runs few, large read-heavy queries over many rows, so read speed matters more than write efficiency. OLTP makes the opposite trade.

1.2 Why Different Models?#

The normalization trade-off

OLTP: Normalized (3NF)

text11 lines
orders:
  order_id | user_id | total | created_at

users:
  user_id | name | country_id | segment_id

countries:
  country_id | name | region

segments:
  segment_id | name | description

Query: "Revenue by country"

sql5 lines
SELECT c.name, SUM(o.total)
FROM orders o
JOIN users u ON o.user_id = u.user_id
JOIN countries c ON u.country_id = c.country_id
GROUP BY c.name

Problems:

  • Multiple JOINs (expensive at scale)
  • Each JOIN shuffles data
  • Index lookups for each row
  • Gets slower as data grows

OLAP: Denormalized (Star Schema)

text2 lines
fact_orders:
  order_id | user_id | country | segment | total | date

Query: "Revenue by country"

sql3 lines
SELECT country, SUM(total)
FROM fact_orders
GROUP BY country

Benefits:

  • No JOINs needed
  • Single table scan
  • Columnar storage efficient
  • Scales linearly with data

Chapter 2: Star Schema#

2.1 The Core Concept#

DiagramStar schema structure
%% title: Star schema structure
flowchart TD
  FS[("fact_sales<br/>───────────<br/>sale_key (PK) · date_key (FK)<br/>product_key · customer_key · store_key<br/>quantity · unit_price · discount<br/>revenue · profit")]:::core
  DP[("dim_product<br/>───────────<br/>product_key (PK) · product_id · name<br/>category · subcategory · brand · price")]:::store
  DD[("dim_date<br/>───────────<br/>date_key (PK) · date · day · month<br/>quarter · year · day_of_week<br/>is_weekend · is_holiday")]:::store
  DCU[("dim_customer<br/>───────────<br/>customer_key (PK) · customer_id · name<br/>email · segment · tier · signup_date<br/>country · region")]:::store
  DS[("dim_store<br/>───────────<br/>store_key (PK) · store_id · name<br/>city · state · country · region<br/>store_type")]:::store
  FS -->|"product_key"| DP
  FS -->|"date_key"| DD
  FS -->|"customer_key"| DCU
  FS -->|"store_key"| DS

Why it's called "star": the fact table in the center with dimension tables around it looks like a star when you draw the relationships.

Key principles:

  1. Fact table: Contains measures (numbers you aggregate)
    • revenue, quantity, discount, profit
    • One row per transaction/event
    • Foreign keys to dimension tables
  2. Dimension tables: Contains attributes (things you group by)
    • product details, customer info, date attributes
    • Denormalized (no further joins needed)
    • Descriptive text fields

2.2 Implementing Star Schema#

pythonDimDate · 282 lines
# modeling/star_schema.py

"""
Star schema implementation for e-commerce analytics.

This model supports queries like:
- Revenue by product category by month
- Top customers by segment by region
- Sales trends by day of week
"""

from dataclasses import dataclass
from datetime import date, datetime
from typing import Optional, List
from decimal import Decimal


# =============================================================================
# Dimension Tables
# =============================================================================

@dataclass
class DimDate:
    """
    Date dimension with pre-computed attributes.
    
    Generated once, rarely changes.
    Enables fast date-based filtering and grouping.
    """
    date_key: int  # Surrogate key (YYYYMMDD format)
    date: date
    day: int
    month: int
    month_name: str
    quarter: int
    year: int
    day_of_week: int  # 0=Monday, 6=Sunday
    day_name: str
    week_of_year: int
    is_weekend: bool
    is_holiday: bool
    is_last_day_of_month: bool
    fiscal_year: int
    fiscal_quarter: int


@dataclass
class DimProduct:
    """
    Product dimension with hierarchy.
    
    Category → Subcategory → Product
    """
    product_key: int  # Surrogate key
    product_id: str  # Business key
    product_name: str
    category: str
    subcategory: str
    brand: str
    supplier: str
    unit_price: Decimal
    unit_cost: Decimal
    is_active: bool
    
    # SCD Type 2 fields (for tracking changes)
    valid_from: date
    valid_to: Optional[date]
    is_current: bool


@dataclass  
class DimCustomer:
    """
    Customer dimension with segmentation.
    """
    customer_key: int  # Surrogate key
    customer_id: str  # Business key
    first_name: str
    last_name: str
    email: str
    phone: Optional[str]
    
    # Demographics
    age_group: str  # "18-25", "26-35", etc.
    gender: Optional[str]
    
    # Segmentation
    segment: str  # "Premium", "Standard", "Budget"
    tier: str  # "Gold", "Silver", "Bronze"
    lifetime_value_band: str  # "High", "Medium", "Low"
    
    # Geography
    city: str
    state: str
    country: str
    region: str  # "North America", "Europe", etc.
    postal_code: str
    
    # Dates
    first_purchase_date: date
    signup_date: date
    
    # SCD fields
    valid_from: date
    valid_to: Optional[date]
    is_current: bool


@dataclass
class DimStore:
    """
    Store/channel dimension.
    """
    store_key: int
    store_id: str
    store_name: str
    store_type: str  # "Online", "Retail", "Warehouse"
    city: str
    state: str
    country: str
    region: str
    timezone: str
    opened_date: date
    manager_name: str
    square_footage: Optional[int]


# =============================================================================
# Fact Table
# =============================================================================

@dataclass
class FactSales:
    """
    Sales fact table.
    
    Grain: One row per line item in an order.
    
    Contains:
    - Foreign keys to all dimensions
    - Measures (numeric values to aggregate)
    - Degenerate dimensions (order_id - no separate table needed)
    """
    # Surrogate key
    sale_key: int
    
    # Foreign keys to dimensions
    date_key: int
    product_key: int
    customer_key: int
    store_key: int
    
    # Degenerate dimension (stored in fact, no lookup needed)
    order_id: str
    line_item_number: int
    
    # Measures (things we SUM, AVG, COUNT)
    quantity: int
    unit_price: Decimal
    unit_cost: Decimal
    discount_amount: Decimal
    tax_amount: Decimal
    
    # Derived measures (pre-calculated for performance)
    gross_revenue: Decimal  # quantity * unit_price
    net_revenue: Decimal    # gross_revenue - discount_amount
    profit: Decimal         # net_revenue - (quantity * unit_cost)
    
    # Timestamps
    order_timestamp: datetime
    ship_timestamp: Optional[datetime]


# =============================================================================
# Schema Creation SQL
# =============================================================================

STAR_SCHEMA_DDL = """
-- Date Dimension (generate for all dates needed)
CREATE TABLE dim_date (
    date_key        INTEGER PRIMARY KEY,  -- YYYYMMDD
    date            DATE NOT NULL,
    day             SMALLINT NOT NULL,
    month           SMALLINT NOT NULL,
    month_name      VARCHAR(10) NOT NULL,
    quarter         SMALLINT NOT NULL,
    year            SMALLINT NOT NULL,
    day_of_week     SMALLINT NOT NULL,
    day_name        VARCHAR(10) NOT NULL,
    week_of_year    SMALLINT NOT NULL,
    is_weekend      BOOLEAN NOT NULL,
    is_holiday      BOOLEAN NOT NULL,
    fiscal_year     SMALLINT NOT NULL,
    fiscal_quarter  SMALLINT NOT NULL
);

-- Product Dimension
CREATE TABLE dim_product (
    product_key     SERIAL PRIMARY KEY,
    product_id      VARCHAR(50) NOT NULL,
    product_name    VARCHAR(200) NOT NULL,
    category        VARCHAR(100) NOT NULL,
    subcategory     VARCHAR(100) NOT NULL,
    brand           VARCHAR(100),
    supplier        VARCHAR(100),
    unit_price      DECIMAL(10,2) NOT NULL,
    unit_cost       DECIMAL(10,2) NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    valid_from      DATE NOT NULL,
    valid_to        DATE,
    is_current      BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE INDEX idx_product_id ON dim_product(product_id);
CREATE INDEX idx_product_category ON dim_product(category, subcategory);

-- Customer Dimension  
CREATE TABLE dim_customer (
    customer_key    SERIAL PRIMARY KEY,
    customer_id     VARCHAR(50) NOT NULL,
    first_name      VARCHAR(100),
    last_name       VARCHAR(100),
    email           VARCHAR(200),
    segment         VARCHAR(50) NOT NULL,
    tier            VARCHAR(20) NOT NULL,
    city            VARCHAR(100),
    state           VARCHAR(100),
    country         VARCHAR(100) NOT NULL,
    region          VARCHAR(50) NOT NULL,
    first_purchase_date DATE,
    valid_from      DATE NOT NULL,
    valid_to        DATE,
    is_current      BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE INDEX idx_customer_id ON dim_customer(customer_id);
CREATE INDEX idx_customer_segment ON dim_customer(segment, tier);
CREATE INDEX idx_customer_region ON dim_customer(region, country);

-- Store Dimension
CREATE TABLE dim_store (
    store_key       SERIAL PRIMARY KEY,
    store_id        VARCHAR(50) NOT NULL,
    store_name      VARCHAR(200) NOT NULL,
    store_type      VARCHAR(50) NOT NULL,
    city            VARCHAR(100),
    state           VARCHAR(100),
    country         VARCHAR(100) NOT NULL,
    region          VARCHAR(50) NOT NULL,
    timezone        VARCHAR(50),
    opened_date     DATE
);

-- Sales Fact Table
CREATE TABLE fact_sales (
    sale_key        BIGSERIAL PRIMARY KEY,
    date_key        INTEGER NOT NULL REFERENCES dim_date(date_key),
    product_key     INTEGER NOT NULL REFERENCES dim_product(product_key),
    customer_key    INTEGER NOT NULL REFERENCES dim_customer(customer_key),
    store_key       INTEGER NOT NULL REFERENCES dim_store(store_key),
    order_id        VARCHAR(50) NOT NULL,
    line_item_number SMALLINT NOT NULL,
    quantity        INTEGER NOT NULL,
    unit_price      DECIMAL(10,2) NOT NULL,
    discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
    tax_amount      DECIMAL(10,2) NOT NULL DEFAULT 0,
    gross_revenue   DECIMAL(12,2) NOT NULL,
    net_revenue     DECIMAL(12,2) NOT NULL,
    profit          DECIMAL(12,2) NOT NULL,
    order_timestamp TIMESTAMP NOT NULL
);

-- Critical indexes for fact table
CREATE INDEX idx_fact_date ON fact_sales(date_key);
CREATE INDEX idx_fact_product ON fact_sales(product_key);
CREATE INDEX idx_fact_customer ON fact_sales(customer_key);
CREATE INDEX idx_fact_order ON fact_sales(order_id);

-- Composite indexes for common query patterns
CREATE INDEX idx_fact_date_product ON fact_sales(date_key, product_key);
CREATE INDEX idx_fact_date_store ON fact_sales(date_key, store_key);
"""

2.3 Star Schema Queries#

python121 lines
# modeling/star_queries.py

"""
Example queries on star schema.

These demonstrate why star schema is efficient for analytics.
"""

EXAMPLE_QUERIES = """
-- =============================================================================
-- Query 1: Revenue by category by month
-- =============================================================================
-- Star join: fact + product and date dimensions

SELECT 
    p.category,
    d.year,
    d.month,
    SUM(f.net_revenue) as revenue,
    COUNT(DISTINCT f.order_id) as orders
FROM fact_sales f
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE d.year = 2024
  -- No is_current filter: each fact row already points at the
  -- product version valid at sale time (SCD Type 2)
GROUP BY p.category, d.year, d.month
ORDER BY d.year, d.month, revenue DESC;


-- =============================================================================
-- Query 2: Top 10 products by revenue this quarter
-- =============================================================================

SELECT 
    p.product_name,
    p.category,
    SUM(f.net_revenue) as revenue,
    SUM(f.quantity) as units_sold,
    SUM(f.profit) as profit
FROM fact_sales f
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE d.year = 2024 AND d.quarter = 4
GROUP BY p.product_key, p.product_name, p.category
ORDER BY revenue DESC
LIMIT 10;


-- =============================================================================
-- Query 3: Customer segment analysis
-- =============================================================================

SELECT 
    c.segment,
    c.tier,
    c.region,
    COUNT(DISTINCT c.customer_key) as customers,
    SUM(f.net_revenue) as revenue,
    SUM(f.net_revenue) / COUNT(DISTINCT c.customer_key) as revenue_per_customer,
    SUM(f.quantity) * 1.0 / COUNT(DISTINCT f.order_id) as avg_items_per_order
FROM fact_sales f
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE d.year = 2024
GROUP BY c.segment, c.tier, c.region
ORDER BY revenue DESC;


-- =============================================================================
-- Query 4: Year-over-year comparison
-- =============================================================================

WITH current_year AS (
    SELECT 
        p.category,
        SUM(f.net_revenue) as revenue_2024
    FROM fact_sales f
    JOIN dim_product p ON f.product_key = p.product_key
    JOIN dim_date d ON f.date_key = d.date_key
    WHERE d.year = 2024
    GROUP BY p.category
),
previous_year AS (
    SELECT 
        p.category,
        SUM(f.net_revenue) as revenue_2023
    FROM fact_sales f
    JOIN dim_product p ON f.product_key = p.product_key
    JOIN dim_date d ON f.date_key = d.date_key
    WHERE d.year = 2023
    GROUP BY p.category
)
SELECT 
    c.category,
    c.revenue_2024,
    p.revenue_2023,
    (c.revenue_2024 - p.revenue_2023) / p.revenue_2023 * 100 as yoy_growth_pct
FROM current_year c
JOIN previous_year p ON c.category = p.category
ORDER BY yoy_growth_pct DESC;


-- =============================================================================
-- Query 5: Order metrics by store type
-- =============================================================================

SELECT 
    s.store_type,
    COUNT(DISTINCT f.order_id) as orders,
    COUNT(DISTINCT f.customer_key) as unique_customers,
    SUM(f.net_revenue) as revenue,
    SUM(f.net_revenue) / COUNT(DISTINCT f.order_id) as avg_order_value,  -- grain is line item
    SUM(f.discount_amount) / SUM(f.gross_revenue) * 100 as discount_rate
FROM fact_sales f
JOIN dim_store s ON f.store_key = s.store_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE d.year = 2024
GROUP BY s.store_type
ORDER BY revenue DESC;
"""
Quick check3 questions · +5 XP each

OLTP vs OLAP and star schema

  1. Q1/3 Your "revenue by country" query joins orders → users → countries in a 3NF schema and gets slower as data grows. What does a star schema change?

    Answer & explanation

    Answer: A

    Star schemas denormalize descriptive attributes, so "revenue by country" becomes a single scan and GROUP BY. Normalizing further adds joins, and every join shuffles data. That join cost is exactly what you are trying to remove.

  2. Q2/3 Analysts want to compare weekend and weekday revenue. Where does is_weekend belong?

    Answer & explanation

    Answer: C

    Anything you group or filter by is a dimension attribute. dim_date pre-computes calendar attributes like is_weekend and fiscal_quarter, so queries need no date math. Measures on the fact table are numbers you aggregate, and is_weekend is not one.

  3. Q3/3 fact_sales stores order_id directly, with no dim_order table. Why?

    Answer & explanation

    Answer: B

    order_id is a degenerate dimension. It identifies the order (e.g. COUNT(DISTINCT order_id)), but it has no descriptive attributes of its own, so a lookup table would add a join and give you nothing back. It is not a measure: you count it, you don't sum it.

Chapter 3: Slowly Changing Dimensions (SCD)#

3.1 The Problem#

The changing dimension problem

A customer's attributes change over time:

Attribute January: Customer John June: Customer John (upgraded!)
Segment "Standard" "Premium" ← Changed!
Tier "Bronze" "Gold" ← Changed!
Country "USA" "USA"

Question: When we query Q1 revenue by segment, should John count as "Standard" or "Premium"?

Answer: It depends on the business question!

  • Option A: Current value — "How much did our current Premium customers spend in Q1?" John counts as Premium (even for Q1 purchases)
  • Option B: Historical value — "What was the revenue split by segment in Q1?" John counts as Standard for Q1 purchases

Both are valid! We need to support both.

Your turn · checkpoint 2not answered

One customer, two segments, two questions

John moved from Standard to Premium in June. Finance wants Q1 revenue by segment as it was at the time. Marketing wants to know how much today's Premium customers spent in Q1. How can one customer dimension answer both?

Think it through, or jot it down. Then compare.

One strong answer

  • Keep every version: a new row per change, with valid_from, valid_to and is_current. That is SCD Type 2.
  • Historical question: each sale points at the version valid when it happened, so John's Q1 purchases count as Standard.
  • Current question: also carry the current value on every row (Type 6), or look up the is_current row by the business key. Then John counts as Premium.
  • Overwriting in place (Type 1) answers only the marketing question; the history is gone.

3.2 SCD Types#

Slowly changing dimension types
Type 0: Retain Original

Never change. Keep the first value forever. Use for: Immutable attributes (signup date, original source)

Type 1: Overwrite

Simply update the value. Lose history.

  • Before: customer_id=123, segment="Standard"
  • After: customer_id=123, segment="Premium"

Pros: Simple, no extra storage · Cons: No history, can't analyze changes

Type 2: Add New Row (Most Common)

Create new row for each change. Track validity period.

customer_key customer_id segment valid_from valid_to is_current
1 123 Standard 2024-01-01 2024-06-01 FALSE
2 123 Premium 2024-06-01 NULL TRUE

Pros: Full history, accurate point-in-time analysis · Cons: More storage, more complex queries

Type 3: Add New Column

Track current and previous value only.

customer_id current_segment previous_segment
123 Premium Standard

Pros: Simple to query current vs previous · Cons: Only one level of history

Type 6: Hybrid (1 + 2 + 3)

Combine approaches for flexibility.

customer_key customer_id segment current_segment valid_from valid_to
1 123 Standard Premium 2024-01-01 2024-06-01
2 123 Premium Premium 2024-06-01 NULL

Can query historical value (segment) OR current value (current_segment)

ExplainerSlowly changing dimensions: Type 1 vs Type 2

John moves from Standard to Premium in June. Type 1 overwrites the row, so his Q1 sales count as Premium. Type 2 closes row 1 (valid_to 2024-05-31), adds row 2 (Premium from 2024-06-01), and each sale keeps the surrogate key that was valid when it happened, so revenue by segment is point-in-time.

Your turn · checkpoint 3not answered

What breaks if facts store the business key?

The fact table stores customer_id (the business key) instead of a surrogate key, and dim_customer keeps a row per version. What goes wrong when you join the two for "revenue by segment"?

Think it through, or jot it down. Then compare.

One strong answer

  • Every version matches: customer_id appears on each of John's rows, so each sale joins to all of them and is counted once per version.
  • Filtering on is_current removes the duplicates but attributes every past sale to today's segment, which is the Type 1 problem again.
  • The fix: give each version its own surrogate key, and store in each fact row the key that was valid at sale time. The plain join is then point-in-time correct.

3.3 Implementing SCD Type 2#

pythonDimensionRecord · 205 lines
# modeling/scd_type2.py

"""
Slowly Changing Dimension Type 2 implementation.

Maintains full history of dimension changes.
"""

from dataclasses import dataclass
from datetime import date, datetime
from typing import Optional, Dict, Any, List
import hashlib


@dataclass
class DimensionRecord:
    """A record in an SCD Type 2 dimension."""
    surrogate_key: int
    business_key: str
    attributes: Dict[str, Any]
    valid_from: date
    valid_to: Optional[date]
    is_current: bool
    hash_value: str  # For change detection


class SCDType2Manager:
    """
    Manages SCD Type 2 dimension updates.
    
    Handles:
    - New records (INSERT)
    - Changed records (close old, open new)
    - Unchanged records (no action)
    - Deleted records (close, don't delete)
    """
    
    def __init__(self, tracked_columns: List[str]):
        """
        Args:
            tracked_columns: Columns that trigger new version on change
        """
        self.tracked_columns = tracked_columns
    
    def compute_hash(self, attributes: Dict[str, Any]) -> str:
        """
        Compute hash of tracked columns for change detection.
        """
        values = [str(attributes.get(col, "")) for col in self.tracked_columns]
        combined = "|".join(values)
        return hashlib.md5(combined.encode()).hexdigest()
    
    def process_updates(
        self,
        current_records: List[DimensionRecord],
        incoming_records: List[Dict[str, Any]],
        effective_date: date
    ) -> Dict[str, List]:
        """
        Process dimension updates using SCD Type 2 logic.
        
        Returns dict with:
        - 'inserts': New records to insert
        - 'updates': Existing records to update (close)
        - 'unchanged': Records that don't need changes
        """
        
        # Build lookup of current records by business key
        current_by_key = {
            r.business_key: r for r in current_records if r.is_current
        }
        
        inserts = []
        updates = []
        unchanged = []
        
        for incoming in incoming_records:
            business_key = incoming["business_key"]
            new_hash = self.compute_hash(incoming)
            
            if business_key not in current_by_key:
                # New record - INSERT
                inserts.append({
                    **incoming,
                    "valid_from": effective_date,
                    "valid_to": None,
                    "is_current": True,
                    "hash_value": new_hash
                })
            
            else:
                current = current_by_key[business_key]
                
                if current.hash_value != new_hash:
                    # Changed - close old, insert new
                    updates.append({
                        "surrogate_key": current.surrogate_key,
                        "valid_to": effective_date,
                        "is_current": False
                    })
                    
                    inserts.append({
                        **incoming,
                        "valid_from": effective_date,
                        "valid_to": None,
                        "is_current": True,
                        "hash_value": new_hash
                    })
                
                else:
                    # Unchanged
                    unchanged.append(current.surrogate_key)
        
        return {
            "inserts": inserts,
            "updates": updates,
            "unchanged": unchanged
        }


# =============================================================================
# SQL for SCD Type 2 Merge
# =============================================================================

SCD_TYPE2_MERGE_SQL = """
-- SCD Type 2 with plain UPDATE + INSERT steps (works in most modern databases)

-- Step 1: Close changed records
UPDATE dim_customer
SET valid_to = :effective_date,
    is_current = FALSE
WHERE is_current = TRUE
  AND customer_id IN (
    SELECT c.customer_id
    FROM dim_customer c
    JOIN staging_customer s ON c.customer_id = s.customer_id
    WHERE c.is_current = TRUE
      AND c.hash_value != s.hash_value
);

-- Step 2: Insert new versions of changed records
INSERT INTO dim_customer (
    customer_id, name, segment, tier, country,
    valid_from, valid_to, is_current, hash_value
)
SELECT 
    s.customer_id, s.name, s.segment, s.tier, s.country,
    :effective_date, NULL, TRUE, s.hash_value
FROM staging_customer s
JOIN dim_customer c ON s.customer_id = c.customer_id
WHERE c.valid_to = :effective_date;  -- Just closed

-- Step 3: Insert completely new records
INSERT INTO dim_customer (
    customer_id, name, segment, tier, country,
    valid_from, valid_to, is_current, hash_value
)
SELECT 
    s.customer_id, s.name, s.segment, s.tier, s.country,
    :effective_date, NULL, TRUE, s.hash_value
FROM staging_customer s
WHERE NOT EXISTS (
    SELECT 1 FROM dim_customer c 
    WHERE c.customer_id = s.customer_id
);
"""


# =============================================================================
# Querying SCD Type 2 Dimensions
# =============================================================================

SCD_TYPE2_QUERIES = """
-- Query 1: Get current customer data
SELECT * 
FROM dim_customer 
WHERE is_current = TRUE;

-- Query 2: Get customer data as of a specific date
SELECT * 
FROM dim_customer 
WHERE valid_from <= '2024-06-15' 
  AND (valid_to IS NULL OR valid_to > '2024-06-15');

-- Query 3: Revenue by segment with point-in-time accuracy
-- Each sale joins to the customer record that was valid at sale time
SELECT 
    c.segment,
    SUM(f.net_revenue) as revenue
FROM fact_sales f
JOIN dim_customer c ON f.customer_key = c.customer_key
    -- The fact table stores the surrogate key that was valid at transaction time
GROUP BY c.segment;

-- Query 4: Customer history
SELECT 
    customer_id,
    segment,
    tier,
    valid_from,
    valid_to
FROM dim_customer
WHERE customer_id = 'cust_123'
ORDER BY valid_from;
"""
Quick check3 questions · +5 XP each

Slowly changing dimensions

  1. Q1/3 John moved from Standard to Premium in June. Finance asks for "Q1 revenue by segment", and his Q1 purchases show up as Premium. Which SCD handling produced this?

    Answer & explanation

    Answer: D

    Type 1 overwrites in place, so every historical sale now sees today's value, "Premium". Type 2 would keep the Standard row valid for Q1 and attribute those purchases correctly.

  2. Q2/3 With SCD Type 2, how does "revenue by segment" get point-in-time accuracy without any date-range condition in the join?

    Answer & explanation

    Answer: A

    The fact row was loaded with the customer_key of the version valid at sale time, so f.customer_key = c.customer_key is already point-in-time. Filtering on is_current would attribute every sale to the current segment, which brings back the Type 1 problem.

  3. Q3/3 SCDType2Manager hashes only the tracked columns (e.g. name, category, price). A product's supplier changes, but supplier is not tracked. What happens on the next load?

    Answer & explanation

    Answer: C

    Change detection only compares hashes of the tracked columns. A change to an untracked column produces the same hash and lands in "unchanged". The tracked_columns list therefore decides which changes get history.

Chapter 4: Columnar Storage#

4.1 Row vs Column Storage#

Row-oriented vs column-oriented storage

Row-oriented storage (OLTP)

How data is stored on disk:

text3 lines
Row 1: [id=1, name="John", country="USA", revenue=100]
Row 2: [id=2, name="Jane", country="UK",  revenue=200]
Row 3: [id=3, name="Bob",  country="USA", revenue=150]

Query: SELECT SUM(revenue) FROM customers

Process:

  1. Read Row 1 (all columns) → extract revenue
  2. Read Row 2 (all columns) → extract revenue
  3. Read Row 3 (all columns) → extract revenue
  4. Sum: 100 + 200 + 150 = 450

Problem: We read name, country too (wasted I/O!)

Column-oriented storage (OLAP)

How data is stored on disk:

text4 lines
id column:      [1, 2, 3]
name column:    ["John", "Jane", "Bob"]
country column: ["USA", "UK", "USA"]
revenue column: [100, 200, 150]

Query: SELECT SUM(revenue) FROM customers

Process:

  1. Read only revenue column: [100, 200, 150]
  2. Sum: 450

Benefits:

  • 75% less I/O (read 1 column, not 4)
  • Better compression (similar values together)
  • SIMD-friendly (vectorized operations)
  • Cache-efficient (sequential access)

4.2 Columnar Formats#

Popular columnar formats

PARQUET (Apache)

  • Row groups (default 128MB)
  • Column chunks within row groups
  • Multiple compression codecs (Snappy, GZIP, LZ4)
  • Rich type system (nested types)
  • Predicate pushdown (skip irrelevant row groups)
  • Industry standard for data lakes

ORC (Apache)

  • Stripes (default 64MB)
  • Lightweight indexes for skip
  • Better for Hive/Presto
  • Slightly better compression than Parquet
  • More mature ACID support

DELTA LAKE (Databricks)

  • Parquet files + transaction log
  • ACID transactions on data lake
  • Time travel (query historical versions)
  • Schema evolution
  • Merge/Update/Delete support

ICEBERG (Apache)

  • Open table format
  • Hidden partitioning
  • Schema evolution
  • Time travel
  • Supported by many engines (Spark, Flink, Trino)

4.3 Parquet Optimization#

pythonParquetWriter · 79 lines
# modeling/parquet_optimization.py

"""
Parquet file optimization for analytics.

Key factors:
1. Row group size
2. Compression codec
3. Column ordering
4. Statistics collection
"""

from typing import List, Dict, Any
import pyarrow as pa
import pyarrow.parquet as pq


class ParquetWriter:
    """
    Optimized Parquet writer for analytics workloads.
    """
    
    def __init__(
        self,
        row_group_size: int = 1_000_000,  # ROWS: pyarrow counts rows, not bytes
        compression: str = "snappy",
        enable_statistics: bool = True
    ):
        self.row_group_size = row_group_size
        self.compression = compression
        self.enable_statistics = enable_statistics
    
    def write_table(
        self,
        table: pa.Table,
        output_path: str,
        partition_cols: List[str] = None
    ):
        """
        Write table to Parquet with optimizations.
        """
        
        # Sort by partition columns for better compression
        if partition_cols:
            sort_keys = [(col, "ascending") for col in partition_cols]
            table = table.sort_by(sort_keys)
        
        pq.write_table(
            table,
            output_path,
            row_group_size=self.row_group_size,
            compression=self.compression,
            write_statistics=self.enable_statistics,
            # Use dictionary encoding for low-cardinality columns
            use_dictionary=True,
            # Write column indexes for predicate pushdown
            write_page_index=True
        )
    
    def write_partitioned(
        self,
        table: pa.Table,
        output_path: str,
        partition_cols: List[str]
    ):
        """
        Write partitioned Parquet dataset.
        
        Partitioning enables partition pruning:
        WHERE date = '2024-01-15' reads only that partition.
        """
        
        pq.write_to_dataset(
            table,
            output_path,
            partition_cols=partition_cols,
            compression=self.compression,
            row_group_size=self.row_group_size
        )
Parquet optimization checklist
  1. Row group size
    • Default: 128MB
    • Smaller (64MB): More parallelism, more overhead
    • Larger (256MB): Better compression, less parallelism
    • Match to executor memory / parallelism
  2. Compression
    • Snappy: Fast, moderate compression (pyarrow default)
    • LZ4: Faster, less compression
    • GZIP/ZSTD: Slower, better compression
    • Choose based on I/O vs CPU trade-off
  3. Column ordering
    • Put frequently filtered columns first
    • Group related columns together
    • Low-cardinality columns benefit from dictionary encoding
  4. File size
    • Target: 256MB - 1GB per file
    • Too small: Excessive metadata overhead
    • Too large: Less parallelism
    • Coalesce small files periodically
  5. Partitioning
    • Partition by query filter columns (date, region)
    • Avoid over-partitioning (>10K partitions)
    • Balance partition size (aim for roughly 100MB-1GB each; a partition can hold several files)
    • Use Z-ordering for multi-column filters

Chapter 5: Partitioning and Pre-Aggregation#

Your turn · checkpoint 4not answered

How many partitions, and is that too many?

In this lesson's example, fact_sales holds three years of sales in 1 billion rows. (Day 5 works a larger retailer with about 11 billion.) A query asks for one day. How many rows does it scan with no partitioning, and with one partition per day? How many partitions do three years make, and what if you partitioned by customer_id instead, with 10 million customers?

Think it through, or jot it down. Then compare.

One strong answer

  • No partitioning: all 1 billion rows, for one day of answers.
  • Daily partitions: 3 × 365 ≈ 1,095 partitions of ~913,000 rows each, so the scan is about 1,000× smaller.
  • 1,095 is fine: the lesson's warning line is over 10K partitions.
  • By customer_id: 10 million tiny partitions, with huge metadata and small files, and every date-range query has to open all of them.

5.1 Partitioning Strategies#

Partitioning for analytics

Goal: Let queries skip irrelevant data

Partition by date (most common)
  • fact_sales/

    • date=2024-01-01/
      • part-00000.parquet
      • part-00001.parquet
    • date=2024-01-02/
      • part-00000.parquet
    • date=2024-01-03/
      • ...
    • ...
  • Query: WHERE date = '2024-01-15'

  • Effect: Reads only 1 partition, skips 364 others

Multi-level partitioning
  • fact_sales/

    • year=2024/
      • month=01/
        • day=01/
        • day=02/
        • ...
      • month=02/
      • ...
    • year=2023/
      • ...
  • Query: WHERE year = 2024 AND month = 1

  • Effect: Reads only January 2024 (~31 partitions)

Partition + sort (Z-Ordering)

For multiple filter columns (date AND product_id): physical layout uses space-filling curves to cluster related values together.

  • Query: WHERE date = '2024-01-15' AND product_id = 'P123'
  • Effect: Minimal file reads due to co-location

5.2 Pre-Aggregation (Rollups)#

pythonRollupDefinition · 118 lines
# modeling/pre_aggregation.py

"""
Pre-aggregation (Rollups) for fast queries.

Instead of aggregating at query time:
  SELECT date, SUM(revenue) FROM fact_sales GROUP BY date
  → Scans 1B rows

Query from pre-aggregated table:
  SELECT date, revenue FROM daily_revenue
  → Scans 365 rows
"""

from dataclasses import dataclass
from typing import List, Dict, Any
from datetime import date


@dataclass
class RollupDefinition:
    """Defines a pre-aggregation rollup."""
    name: str
    source_table: str
    target_table: str
    dimensions: List[str]  # GROUP BY columns
    measures: Dict[str, str]  # column: aggregation
    refresh_schedule: str  # Cron expression


# Define common rollups
ROLLUPS = [
    RollupDefinition(
        name="daily_revenue_by_category",
        source_table="fact_sales",
        target_table="agg_daily_category",
        dimensions=["date_key", "category"],
        measures={
            "net_revenue": "SUM",
            "quantity": "SUM",
            "order_count": "COUNT DISTINCT order_id",
            "customer_count": "COUNT DISTINCT customer_key"
        },
        refresh_schedule="0 1 * * *"  # Daily at 1 AM
    ),
    
    RollupDefinition(
        name="hourly_revenue_by_store",
        source_table="fact_sales",
        target_table="agg_hourly_store",
        dimensions=["date_key", "hour", "store_key"],
        measures={
            "net_revenue": "SUM",
            "quantity": "SUM",
            "order_count": "COUNT DISTINCT order_id"
        },
        refresh_schedule="0 * * * *"  # Hourly
    ),
    
    RollupDefinition(
        name="monthly_customer_metrics",
        source_table="fact_sales",
        target_table="agg_monthly_customer",
        dimensions=["year", "month", "customer_key", "segment"],
        measures={
            "net_revenue": "SUM",
            "order_count": "COUNT DISTINCT order_id",
            "avg_order_value": "SUM(net_revenue) / COUNT(DISTINCT order_id)"
        },
        refresh_schedule="0 2 1 * *"  # Monthly on 1st at 2 AM
    )
]


ROLLUP_DDL = """
-- Pre-aggregated table for daily revenue by category
CREATE TABLE agg_daily_category (
    date_key        INTEGER NOT NULL,
    category        VARCHAR(100) NOT NULL,
    net_revenue     DECIMAL(15,2) NOT NULL,
    quantity        BIGINT NOT NULL,
    order_count     BIGINT NOT NULL,
    customer_count  BIGINT NOT NULL,
    last_updated    TIMESTAMP NOT NULL DEFAULT NOW(),
    PRIMARY KEY (date_key, category)
);

-- Refresh query
INSERT INTO agg_daily_category
SELECT 
    f.date_key,
    p.category,
    SUM(f.net_revenue) as net_revenue,
    SUM(f.quantity) as quantity,
    COUNT(DISTINCT f.order_id) as order_count,
    COUNT(DISTINCT f.customer_key) as customer_count,
    NOW() as last_updated
FROM fact_sales f
JOIN dim_product p ON f.product_key = p.product_key
WHERE f.date_key = :target_date_key
GROUP BY f.date_key, p.category
ON CONFLICT (date_key, category) 
DO UPDATE SET
    net_revenue = EXCLUDED.net_revenue,
    quantity = EXCLUDED.quantity,
    order_count = EXCLUDED.order_count,
    customer_count = EXCLUDED.customer_count,
    last_updated = NOW();


-- Query from rollup (fast!)
SELECT category, SUM(net_revenue)
FROM agg_daily_category
WHERE date_key BETWEEN 20240101 AND 20241231
GROUP BY category;
-- Scans 365 * ~100 categories = 36,500 rows
-- vs 1B rows in fact_sales
"""

Rollup cube pattern. For flexible drill-down, pre-compute multiple granularities:

Aggregation hierarchy
  • Total — level 0
    • By Year — level 1
      • By Month
        • By Day
        • By Week
      • By Quarter
    • By Year × Category — cross
    • By Month × Category × Region — cross
    • By Day × Product — cross

Pre-compute common combinations:

  • Time only: Year, Quarter, Month, Week, Day
  • Time × Category: For product analysis
  • Time × Region: For geographic analysis
  • Time × Customer Segment: For marketing
  • CUBE: All combinations (expensive but complete)
WorksheetWhat a one-day revenue query reads, layer by layer (row sizes assumed)
Value
fact_sales rows (three years, from Mistake 1) 1 billion → ~913,000 a day
Row size in a row store, all columns (assumed) 200 bytes → ~200 GB per full scan
Columns the query needs: date_key (4 B) + net_revenue (8 B) 12 bytes a row → ~12 GB per full scan, before compression
Plus daily partitions: one day of those two columns 913,000 × 12 B ≈ ~11 MB
agg_daily_category, ~100 categories 3 × 365 × 100 ≈ 110,000 rows in all; 100 rows for one day

Conclusion: columnar storage cuts the bytes about 16×, partitioning about 1,000× more, and the rollup turns the query into reading 100 rows. Each layer removes a different kind of waste, so a fast dashboard usually needs all three.

5.3 Materialized Views#

pythonMaterializedViewManager · 156 lines
# modeling/materialized_views.py

"""
Materialized Views for automatic pre-aggregation.

Better than manual rollups because:
1. Automatic refresh (BigQuery, Snowflake; PostgreSQL needs REFRESH)
2. Query optimizer can use them transparently (BigQuery, Snowflake;
   not PostgreSQL)
3. Built-in freshness tracking
"""

from datetime import datetime, timezone
from typing import Dict, List

MATERIALIZED_VIEW_PATTERNS = """
-- =============================================================================
-- Pattern 1: Simple aggregation view
-- =============================================================================

CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT 
    date_key,
    SUM(net_revenue) as revenue,
    COUNT(*) as transaction_count
FROM fact_sales
GROUP BY date_key
WITH DATA;

-- Refresh options
REFRESH MATERIALIZED VIEW mv_daily_revenue;  -- Full refresh, blocks reads
-- CONCURRENTLY doesn't block readers but needs a UNIQUE index
CREATE UNIQUE INDEX ON mv_daily_revenue (date_key);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;


-- =============================================================================
-- Pattern 2: Join materialized into view
-- =============================================================================

CREATE MATERIALIZED VIEW mv_product_performance AS
SELECT 
    p.category,
    p.subcategory,
    p.brand,
    d.year,
    d.month,
    SUM(f.net_revenue) as revenue,
    SUM(f.quantity) as units,
    SUM(f.profit) as profit
FROM fact_sales f
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_date d ON f.date_key = d.date_key
GROUP BY p.category, p.subcategory, p.brand, d.year, d.month
WITH DATA;

-- Index for fast queries
CREATE INDEX idx_mv_product_year 
ON mv_product_performance(year, month);


-- =============================================================================
-- Pattern 3: Incremental materialized view (database-specific)
-- =============================================================================

-- In BigQuery: 
CREATE MATERIALIZED VIEW mv_hourly_revenue
OPTIONS (
    enable_refresh = true,
    refresh_interval_minutes = 60
)
AS
SELECT 
    TIMESTAMP_TRUNC(order_timestamp, HOUR) as hour,
    SUM(net_revenue) as revenue
FROM fact_sales
GROUP BY 1;

-- BigQuery automatically updates incrementally

-- In Snowflake:
CREATE MATERIALIZED VIEW mv_hourly_revenue
AS
SELECT 
    DATE_TRUNC('HOUR', order_timestamp) as hour,
    SUM(net_revenue) as revenue
FROM fact_sales
GROUP BY 1;

-- Snowflake maintains incrementally if possible
"""


class MaterializedViewManager:
    """
    Manages materialized view lifecycle.
    """
    
    def __init__(self, db_connection):
        self.db = db_connection
        self.views: Dict[str, Dict] = {}
    
    def create_view(
        self,
        name: str,
        query: str,
        refresh_schedule: str = None,
        indexes: List[str] = None
    ):
        """Create a materialized view."""
        
        sql = f"CREATE MATERIALIZED VIEW {name} AS {query} WITH DATA"
        self.db.execute(sql)
        
        if indexes:
            for idx_cols in indexes:
                idx_name = f"idx_{name}_{idx_cols.replace(', ', '_')}"
                self.db.execute(
                    f"CREATE INDEX {idx_name} ON {name}({idx_cols})"
                )
        
        self.views[name] = {
            "query": query,
            "refresh_schedule": refresh_schedule,
            "last_refresh": None
        }
    
    def refresh_view(self, name: str, concurrent: bool = True):
        """Refresh a materialized view."""
        
        if concurrent:
            sql = f"REFRESH MATERIALIZED VIEW CONCURRENTLY {name}"
        else:
            sql = f"REFRESH MATERIALIZED VIEW {name}"
        
        self.db.execute(sql)
        self.views[name]["last_refresh"] = datetime.now(timezone.utc)
    
    def get_freshness(self, name: str) -> Dict:
        """Get view freshness information."""
        
        view = self.views.get(name)
        if not view:
            return {"error": "View not found"}
        
        last_refresh = view.get("last_refresh")
        if not last_refresh:
            return {"status": "never_refreshed"}
        
        age = datetime.now(timezone.utc) - last_refresh
        
        return {
            "last_refresh": last_refresh.isoformat(),
            "age_seconds": age.total_seconds(),
            "status": "fresh" if age.total_seconds() < 3600 else "stale"
        }
Quick check3 questions · +5 XP each

Columnar storage, partitioning and rollups

  1. Q1/3 A table with 4 columns (id, name, country, revenue) holds a billion rows. Why is SELECT SUM(revenue) much cheaper in Parquet than in a row store?

    Answer & explanation

    Answer: B

    A columnar store reads only the columns the query touches. Here that is 1 of 4, about 75% less I/O. Values of one type stored together also compress better. Row-group statistics let the engine skip data (predicate pushdown). They are not pre-computed totals.

  2. Q2/3 A yearly dashboard moves from scanning fact_sales (~1B rows) to the agg_daily_category rollup, which has ~100 categories. Roughly how many rows does "revenue by category for 2024" scan now?

    Answer & explanation

    Answer: D

    The rollup has one row per day per category: 365 × ~100 = 36,500 rows, compared with a billion in the fact table. About 1,200 would be a monthly rollup (12 × 100). The daily grain keeps day-level drill-down possible.

  3. Q3/3 Most queries filter on both date and product_id. Which layout does the lesson recommend?

    Answer & explanation

    Answer: A

    Date partitioning prunes whole days, and Z-ordering clusters related product_id values inside each day, so few files get read. Partitioning on a high-cardinality key like product_id creates huge numbers of small partitions. That is the over-partitioning trap.

Part II · Stage 2 of 4 · 6 min

Part II: Implementation#

Chapter 6: Building the Data Model#

6.1 ETL Pipeline for Star Schema#

pythonETLConfig · 311 lines
# modeling/etl_pipeline.py

"""
ETL pipeline to build star schema from source data.

Flow:
1. Extract from source systems
2. Transform into dimension and fact tables
3. Load into data warehouse
"""

from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Dict, Any, List, Optional
import logging

logger = logging.getLogger(__name__)


@dataclass
class ETLConfig:
    """ETL job configuration."""
    source_db_url: str
    target_db_url: str
    batch_date: date
    full_refresh: bool = False


class DimensionLoader:
    """
    Loads dimension tables with SCD Type 2 handling.
    """
    
    def __init__(self, target_db, tracked_columns: Dict[str, List[str]]):
        self.db = target_db
        self.tracked_columns = tracked_columns
    
    def load_product_dimension(self, products_df, batch_date: date):
        """
        Load product dimension with Type 2 SCD.
        """
        
        logger.info(f"Loading product dimension for {batch_date}")
        
        # Get current records
        current = self.db.read_table("dim_product", "is_current = TRUE")
        
        # Compute changes
        scd_manager = SCDType2Manager(
            self.tracked_columns.get("product", ["name", "category", "price"])
        )
        
        changes = scd_manager.process_updates(
            current_records=current,
            incoming_records=products_df.to_records(),
            effective_date=batch_date
        )
        
        # Apply changes
        if changes["updates"]:
            self.db.update_batch("dim_product", changes["updates"])
            logger.info(f"Closed {len(changes['updates'])} changed products")
        
        if changes["inserts"]:
            self.db.insert_batch("dim_product", changes["inserts"])
            logger.info(f"Inserted {len(changes['inserts'])} product versions")
        
        return {
            "inserted": len(changes["inserts"]),
            "updated": len(changes["updates"]),
            "unchanged": len(changes["unchanged"])
        }
    
    def load_customer_dimension(self, customers_df, batch_date: date):
        """Load customer dimension."""
        # Similar pattern to product
        pass
    
    def load_date_dimension(self, start_date: date, end_date: date):
        """
        Generate and load date dimension.
        
        Usually done once, extended as needed.
        """
        
        dates = []
        current = start_date
        
        while current <= end_date:
            dates.append({
                "date_key": int(current.strftime("%Y%m%d")),
                "date": current,
                "day": current.day,
                "month": current.month,
                "month_name": current.strftime("%B"),
                "quarter": (current.month - 1) // 3 + 1,
                "year": current.year,
                "day_of_week": current.weekday(),
                "day_name": current.strftime("%A"),
                "week_of_year": current.isocalendar()[1],
                "is_weekend": current.weekday() >= 5,
                "is_holiday": False,  # Add holiday logic
                "fiscal_year": current.year,  # Adjust for fiscal calendar
                "fiscal_quarter": (current.month - 1) // 3 + 1
            })
            current = current + timedelta(days=1)
        
        self.db.upsert_batch("dim_date", dates, key_columns=["date_key"])
        
        return {"dates_loaded": len(dates)}


class FactLoader:
    """
    Loads fact tables from source transactions.
    """
    
    def __init__(self, target_db):
        self.db = target_db
    
    def load_sales_fact(
        self,
        orders_df,
        batch_date: date,
        dimension_lookups: Dict
    ):
        """
        Load sales fact table.
        
        Steps:
        1. Join with dimensions to get surrogate keys
        2. Calculate derived measures
        3. Insert into fact table
        """
        
        logger.info(f"Loading sales fact for {batch_date}")
        
        facts = []
        
        for order in orders_df.to_records():
            # Look up dimension keys
            date_key = int(order["order_date"].strftime("%Y%m%d"))
            
            product_key = dimension_lookups["product"].get(
                order["product_id"], self._get_unknown_key("product")
            )
            
            customer_key = dimension_lookups["customer"].get(
                order["customer_id"], self._get_unknown_key("customer")
            )
            
            store_key = dimension_lookups["store"].get(
                order["store_id"], self._get_unknown_key("store")
            )
            
            # Calculate measures
            gross_revenue = order["quantity"] * order["unit_price"]
            net_revenue = gross_revenue - order.get("discount", 0)
            cost = order["quantity"] * order.get("unit_cost", 0)
            profit = net_revenue - cost
            
            facts.append({
                "date_key": date_key,
                "product_key": product_key,
                "customer_key": customer_key,
                "store_key": store_key,
                "order_id": order["order_id"],
                "line_item_number": order.get("line_number", 1),
                "quantity": order["quantity"],
                "unit_price": order["unit_price"],
                "discount_amount": order.get("discount", 0),
                "tax_amount": order.get("tax", 0),
                "gross_revenue": gross_revenue,
                "net_revenue": net_revenue,
                "profit": profit,
                "order_timestamp": order["order_timestamp"]
            })
        
        # Batch insert
        self.db.insert_batch("fact_sales", facts)
        
        logger.info(f"Loaded {len(facts)} sales facts")
        
        return {"facts_loaded": len(facts)}
    
    def _get_unknown_key(self, dimension: str) -> int:
        """Get the 'unknown' dimension key for missing lookups."""
        # Convention: -1 or a specific unknown record
        return -1
    
    def build_dimension_lookups(self, batch_date: date) -> Dict:
        """
        Build lookup dictionaries for dimension foreign keys.
        
        Returns mapping from business key → surrogate key
        for each dimension, using the record valid on batch_date.
        """
        
        lookups = {}
        
        # Version valid on batch_date (not is_current: a backfill of an
        # old date must pick the version that was valid back then)
        valid_on = """
            valid_from <= :batch_date
            AND (valid_to IS NULL OR valid_to > :batch_date)
        """
        
        # Product lookup
        products = self.db.query(f"""
            SELECT product_id, product_key 
            FROM dim_product 
            WHERE {valid_on}
        """, {"batch_date": batch_date})
        lookups["product"] = {
            r["product_id"]: r["product_key"] for r in products
        }
        
        # Customer lookup
        customers = self.db.query(f"""
            SELECT customer_id, customer_key 
            FROM dim_customer 
            WHERE {valid_on}
        """, {"batch_date": batch_date})
        lookups["customer"] = {
            r["customer_id"]: r["customer_key"] for r in customers
        }
        
        # Store lookup
        stores = self.db.query("SELECT store_id, store_key FROM dim_store")
        lookups["store"] = {
            r["store_id"]: r["store_key"] for r in stores
        }
        
        return lookups


class ETLOrchestrator:
    """
    Orchestrates the full ETL pipeline.
    """
    
    def __init__(
        self,
        source_db,
        target_db,
        dim_loader: DimensionLoader,
        fact_loader: FactLoader
    ):
        self.source = source_db
        self.target = target_db
        self.dimensions = dim_loader
        self.facts = fact_loader
    
    def run_daily_etl(self, batch_date: date) -> Dict:
        """
        Run complete daily ETL pipeline.
        """
        
        results = {"batch_date": batch_date.isoformat()}
        
        try:
            # Step 1: Load dimensions (order matters for lookups)
            logger.info("Loading dimensions...")
            
            products = self.source.extract_products(batch_date)
            results["products"] = self.dimensions.load_product_dimension(
                products, batch_date
            )
            
            customers = self.source.extract_customers(batch_date)
            results["customers"] = self.dimensions.load_customer_dimension(
                customers, batch_date
            )
            
            # Step 2: Build lookups
            logger.info("Building dimension lookups...")
            lookups = self.facts.build_dimension_lookups(batch_date)
            
            # Step 3: Load facts
            logger.info("Loading facts...")
            orders = self.source.extract_orders(batch_date)
            results["facts"] = self.facts.load_sales_fact(
                orders, batch_date, lookups
            )
            
            # Step 4: Update aggregations
            logger.info("Updating rollups...")
            results["rollups"] = self._refresh_rollups(batch_date)
            
            results["status"] = "success"
            
        except Exception as e:
            logger.error(f"ETL failed: {e}", exc_info=True)
            results["status"] = "failed"
            results["error"] = str(e)
        
        return results
    
    def _refresh_rollups(self, batch_date: date) -> Dict:
        """Refresh materialized views / rollup tables."""
        
        rollups_refreshed = []
        
        for rollup in ROLLUPS:
            self.target.execute(
                rollup.refresh_query,
                {"date_key": int(batch_date.strftime("%Y%m%d"))}
            )
            rollups_refreshed.append(rollup.name)
        
        return {"refreshed": rollups_refreshed}
Quick check2 questions · +5 XP each

Building the data model

  1. Q1/2 run_daily_etl loads dimensions before facts. What goes wrong if you reverse the order?

    Answer & explanation

    Answer: C

    The fact load turns business keys into surrogate keys using lookups built from the dimensions. If facts load first, any product or customer version created today is not in the lookup yet, so those rows fall back to the unknown key (-1).

  2. Q2/2 An order arrives whose product_id is not in dim_product. What does FactLoader do, and why?

    Answer & explanation

    Answer: B

    _get_unknown_key returns -1, a conventional "unknown" member. Revenue totals stay complete and the row still joins to something. Dropping the row would quietly understate revenue.

Part III · Stage 3 of 4 · 3 min

Part III: Real-World Application#

Chapter 7: Case Studies#

7.1 A Marketplace Data Model (Airbnb-style, illustrative)#

A dimensional model for a home-sharing marketplace
Fact tables Grain
fact_bookings one row per booking
fact_searches one row per search
fact_reviews one row per review
fact_messages one row per message
Dimension tables Contents
dim_listing property details, host info
dim_guest guest profile, verification
dim_host host profile, superhost status
dim_location city, neighborhood, market
dim_date calendar, holidays, events
dim_time hour, minute, day part
Key design decisions
  1. Separate host and guest dimensions — Same person can be both; Different attributes matter
  2. Location dimension with hierarchy — Country → State → City → Neighborhood; Enables drill-down in reports
  3. Date + Time dimensions — Date for daily aggregates; Time for booking time analysis
  4. Slowly changing host/guest dimensions — Track superhost status changes; Analyze impact of status on bookings

7.2 Uber's Metrics Layer#

Uber's metrics layer (uMetric), simplified
Challenge
  • 10,000+ metrics (bookings, revenue, driver hours, etc.; Uber's uMetric post, 2021)
  • Multiple dimensions (city, product, time)
  • Consistency across dashboards and reports
  • Self-service for analysts
Solution: Metrics layer

Metric definitions (illustrative):

  • gross_bookings = SUM(fare_amount)
  • net_revenue = gross_bookings - promotions - refunds
  • trips = COUNT(DISTINCT trip_id)
  • driver_hours = SUM(online_hours)

Dimensions:

  • time: hour, day, week, month, quarter, year
  • geography: country, city, zone
  • product: UberX, UberXL, UberBlack
  • user_type: new, returning, power_user

Pre-computed cubes (illustrative):

  • hourly × city × product
  • daily × city × product × user_type
  • weekly × country × product
Benefits
  • Single source of truth for each metric
  • Consistent calculations everywhere
  • Pre-computed for fast queries
  • Self-service via metrics catalog

Chapter 8: Common Mistakes#

Your turn · checkpoint 5not answered

Anonymous buyers and order-level rows

Guest checkouts arrive with customer_id NULL, and the fact table was built with one row per order (order_id, total_amount, item_count). Product managers ask for revenue by category, including guest sales. What breaks?

Think it through, or jot it down. Then compare.

One strong answer

  • NULL keys: inner joins to dim_customer drop guest sales, so revenue is understated, and GROUP BY puts them in an unnamed bucket. Map NULLs to an "unknown" member such as key -1.
  • Order grain: an order row no longer records which products were bought, so revenue can't be split by category at all.
  • The fix: a line-item grain, which can always roll up to orders. You can never drill below the grain you stored.

8.1 Modeling Mistakes#

Data modeling anti-patterns
Mistake 1: Over-Normalization

Wrong:

sql7 lines
-- 4 JOINs to get product category
SELECT category_name
FROM products p
JOIN product_types pt ON p.type_id = pt.id
JOIN subcategories sc ON pt.subcategory_id = sc.id
JOIN categories c ON sc.category_id = c.id
JOIN category_groups cg ON c.group_id = cg.id

Problem: Slow queries, complex ETL

Right:

sql4 lines
-- Denormalized in dimension
SELECT category
FROM dim_product
WHERE product_id = '123'
Mistake 2: Wrong Grain

Wrong:

sql4 lines
-- Fact table at order level
fact_orders: order_id, total_amount, item_count

-- Can't analyze by product!

Problem: Can't drill down to line items

Right:

sql4 lines
-- Fact table at line item level
fact_order_lines: order_id, line_id, product_id, quantity, amount

-- Can aggregate up to orders when needed
Mistake 3: Missing Surrogate Keys

Wrong:

sql5 lines
-- Using business keys directly
fact_sales:
  customer_id = "CUST123"  -- Business key

-- If customer changes, what customer_id was valid at sale time?

Problem: Can't track dimension history

Right:

sql3 lines
-- Using surrogate keys
fact_sales:
  customer_key = 42  -- Links to version valid at sale time
Mistake 4: Ignoring NULL Handling

Wrong:

sql3 lines
-- NULLs in dimensions
dim_customer:
  customer_id = NULL  -- Anonymous purchases

Problem: JOINs may lose rows, GROUP BY issues

Right:

sql5 lines
-- Unknown row for NULLs
dim_customer:
  customer_key = -1, customer_id = "UNKNOWN", name = "Unknown"

-- NULL business keys map to unknown surrogate key

8.2 Performance Mistakes#

Performance anti-patterns
Mistake 1: No Partitioning

Wrong:

sql3 lines
-- 3 years of data in one table
SELECT SUM(revenue) FROM fact_sales WHERE date = '2024-01-15'
-- Scans 1 billion rows!

Right:

text3 lines
-- Partitioned by date
fact_sales/date=2024-01-15/
-- Scans only that partition
Mistake 2: Over-Partitioning

Wrong:

text4 lines
-- Partition by customer_id
fact_sales/customer_id=C1/
fact_sales/customer_id=C2/
-- 10 million partitions!

Problem: Metadata overhead, small files

Right:

text3 lines
-- Partition by date, maybe region
fact_sales/date=2024-01-15/region=US/
-- Reasonable partition count
Mistake 3: No Rollups for Common Queries

Wrong:

sql3 lines
-- Every dashboard query scans fact table
SELECT date, SUM(revenue) FROM fact_sales GROUP BY date
-- 60 seconds every time

Right:

sql3 lines
-- Pre-aggregated daily rollup
SELECT date, revenue FROM agg_daily_revenue
-- 50 milliseconds
Quick check3 questions · +5 XP each

Real-world models and common mistakes

  1. Q1/3 Your fact table is fact_orders(order_id, total_amount, item_count). Product managers now want revenue by product category. What is the root problem?

    Answer & explanation

    Answer: D

    Grain is foundational. Order-level rows no longer record which products were bought. A line-item grain can always roll up to orders, but you can never drill below the grain you stored.

  2. Q2/3 An engineer proposes partitioning fact_sales by customer_id so per-customer queries are fast. With 10 million customers, what is the likely outcome?

    Answer & explanation

    Answer: A

    Partitioning on a high-cardinality key over-partitions the table: huge metadata and many small files. A date-range query would also have to open every customer partition. Partition by common filter columns such as date (maybe region) instead.

  3. Q3/3 Two dashboards show different "net revenue" for the same city and week. What does Uber's metrics layer do to prevent this?

    Answer & explanation

    Answer: C

    A metrics layer is a single source of truth. net_revenue = gross_bookings - promotions - refunds is written once and used everywhere. Mismatches like this usually come from teams writing their own formulas, not from stale data.

Part IV · Stage 4 of 4 · 4 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips#

9.1 When to Discuss Data Modeling#

Data modeling interview triggers
Interview prompt Bring up
"Design an analytics system" Star schema for the data warehouse · Discuss dimensions and facts
"How would you make this dashboard fast?" Partitioning and pre-aggregation · Materialized views
"Users report the numbers don't match" SCD Type 2 for dimension history · Point-in-time accuracy
"Design a metrics platform" Metrics layer architecture · Rollup strategy
❯ Say this

9.2 Key Phrases#

Talking about data modeling
  • "I'd use a star schema with the sales fact table at the line-item grain. This gives us flexibility to aggregate to any level — orders, customers, products, time periods."
  • "For the product dimension, I'd implement SCD Type 2 to track price changes. When we report historical revenue, we want the price that was actually charged, not today's price."
  • "To make the dashboard fast, I'd pre-aggregate at the day-category level. That reduces a billion-row scan to thousands of rows. We refresh this rollup nightly, at 1 AM after the daily load."
  • "The date dimension is denormalized with day-of-week, is-weekend, quarter, and fiscal year. This lets us filter and group by any time attribute without complex date math in queries."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: E-commerce Data Model#

Setup: Design the dimensional model for an e-commerce platform:

  • Products with categories, brands, and prices that change
  • Customers with segments that upgrade/downgrade
  • Orders with multiple items, shipping, and returns
  • Multiple sales channels (web, mobile, marketplace)

Questions:

  1. What's the grain of your fact table?
  2. How do you handle returns and refunds?
  3. How do you track product price changes historically?
Click for hints
  • Grain: Line item level (not order level)
  • Returns: Separate fact table or negative quantities
  • Price changes: SCD Type 2 on product dimension
  • Capture price in fact table too (what was charged)

Practice · problem 2

Problem 2: SaaS Subscription Analytics#

Setup: Design analytics for a B2B SaaS with:

  • Subscription plans (monthly/annual, tiers)
  • Usage metrics (API calls, storage, users)
  • Revenue recognition (MRR, ARR, expansion, churn)

Questions:

  1. What fact tables do you need?
  2. How do you calculate MRR?
  3. How do you track plan changes?
Click for hints
  • Fact tables: subscriptions, usage, revenue events
  • MRR: Point-in-time subscription value (monthly snapshot)
  • Plan changes: SCD Type 2 on subscription dimension
  • Consider snapshot fact table for MRR

Chapter 11: Sample Interview Dialogue#

Transcript
Interviewer

"We have slow dashboard queries. How would you speed them up?"

You

"Let me understand the current setup first. What's the data model, and what kinds of queries are slow?"

Interviewer

"We have a normalized operational database. Dashboards join 5-6 tables and aggregate millions of rows. Some queries take 30 seconds."

You

"That's a classic problem. Normalized models are great for OLTP but painful for analytics. I'd recommend a dimensional model — specifically a star schema.

First, I'd identify the key fact table. For an e-commerce dashboard, that's probably sales at the line-item grain. It would have foreign keys to dimensions and numeric measures like quantity and revenue.

Then dimension tables for product, customer, time, and location. These are denormalized — all product attributes in one table, no further joins needed.

This alone eliminates most joins. But for a dashboard hitting millions of rows, I'd add pre-aggregation. Common queries like 'revenue by category by month' can read from a rollup table with 365 × 100 rows instead of 100 million.

Finally, partitioning the fact table by date means queries for specific time ranges only scan relevant data. 'Last 7 days' reads 7 partitions, not the whole table."

Interviewer

"What about customers who change segments over time?"

You

"Great question. That's a slowly changing dimension. I'd use SCD Type 2 — when a customer's segment changes, we close the old record and create a new one with different valid dates.

The fact table stores the surrogate key that was valid at transaction time. So when we query 'Q1 revenue by segment', each sale is attributed to the segment the customer was in when they bought, not their current segment.

This is crucial for accurate historical analysis. Without it, if a customer upgrades to Premium today, all their historical purchases would suddenly count as Premium — which would be misleading."

Summary#

Day 3 summary: Data modeling for analytics
  • Star schema
    • Fact tables — Measures (revenue, quantity)
    • Dimension tables — Attributes (product, customer, date)
    • Denormalized for fast queries
    • Surrogate keys for history tracking
    • Grain defines the level of detail
  • Slowly changing dimensions
    • Type 0 — Retain original
    • Type 1 — Overwrite (no history)
    • Type 2 — Add row (full history) ← Most common
    • Type 3 — Add column (limited history)
    • Hash-based change detection
  • Columnar storage
    • Read only needed columns
    • Better compression
    • Parquet, ORC, Delta, Iceberg
    • Predicate pushdown for filtering
  • Partitioning
    • Partition by query filter columns
    • Date partitioning most common
    • Avoid over-partitioning
    • Enables partition pruning
  • Pre-aggregation
    • Rollup tables for common queries
    • Materialized views for automation
    • Trade storage for speed
    • Refresh strategy matters
◆ Key takeaways

Key Takeaways#

Data modeling key takeaways
  1. OLTP ≠ OLAP — Different access patterns need different models
  2. DENORMALIZE FOR READS — Star schema trades storage for query speed
  3. GRAIN IS FOUNDATIONAL — Define the lowest level of detail first
  4. TRACK DIMENSION HISTORY — SCD Type 2 enables accurate historical analysis
  5. PRE-COMPUTE COMMON QUERIES — Rollups make dashboards fast
Golden rules
  • One fact table per business process
  • Surrogate keys for all dimensions
  • Denormalize dimensions (no snowflake)
  • Partition fact tables by date
  • Pre-aggregate for dashboard queries

What's Next#

Tomorrow, we'll tackle Late-Arriving Data and Correctness — handling events that arrive after their window closed, and maintaining accurate analytics despite late data.

Tomorrow's preview: Late data
Questions we'll answer
  • What happens when events arrive late?
  • How do watermarks work in practice?
  • When should we recompute vs patch?
  • How do we version our analytics?
  • What's the "correction" pattern?

Late data is the nightmare of real-time analytics. Tomorrow we learn to wake up from it.

Next: Day 4 — Late-Arriving Data and Correctness

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 03 · Data Modeling for Analytics
Finish Day 3Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~7 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

Kimball GroupDocs · ~20 min

Dimensional Modeling Techniques

Ralph Kimball
What
The Kimball Group's index of dimensional modelling techniques.
Key idea
Fact tables at a declared grain, dimension tables around them, and named patterns for slowly changing dimensions (Type 1, 2, 3 and others).
Why here
The source of this lesson's star schema and SCD vocabulary, with a short page per technique.
taught here: Star schema
GooglePaper · ~40 min

Dremel: Interactive Analysis of Web-Scale Datasets

Sergey Melnik, Andrey Gubarev et al. · 2010
What
The paper behind Google's interactive query engine, later BigQuery.
Key idea
A columnar layout for nested records plus multi-level execution trees runs aggregations over trillion-row tables in seconds.
Why here
Why this lesson's columnar storage chapter matters: analytic queries read few columns, so store and scan by column.
taught here: Columnar storage

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…