OLAP and Data Warehousing - Analytical Processing

Learning Objectives: By the end of this lesson, you will be able to:

  • Understand the differences between OLTP and OLAP systems
  • Design dimensional data models for analytical workloads
  • Work with modern data warehouse solutions (Snowflake, BigQuery, Redshift)
  • Implement star and snowflake schemas for business intelligence
  • Write analytical queries using window functions and aggregations

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Your CEO walks in and asks: “How did our sales perform last quarter compared to the same period last year? Which products are trending? Which regions are underperforming?” Your operational database—designed to handle thousands of transactions per second—grinds to a halt when you try to run these complex analytical queries across millions of records.

This is why data warehouses exist: systems specifically designed to answer business questions by analyzing massive amounts of historical data without impacting operational systems.

Lesson Overview

  • Duration: 90 minutes
  • Prerequisites: Understanding of relational databases and SQL
  • Materials Needed: Access to a data warehouse (BigQuery free tier, or local DuckDB)

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. OLTP vs OLAP - Understanding the fundamental differences
  2. Data Warehouse Architecture - ETL, ELT, and data pipelines
  3. Dimensional Modeling - Star schemas and snowflake schemas
  4. Modern Data Warehouses - Snowflake, BigQuery, Redshift, ClickHouse
  5. Analytical Queries - Complex aggregations and window functions
  6. Best Practices - Performance optimization and cost management

Success Criteria:

By the end of this session, you should be able to design a dimensional data model for business analytics and write complex analytical queries on data warehouse systems.

3. Foundation Knowledge (15-20 minutes)

Core Concepts

OLTP vs OLAP

OLTP (Online Transaction Processing):

  • Optimized for many small, fast transactions
  • Normalized data to reduce redundancy
  • Handles current operational data
  • Examples: Order processing, banking transactions, inventory management

OLAP (Online Analytical Processing):

  • Optimized for complex analytical queries
  • Denormalized data for query performance
  • Handles historical, aggregated data
  • Examples: Sales reporting, trend analysis, business intelligence

Real-World Analogy: OLTP is like a cash register—quick, focused transactions. OLAP is like the quarterly financial report—comprehensive analysis of all those transactions.

Characteristic OLTP OLAP
Query Type Simple, operational Complex, analytical
Query Volume Thousands per second Hundreds per day
Data Size Gigabytes Terabytes to Petabytes
Schema Normalized (3NF) Denormalized (Star/Snowflake)
Updates Frequent Batch loads
Users Many concurrent Fewer analysts

Data Warehouse Architecture

Traditional ETL (Extract, Transform, Load):

Source Systems → Extract → Transform → Load → Data Warehouse
                           (staging area)

Modern ELT (Extract, Load, Transform):

Source Systems → Extract → Load → Transform → Data Warehouse
                         (raw data)    (in-warehouse processing)

Why ELT is Trending:

  • Cloud data warehouses have massive compute power
  • Transformation logic in SQL is more maintainable
  • Raw data preserved for future transformations
  • Better audit trails and reproducibility

Key Terminology

  • Data Warehouse: Central repository of integrated data for analytics
  • Data Mart: Subset of data warehouse focused on specific business area
  • Data Lake: Raw data storage in native format (often paired with warehouse)
  • Fact Table: Contains measurable, quantitative data (metrics)
  • Dimension Table: Contains descriptive attributes (context)
  • Grain: The level of detail stored in a fact table
  • Slowly Changing Dimension (SCD): How dimension changes are tracked over time
  • Star Schema: Central fact table connected to dimension tables
  • Snowflake Schema: Star schema with normalized dimensions
  • OLAP Cube: Multi-dimensional data structure for fast analytical queries

4. Detailed Explanation and Examples (25-30 minutes)

Dimensional Modeling

The Star Schema

The star schema is the most common dimensional modeling approach, featuring:

  • One central fact table containing metrics
  • Multiple dimension tables containing descriptive attributes
  • Foreign keys connecting facts to dimensions
              ┌─────────────┐
              │ dim_product │
              └──────┬──────┘
                     │
┌───────────┐ ┌──────┴──────┐ ┌─────────────┐
│ dim_store │─┤ fact_sales  ├─│ dim_customer│
└───────────┘ └──────┬──────┘ └─────────────┘
                     │
              ┌──────┴──────┐
              │  dim_date   │
              └─────────────┘

Advantages:

  • Simple to understand and navigate
  • Optimized for analytical queries
  • Fast aggregations due to predictable joins
  • Business users can self-serve with BI tools

Fact Tables

Fact tables store measurable events or transactions:

CREATE TABLE fact_sales (
    sale_id         BIGINT PRIMARY KEY,
    date_key        INTEGER REFERENCES dim_date(date_key),
    product_key     INTEGER REFERENCES dim_product(product_key),
    store_key       INTEGER REFERENCES dim_store(store_key),
    customer_key    INTEGER REFERENCES dim_customer(customer_key),
    
    -- Measures (facts)
    quantity        INTEGER,
    unit_price      DECIMAL(10, 2),
    discount_amount DECIMAL(10, 2),
    total_amount    DECIMAL(12, 2),
    cost_amount     DECIMAL(10, 2),
    profit_amount   DECIMAL(10, 2)
);

Types of Facts:

  • Additive: Can be summed across all dimensions (quantity, revenue)
  • Semi-Additive: Can be summed across some dimensions (inventory levels)
  • Non-Additive: Cannot be summed (ratios, percentages)

Dimension Tables

Dimension tables provide context for facts:

CREATE TABLE dim_product (
    product_key     INTEGER PRIMARY KEY,
    product_id      VARCHAR(20),
    product_name    VARCHAR(200),
    category        VARCHAR(100),
    subcategory     VARCHAR(100),
    brand           VARCHAR(100),
    supplier        VARCHAR(100),
    unit_cost       DECIMAL(10, 2),
    
    -- SCD Type 2 columns
    effective_date  DATE,
    expiration_date DATE,
    is_current      BOOLEAN
);

CREATE TABLE dim_date (
    date_key        INTEGER PRIMARY KEY,
    date_value      DATE UNIQUE,
    day_of_week     VARCHAR(10),
    day_of_month    INTEGER,
    day_of_year     INTEGER,
    week_of_year    INTEGER,
    month_number    INTEGER,
    month_name      VARCHAR(10),
    quarter         INTEGER,
    year            INTEGER,
    is_weekend      BOOLEAN,
    is_holiday      BOOLEAN,
    fiscal_quarter  INTEGER,
    fiscal_year     INTEGER
);

Modern Data Warehouse Solutions

Snowflake

  • Architecture: Separation of storage and compute
  • Key Features: Auto-scaling, time travel, data sharing, zero-copy cloning
  • Best For: Multi-cloud, variable workloads, data collaboration
  • Pricing: Pay for storage + compute (per-second billing)

Google BigQuery

  • Architecture: Serverless, columnar storage
  • Key Features: ML integration, geospatial support, nested data
  • Best For: Google Cloud users, ad-hoc analysis, petabyte scale
  • Pricing: Pay per query (data scanned) + storage

Amazon Redshift

  • Architecture: MPP (Massively Parallel Processing) cluster
  • Key Features: Integration with AWS ecosystem, Spectrum for S3
  • Best For: AWS users, predictable workloads
  • Pricing: Per-node or serverless

ClickHouse

  • Architecture: Column-oriented, open-source
  • Key Features: Real-time analytics, vector search support
  • Best For: Real-time dashboards, high insert throughput
  • Pricing: Self-hosted free, cloud versions available

Common Misconceptions

  1. Misconception: “Just replicate the operational database for analytics” Reality: Operational schemas are optimized for transactions, not analysis. Dimensional models are specifically designed for analytical query patterns.

  2. Misconception: “Data warehouses are just big databases” Reality: They use different storage formats (columnar), query optimizers, and architectures designed specifically for analytical workloads.

  3. Misconception: “ETL is outdated; always use ELT” Reality: Both approaches have valid use cases. ETL is better when transformations need specialized tools or when data should be cleansed before warehouse storage.

5. Live Coding Demonstration (20-25 minutes)

Example 1: Building a Sales Data Warehouse Schema

-- Create dimension tables
CREATE TABLE dim_date (
    date_key INTEGER PRIMARY KEY,
    date_value DATE NOT NULL,
    day_name VARCHAR(10),
    day_of_week INTEGER,
    day_of_month INTEGER,
    week_of_year INTEGER,
    month_number INTEGER,
    month_name VARCHAR(15),
    quarter INTEGER,
    year INTEGER,
    is_weekend BOOLEAN,
    is_holiday BOOLEAN
);

CREATE TABLE dim_product (
    product_key INTEGER PRIMARY KEY,
    product_id VARCHAR(20) NOT NULL,
    product_name VARCHAR(200) NOT NULL,
    category VARCHAR(100),
    subcategory VARCHAR(100),
    brand VARCHAR(100),
    unit_cost DECIMAL(10, 2),
    -- SCD Type 2
    valid_from DATE NOT NULL,
    valid_to DATE,
    is_current BOOLEAN DEFAULT TRUE
);

CREATE TABLE dim_customer (
    customer_key INTEGER PRIMARY KEY,
    customer_id VARCHAR(20) NOT NULL,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    email VARCHAR(100),
    city VARCHAR(100),
    state VARCHAR(50),
    country VARCHAR(50),
    customer_segment VARCHAR(20),
    -- SCD Type 2
    valid_from DATE NOT NULL,
    valid_to DATE,
    is_current BOOLEAN DEFAULT TRUE
);

CREATE TABLE dim_store (
    store_key INTEGER PRIMARY KEY,
    store_id VARCHAR(20) NOT NULL,
    store_name VARCHAR(100),
    city VARCHAR(100),
    state VARCHAR(50),
    country VARCHAR(50),
    region VARCHAR(50),
    store_type VARCHAR(50)
);

-- Create fact table
CREATE TABLE fact_sales (
    sale_key BIGINT PRIMARY KEY,
    date_key INTEGER REFERENCES dim_date(date_key),
    product_key INTEGER REFERENCES dim_product(product_key),
    customer_key INTEGER REFERENCES dim_customer(customer_key),
    store_key INTEGER REFERENCES dim_store(store_key),
    
    -- Degenerate dimensions (from transaction)
    order_number VARCHAR(20),
    line_number INTEGER,
    
    -- Facts (measures)
    quantity INTEGER,
    unit_price DECIMAL(10, 2),
    discount_percent DECIMAL(5, 2),
    discount_amount DECIMAL(10, 2),
    tax_amount DECIMAL(10, 2),
    total_amount DECIMAL(12, 2),
    cost_amount DECIMAL(10, 2),
    profit_amount DECIMAL(12, 2)
);

-- Populate date dimension (useful utility)
INSERT INTO dim_date
SELECT
    TO_CHAR(d, 'YYYYMMDD')::INTEGER AS date_key,
    d AS date_value,
    TO_CHAR(d, 'Day') AS day_name,
    EXTRACT(ISODOW FROM d) AS day_of_week,
    EXTRACT(DAY FROM d) AS day_of_month,
    EXTRACT(WEEK FROM d) AS week_of_year,
    EXTRACT(MONTH FROM d) AS month_number,
    TO_CHAR(d, 'Month') AS month_name,
    EXTRACT(QUARTER FROM d) AS quarter,
    EXTRACT(YEAR FROM d) AS year,
    EXTRACT(ISODOW FROM d) IN (6, 7) AS is_weekend,
    FALSE AS is_holiday -- Update with actual holidays
FROM GENERATE_SERIES('2020-01-01'::DATE, '2030-12-31'::DATE, '1 day') AS d;

Example 2: Analytical Queries

-- Basic dimensional query: Sales by product category and month
SELECT 
    dd.year,
    dd.month_name,
    dp.category,
    SUM(fs.quantity) AS total_units,
    SUM(fs.total_amount) AS total_revenue,
    SUM(fs.profit_amount) AS total_profit
FROM fact_sales fs
JOIN dim_date dd ON fs.date_key = dd.date_key
JOIN dim_product dp ON fs.product_key = dp.product_key
WHERE dd.year = 2024
GROUP BY dd.year, dd.month_number, dd.month_name, dp.category
ORDER BY dd.month_number, total_revenue DESC;

-- Year-over-Year comparison
WITH monthly_sales AS (
    SELECT
        dd.year,
        dd.month_number,
        SUM(fs.total_amount) AS revenue
    FROM fact_sales fs
    JOIN dim_date dd ON fs.date_key = dd.date_key
    GROUP BY dd.year, dd.month_number
)
SELECT
    curr.year,
    curr.month_number,
    curr.revenue AS current_revenue,
    prev.revenue AS previous_year_revenue,
    curr.revenue - prev.revenue AS yoy_change,
    ROUND((curr.revenue - prev.revenue) / prev.revenue * 100, 2) AS yoy_percent
FROM monthly_sales curr
LEFT JOIN monthly_sales prev 
    ON curr.month_number = prev.month_number 
    AND curr.year = prev.year + 1
WHERE curr.year = 2024
ORDER BY curr.month_number;

-- Running total and ranking
SELECT
    dd.year,
    dd.month_number,
    dp.category,
    SUM(fs.total_amount) AS monthly_revenue,
    SUM(SUM(fs.total_amount)) OVER (
        PARTITION BY dp.category
        ORDER BY dd.year, dd.month_number
    ) AS running_total,
    RANK() OVER (
        PARTITION BY dd.year, dd.month_number
        ORDER BY SUM(fs.total_amount) DESC
    ) AS category_rank
FROM fact_sales fs
JOIN dim_date dd ON fs.date_key = dd.date_key
JOIN dim_product dp ON fs.product_key = dp.product_key
WHERE dd.year >= 2023
GROUP BY dd.year, dd.month_number, dp.category
ORDER BY dd.year, dd.month_number, category_rank;

-- Customer cohort analysis
WITH first_purchase AS (
    SELECT 
        customer_key,
        MIN(date_key) AS first_purchase_date_key
    FROM fact_sales
    GROUP BY customer_key
),
cohort_revenue AS (
    SELECT
        dd_first.year AS cohort_year,
        dd_first.month_number AS cohort_month,
        EXTRACT(YEAR FROM dd.date_value) * 12 + dd.month_number -
        (dd_first.year * 12 + dd_first.month_number) AS months_since_first,
        SUM(fs.total_amount) AS revenue,
        COUNT(DISTINCT fs.customer_key) AS customers
    FROM fact_sales fs
    JOIN first_purchase fp ON fs.customer_key = fp.customer_key
    JOIN dim_date dd ON fs.date_key = dd.date_key
    JOIN dim_date dd_first ON fp.first_purchase_date_key = dd_first.date_key
    GROUP BY dd_first.year, dd_first.month_number, months_since_first
)
SELECT
    cohort_year,
    cohort_month,
    months_since_first,
    customers,
    revenue,
    ROUND(revenue / NULLIF(customers, 0), 2) AS revenue_per_customer
FROM cohort_revenue
WHERE cohort_year = 2024
ORDER BY cohort_year, cohort_month, months_since_first;

Example 3: BigQuery/Snowflake Specific Features

-- BigQuery: Partitioning and clustering
CREATE TABLE `project.dataset.fact_sales`
PARTITION BY DATE(sale_date)
CLUSTER BY store_key, product_key
AS
SELECT * FROM source_sales;

-- Snowflake: Time travel (query data as it existed in the past)
SELECT * FROM fact_sales
AT(TIMESTAMP => '2024-01-01 00:00:00'::TIMESTAMP);

-- Snowflake: Clone for testing
CREATE TABLE fact_sales_test CLONE fact_sales;

-- BigQuery: Nested and repeated fields
SELECT
    order_id,
    customer.name AS customer_name,
    customer.email AS customer_email,
    ARRAY_LENGTH(items) AS item_count,
    (SELECT SUM(item.quantity * item.price) FROM UNNEST(items) AS item) AS total
FROM orders;

-- Window functions for advanced analytics
SELECT
    store_key,
    date_key,
    daily_revenue,
    -- Moving average
    AVG(daily_revenue) OVER (
        PARTITION BY store_key
        ORDER BY date_key
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS revenue_7day_avg,
    -- Percent of total
    daily_revenue / SUM(daily_revenue) OVER (
        PARTITION BY date_key
    ) * 100 AS percent_of_daily_total,
    -- Comparison to previous period
    daily_revenue - LAG(daily_revenue) OVER (
        PARTITION BY store_key
        ORDER BY date_key
    ) AS change_from_previous_day
FROM (
    SELECT 
        store_key,
        date_key,
        SUM(total_amount) AS daily_revenue
    FROM fact_sales
    GROUP BY store_key, date_key
) daily_sales;

Example 4: ETL/ELT Pattern

-- ELT Pattern: Load raw data, then transform in warehouse

-- Step 1: Create staging table for raw data
CREATE TABLE staging_orders (
    raw_data JSON,
    load_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    source_file VARCHAR(255)
);

-- Step 2: Transform into dimensional model
INSERT INTO dim_customer (customer_key, customer_id, first_name, last_name, email, valid_from, is_current)
SELECT
    ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_key,
    customer_id,
    first_name,
    last_name,
    email,
    CURRENT_DATE AS valid_from,
    TRUE AS is_current
FROM (
    SELECT DISTINCT
        raw_data->>'customer_id' AS customer_id,
        raw_data->'customer'->>'first_name' AS first_name,
        raw_data->'customer'->>'last_name' AS last_name,
        raw_data->'customer'->>'email' AS email
    FROM staging_orders
    WHERE raw_data->>'customer_id' NOT IN (SELECT customer_id FROM dim_customer WHERE is_current = TRUE)
);

-- Step 3: Load fact table
INSERT INTO fact_sales
SELECT
    (raw_data->>'order_id')::BIGINT AS sale_key,
    TO_CHAR((raw_data->>'order_date')::DATE, 'YYYYMMDD')::INTEGER AS date_key,
    dp.product_key,
    dc.customer_key,
    ds.store_key,
    raw_data->>'order_number' AS order_number,
    1 AS line_number,
    (raw_data->>'quantity')::INTEGER AS quantity,
    (raw_data->>'unit_price')::DECIMAL(10,2) AS unit_price,
    0 AS discount_percent,
    0 AS discount_amount,
    (raw_data->>'tax')::DECIMAL(10,2) AS tax_amount,
    (raw_data->>'total')::DECIMAL(12,2) AS total_amount,
    (raw_data->>'cost')::DECIMAL(10,2) AS cost_amount,
    (raw_data->>'total')::DECIMAL(12,2) - (raw_data->>'cost')::DECIMAL(10,2) AS profit_amount
FROM staging_orders so
JOIN dim_product dp ON so.raw_data->>'product_id' = dp.product_id AND dp.is_current = TRUE
JOIN dim_customer dc ON so.raw_data->>'customer_id' = dc.customer_id AND dc.is_current = TRUE
JOIN dim_store ds ON so.raw_data->>'store_id' = ds.store_id;

6. Hands-On Practice (30-40 minutes)

Exercise 1: Design an E-commerce Data Warehouse (15 minutes)

Objective: Create a dimensional model for an online retailer

Requirements:

  • Track orders, returns, and customer behavior
  • Support analysis by time, product, customer, and geography
  • Enable promotional campaign analysis

Solution:

-- Fact table for orders
CREATE TABLE fact_orders (
    order_key BIGINT PRIMARY KEY,
    date_key INTEGER,
    product_key INTEGER,
    customer_key INTEGER,
    promotion_key INTEGER,
    shipping_address_key INTEGER,
    
    -- Measures
    quantity INTEGER,
    unit_price DECIMAL(10, 2),
    discount_amount DECIMAL(10, 2),
    shipping_cost DECIMAL(10, 2),
    tax_amount DECIMAL(10, 2),
    total_amount DECIMAL(12, 2)
);

-- Fact table for returns (separate grain)
CREATE TABLE fact_returns (
    return_key BIGINT PRIMARY KEY,
    original_order_key BIGINT,
    return_date_key INTEGER,
    product_key INTEGER,
    customer_key INTEGER,
    return_reason_key INTEGER,
    
    -- Measures
    quantity_returned INTEGER,
    refund_amount DECIMAL(10, 2)
);

-- Dimension tables
CREATE TABLE dim_promotion (
    promotion_key INTEGER PRIMARY KEY,
    promotion_id VARCHAR(20),
    promotion_name VARCHAR(100),
    promotion_type VARCHAR(50),  -- Discount, BOGO, Free Shipping
    discount_percent DECIMAL(5, 2),
    start_date DATE,
    end_date DATE,
    marketing_channel VARCHAR(50)
);

CREATE TABLE dim_geography (
    geography_key INTEGER PRIMARY KEY,
    city VARCHAR(100),
    state VARCHAR(50),
    country VARCHAR(50),
    region VARCHAR(50),
    postal_code VARCHAR(20),
    latitude DECIMAL(10, 6),
    longitude DECIMAL(10, 6)
);

CREATE TABLE dim_return_reason (
    return_reason_key INTEGER PRIMARY KEY,
    reason_code VARCHAR(20),
    reason_description VARCHAR(200),
    reason_category VARCHAR(50)  -- Quality, Wrong Item, Changed Mind
);

-- Sample analytical queries

-- Promotion effectiveness analysis
SELECT
    dp.promotion_name,
    dp.promotion_type,
    COUNT(DISTINCT fo.order_key) AS order_count,
    SUM(fo.total_amount) AS total_revenue,
    SUM(fo.discount_amount) AS total_discounts,
    AVG(fo.total_amount) AS avg_order_value,
    COUNT(DISTINCT fr.return_key) AS return_count,
    SUM(fr.refund_amount) AS total_refunds,
    SUM(fo.total_amount) - SUM(COALESCE(fr.refund_amount, 0)) AS net_revenue
FROM fact_orders fo
JOIN dim_promotion dp ON fo.promotion_key = dp.promotion_key
LEFT JOIN fact_returns fr ON fo.order_key = fr.original_order_key
GROUP BY dp.promotion_key, dp.promotion_name, dp.promotion_type
ORDER BY net_revenue DESC;

-- Return rate by product category
SELECT
    dprod.category,
    COUNT(fo.order_key) AS orders,
    SUM(fo.quantity) AS units_sold,
    COUNT(fr.return_key) AS returns,
    SUM(COALESCE(fr.quantity_returned, 0)) AS units_returned,
    ROUND(SUM(COALESCE(fr.quantity_returned, 0))::DECIMAL / NULLIF(SUM(fo.quantity), 0) * 100, 2) AS return_rate
FROM fact_orders fo
JOIN dim_product dprod ON fo.product_key = dprod.product_key
LEFT JOIN fact_returns fr ON fo.order_key = fr.original_order_key AND fo.product_key = fr.product_key
GROUP BY dprod.category
ORDER BY return_rate DESC;

Exercise 2: Customer Analytics Dashboard (15 minutes)

Objective: Write queries for a customer analytics dashboard

Challenge: Create queries for:

  1. Customer lifetime value (CLV)
  2. Purchase frequency distribution
  3. Customer segmentation (RFM analysis)

Solution:

-- Customer Lifetime Value
WITH customer_metrics AS (
    SELECT
        dc.customer_key,
        dc.first_name || ' ' || dc.last_name AS customer_name,
        dc.customer_segment,
        MIN(dd.date_value) AS first_purchase,
        MAX(dd.date_value) AS last_purchase,
        COUNT(DISTINCT fo.order_key) AS total_orders,
        SUM(fo.total_amount) AS lifetime_value,
        AVG(fo.total_amount) AS avg_order_value
    FROM fact_orders fo
    JOIN dim_customer dc ON fo.customer_key = dc.customer_key
    JOIN dim_date dd ON fo.date_key = dd.date_key
    GROUP BY dc.customer_key, dc.first_name, dc.last_name, dc.customer_segment
)
SELECT
    customer_segment,
    COUNT(*) AS customer_count,
    AVG(lifetime_value) AS avg_clv,
    AVG(total_orders) AS avg_orders,
    AVG(avg_order_value) AS avg_aov,
    AVG(EXTRACT(DAY FROM last_purchase - first_purchase)) AS avg_customer_lifespan_days
FROM customer_metrics
GROUP BY customer_segment
ORDER BY avg_clv DESC;

-- RFM Analysis (Recency, Frequency, Monetary)
WITH rfm_base AS (
    SELECT
        dc.customer_key,
        dc.first_name || ' ' || dc.last_name AS customer_name,
        MAX(dd.date_value) AS last_purchase_date,
        COUNT(DISTINCT fo.order_key) AS frequency,
        SUM(fo.total_amount) AS monetary
    FROM fact_orders fo
    JOIN dim_customer dc ON fo.customer_key = dc.customer_key
    JOIN dim_date dd ON fo.date_key = dd.date_key
    WHERE dd.date_value >= CURRENT_DATE - INTERVAL '2 years'
    GROUP BY dc.customer_key, dc.first_name, dc.last_name
),
rfm_scores AS (
    SELECT
        customer_key,
        customer_name,
        CURRENT_DATE - last_purchase_date AS recency_days,
        frequency,
        monetary,
        NTILE(5) OVER (ORDER BY last_purchase_date DESC) AS r_score,
        NTILE(5) OVER (ORDER BY frequency ASC) AS f_score,
        NTILE(5) OVER (ORDER BY monetary ASC) AS m_score
    FROM rfm_base
)
SELECT
    customer_key,
    customer_name,
    recency_days,
    frequency,
    monetary,
    r_score,
    f_score,
    m_score,
    (r_score + f_score + m_score) AS rfm_total,
    CASE
        WHEN r_score >= 4 AND f_score >= 4 AND m_score >= 4 THEN 'Champions'
        WHEN r_score >= 4 AND f_score >= 3 THEN 'Loyal Customers'
        WHEN r_score >= 3 AND m_score >= 4 THEN 'Potential Loyalists'
        WHEN r_score >= 4 AND f_score <= 2 THEN 'New Customers'
        WHEN r_score <= 2 AND f_score >= 4 THEN 'At Risk'
        WHEN r_score <= 2 AND f_score <= 2 THEN 'Lost'
        ELSE 'Others'
    END AS customer_segment
FROM rfm_scores
ORDER BY rfm_total DESC;

-- Purchase frequency distribution
SELECT
    frequency_bucket,
    COUNT(*) AS customer_count,
    SUM(total_revenue) AS total_revenue,
    AVG(total_revenue) AS avg_revenue
FROM (
    SELECT
        customer_key,
        COUNT(*) AS order_count,
        SUM(total_amount) AS total_revenue,
        CASE
            WHEN COUNT(*) = 1 THEN '1 order'
            WHEN COUNT(*) BETWEEN 2 AND 3 THEN '2-3 orders'
            WHEN COUNT(*) BETWEEN 4 AND 6 THEN '4-6 orders'
            WHEN COUNT(*) BETWEEN 7 AND 10 THEN '7-10 orders'
            ELSE '10+ orders'
        END AS frequency_bucket
    FROM fact_orders
    GROUP BY customer_key
) customer_orders
GROUP BY frequency_bucket
ORDER BY MIN(order_count);

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Queries scanning entire tables, high costs Cause: Not using partition pruning or clustering Solution: Always filter on partition columns first; use clustering for common filter columns

  2. Problem: Incorrect aggregations due to duplicate joins Cause: Many-to-many relationships creating cartesian products Solution: Understand grain; aggregate before joining if needed

  3. Problem: Slowly Changing Dimensions not handled properly Cause: Overwriting historical data instead of versioning Solution: Implement proper SCD Type 2 with valid_from/valid_to dates

  4. Problem: High storage costs Cause: Storing all data at finest grain forever Solution: Implement tiered storage; aggregate old data; set retention policies

Debugging Tips

  • Use EXPLAIN: Understand query plans and data scanned
  • Check partitioning: Verify partition pruning is happening
  • Monitor costs: Set up alerts for expensive queries
  • Test with samples: Validate logic on subset before full data

8. Best Practices and Professional Tips (5 minutes)

Industry Standards

  • Conformed Dimensions: Use same dimension definitions across fact tables
  • Surrogate Keys: Use integer keys instead of natural keys
  • Audit Columns: Include load timestamps and source information
  • Documentation: Document business rules and calculations
  • Version Control: Manage schema changes with migrations

Cost Optimization

  • Partition data: By date for time-based queries
  • Cluster/Sort: By frequently filtered columns
  • Use aggregated tables: Pre-compute common metrics
  • Set up monitoring: Alert on expensive queries
  • Schedule wisely: Run heavy queries during off-peak

Next Steps in Your Learning Journey

  • Data Modeling: Advanced dimensional modeling techniques
  • Data Pipelines: Airflow, dbt, and orchestration
  • Data Quality: Testing and validation frameworks
  • Real-time Analytics: Streaming data into warehouses

9. Summary and Review (5 minutes)

Key Takeaways

  1. OLAP systems are optimized for analytical queries on historical data
  2. Dimensional modeling (star/snowflake schemas) enables efficient business analytics
  3. Modern data warehouses (Snowflake, BigQuery, Redshift) provide massive scale and SQL compatibility
  4. Proper design including partitioning, clustering, and aggregations is crucial for performance and cost

Quick Review Questions

  1. What’s the difference between a fact table and dimension table? Answer: Fact tables contain measurable numeric values (metrics); dimension tables contain descriptive attributes (context for analysis)

  2. When would you use ELT instead of ETL? Answer: When leveraging the data warehouse’s compute power for transformations, when preserving raw data is important, or when transformations are primarily SQL-based

  3. What is the grain of a fact table? Answer: The grain defines what each row represents—the level of detail (e.g., one row per line item per order)


For Instructors

Teaching Tips

  • Use real business scenarios for dimensional modeling exercises
  • Show cost comparisons between optimized and non-optimized queries
  • Demonstrate BI tool connectivity (Tableau, Looker, Metabase)
  • Walk through a complete ELT pipeline

Assessment Ideas

  • Dimensional model design from requirements
  • Query optimization challenges
  • Cost estimation exercises
  • Data quality rule implementation

Adaptation Notes

  • For beginners: Focus on star schema basics before advanced concepts
  • For advanced students: Include advanced SCD types, data vault modeling
  • Online delivery: Use BigQuery free tier for hands-on exercises
  • Time constraints: Dimensional modeling and queries can be covered in 60 minutes