Data Engineering Guide

Dimensional Modeling Guide

The difference between a data warehouse analysts love and one they avoid? Dimensional modeling. Learn to build star schemas that are intuitive to query and blazing fast to run.

25 min readFor Analytics & Data EngineersSQL examples included

See it as a diagram

Everything below, as a diagram you can edit. Describe yours and see it in seconds.

118/20003 credits left
Try:

No account needed · Editable canvas, not a picture

1. What Is Dimensional Modeling?

Dimensional modeling is a technique for designing data warehouses that prioritizes query simplicity and performance over storage efficiency. Created by Ralph Kimball in the 1990s, it remains the gold standard for analytics because it models data the way business users think about it.

Why Not Just Use 3NF?

Third Normal Form (3NF) is great for OLTP systems—it minimizes redundancy and prevents update anomalies. But it's terrible for analytics: queries require 15+ joins, performance degrades, and analysts can't understand the model without a PhD in your schema.

The core principle: separate the "what happened" from the "context"

Fact Tables

Store events and measurements: orders placed, clicks, payments. Contain metrics you can aggregate (sum, count, average).

Dimension Tables

Store descriptive context: who (customers), what (products), when (dates), where (locations). Enable filtering and grouping.

The Kimball philosophy

"The data warehouse is only as good as the business intelligence it enables." Dimensional models are designed for humans first, computers second. If an analyst can't write a query without help, the model has failed.

2. Facts vs Dimensions

The fundamental building blocks of dimensional modeling. Get this right and everything else follows.

Fact Tables

  • Record business events/transactions
  • Contain numeric measures (amount, quantity, duration)
  • Have foreign keys to dimension tables
  • Very tall and narrow (many rows, few columns)
  • Grain = one row per event

Examples:

fct_orders, fct_page_views, fct_payments

Dimension Tables

  • Store descriptive attributes
  • Contain text fields for filtering/grouping
  • Have a surrogate key as primary key
  • Short and wide (fewer rows, many columns)
  • Grain = one row per entity

Examples:

dim_customers, dim_products, dim_date

QuestionIf Answer Is...It's a...
Can you SUM/COUNT/AVG it?YesFact
Would you GROUP BY or FILTER on it?YesDimension
Does it describe an entity?YesDimension
Does it record an event/transaction?YesFact

Example: E-commerce Order

-- Fact table: one row per order line item
CREATE TABLE fct_order_lines (
    order_line_sk       BIGINT PRIMARY KEY,    -- Surrogate key
    order_id            VARCHAR(50),           -- Natural key (degenerate dimension)
    customer_sk         BIGINT REFERENCES dim_customers,
    product_sk          BIGINT REFERENCES dim_products,
    date_sk             INT REFERENCES dim_date,
    -- Measures (aggregatable)
    quantity            INT,
    unit_price          DECIMAL(10,2),
    discount_amount     DECIMAL(10,2),
    line_total          DECIMAL(10,2)
);

-- Dimension table: one row per customer
CREATE TABLE dim_customers (
    customer_sk         BIGINT PRIMARY KEY,    -- Surrogate key
    customer_id         VARCHAR(50),           -- Natural key
    customer_name       VARCHAR(255),
    email               VARCHAR(255),
    segment             VARCHAR(50),           -- 'Enterprise', 'SMB', 'Consumer'
    acquisition_channel VARCHAR(50),
    created_at          TIMESTAMP
);

3. Star Schema Design

A star schema places a fact table at the center, surrounded by dimension tables. The name comes from the shape: fact in the middle, dimensions radiating outward like points of a star.

Star Schema Structure

dim_date

dim_customer

fct_orders

dim_product

dim_store

Fact table at center, dimensions around it — simple joins, intuitive queries

Star vs Snowflake Schema

Star Schema (Preferred)

  • • Dimensions are denormalized
  • • Fewer joins = faster queries
  • • Easier to understand and query
  • • Slight data redundancy (OK for analytics)

Snowflake Schema

  • • Dimensions are normalized
  • • More joins = slower queries
  • • Harder to query without documentation
  • • Saves storage (rarely matters today)

Pro Tip

When to Snowflake

Only snowflake when dimensions are truly hierarchical AND analysts frequently query at different levels independently (e.g., product → category → department). Otherwise, denormalize everything into the dimension table.

4. Types of Dimensions

Not all dimensions are created equal. Understanding these patterns helps you model correctly from the start.

1

Conformed Dimensions

Shared across multiple fact tables. dim_date, dim_customer used by fct_orders, fct_page_views, fct_support_tickets. The key to enterprise consistency.

2

Role-Playing Dimensions

Same dimension used multiple times with different meanings. dim_date joined as order_date, ship_date, delivery_date. Create views or aliases.

3

Degenerate Dimensions

Dimension keys stored in the fact table without a separate dimension table. Order numbers, invoice IDs, transaction IDs. No attributes worth storing separately.

4

Junk Dimensions

Combines miscellaneous low-cardinality flags into one dimension. Instead of 5 boolean columns in the fact table, create dim_order_flags with all combinations.

5

Date Dimension

The most important dimension. Pre-calculated attributes: day_of_week, is_weekend, fiscal_quarter, holiday_flag. Always use integer surrogate keys (YYYYMMDD format).

Example: Date Dimension

CREATE TABLE dim_date (
    date_sk             INT PRIMARY KEY,       -- YYYYMMDD format
    date_actual         DATE NOT NULL,
    day_of_week         VARCHAR(10),           -- 'Monday', 'Tuesday', ...
    day_of_week_num     INT,                   -- 1-7
    day_of_month        INT,
    day_of_year         INT,
    week_of_year        INT,
    month_num           INT,
    month_name          VARCHAR(10),
    quarter_num         INT,
    quarter_name        VARCHAR(10),           -- 'Q1', 'Q2', ...
    year_num            INT,
    fiscal_year         INT,
    fiscal_quarter      INT,
    is_weekend          BOOLEAN,
    is_holiday          BOOLEAN,
    holiday_name        VARCHAR(50)
);

-- Usage: easily filter/group by any date attribute
SELECT 
    d.month_name,
    d.year_num,
    SUM(f.order_total) as revenue
FROM fct_orders f
JOIN dim_date d ON f.order_date_sk = d.date_sk
WHERE d.is_weekend = FALSE
GROUP BY d.month_name, d.year_num;

5. Slowly Changing Dimensions (SCD)

Dimension attributes change over time. A customer moves, a product gets recategorized, an employee changes departments. How you handle these changes is critical for historical accuracy.

Type 0: Fixed

Never changes. Original value preserved forever. Use for attributes that shouldn't change (original signup date, birth date).

Example: Customer's original acquisition channel never updates even if they later come through a different channel.

Type 1: Overwrite

Old value is replaced with new. No history preserved. Use for corrections and attributes where history doesn't matter.

Tradeoff: Simple but you lose the ability to answer "what was the customer's segment when they placed this order?"

Type 2: Add New Row (Most Common)

Create a new dimension row with new surrogate key. Track with effective_date, expiry_date, is_current flag. Full history preserved.

Best for: Any attribute where historical accuracy matters. Segment changes, address changes, pricing tier changes.

Type 3: Add New Column

Add columns for current_value and previous_value. Limited history (typically just one prior value).

Rare: Only useful when you need exactly "before and after" comparison. Type 2 is almost always better.

Example: SCD Type 2 Implementation

-- SCD Type 2: Customer changes segment from 'SMB' to 'Enterprise'
-- BEFORE: 1 row
customer_sk | customer_id | segment    | effective_date | expiry_date | is_current
1           | C001        | SMB        | 2023-01-01     | 9999-12-31  | TRUE

-- AFTER: 2 rows (old row expired, new row added)
customer_sk | customer_id | segment    | effective_date | expiry_date | is_current
1           | C001        | SMB        | 2023-01-01     | 2024-06-15  | FALSE
2           | C001        | Enterprise | 2024-06-15     | 9999-12-31  | TRUE

-- Historical query: What segment was customer in when they ordered?
SELECT 
    o.order_id,
    c.segment as segment_at_order_time
FROM fct_orders o
JOIN dim_customers c ON o.customer_sk = c.customer_sk
-- customer_sk in fact table points to correct historical version

SCD Type 2 Gotcha

When using Type 2, you must decide at load time which surrogate key to assign to the fact. Usually you want the dimension version that was "current" at the time of the event. This requires a point-in-time lookup during ETL.

6. Types of Fact Tables

Different business processes call for different fact table designs. Choose the right type based on the nature of your events.

Transaction Facts

One row per event at the lowest grain. Most common type. Orders, clicks, payments, logins.

Grain: One row per order line item

Periodic Snapshot Facts

One row per time period. Captures state at regular intervals. Account balances, inventory levels, pipeline snapshots.

Grain: One row per account per day

Accumulating Snapshot Facts

One row per process instance, updated as milestones occur. Order fulfillment, loan applications, support tickets.

Grain: One row per order (updated through lifecycle)

Factless Facts

Records events with no measures—just foreign keys. Student attendance, product promotions coverage.

Use: What students attended class? What products were on promotion?

Example: Accumulating Snapshot (Order Fulfillment)

CREATE TABLE fct_order_fulfillment (
    order_sk                BIGINT PRIMARY KEY,
    order_id                VARCHAR(50),
    customer_sk             BIGINT,
    -- Multiple date foreign keys (milestones)
    order_date_sk           INT,
    payment_date_sk         INT,
    ship_date_sk            INT,
    delivery_date_sk        INT,
    -- Lag measures (calculated)
    days_to_payment         INT,
    days_to_ship            INT,
    days_to_delivery        INT,
    -- Measures
    order_total             DECIMAL(10,2),
    current_status          VARCHAR(50)
);

-- Row is updated as order progresses through fulfillment
-- Initially: only order_date_sk populated
-- After payment: payment_date_sk filled, days_to_payment calculated
-- After shipping: ship_date_sk filled, days_to_ship calculated
-- After delivery: delivery_date_sk filled, days_to_delivery calculated

7. Best Practices Checklist

Define grain first

Before designing any fact table, explicitly state the grain: "One row per order line item" or "One row per customer per day." Never mix grains.

Use surrogate keys

Integer surrogate keys on all dimension tables. Store natural keys as attributes. This handles SCD Type 2 correctly and improves join performance.

Build conformed dimensions

dim_date and dim_customer should be shared across all fact tables. Same keys, same attributes. This enables cross-process analysis.

Denormalize dimensions

Prefer star over snowflake. Include category, department, region directly in the dimension. Storage is cheap, joins are expensive.

Add a date dimension

Never join on raw dates. Create dim_date with pre-calculated attributes. Use integer keys (YYYYMMDD) for partition pruning.

Handle nulls with default rows

Create "Unknown" or "Not Applicable" rows in dimensions (SK = -1). Never have null foreign keys in fact tables.

Document the grain

Every fact table should have its grain documented. This prevents accidental double-counting and helps analysts write correct queries.

Use SCD Type 2 for important attributes

Customer segment, product category, employee department—anything that changes and affects analysis should be Type 2.

Pro Tip

The "Analyst Test"

After designing your model, have an analyst write 5 common queries without documentation. If they can do it in under 5 minutes each, your model is good. If they need to ask questions or make mistakes, simplify your design.

8. Frequently Asked Questions

What is the difference between a fact table and a dimension table?

Fact tables store measurable business events (transactions, clicks, orders) with numeric metrics and foreign keys. Dimension tables store descriptive attributes (customer names, product categories, dates) that provide context for analyzing facts. Facts answer "how much" while dimensions answer "who, what, when, where, why."

What is a star schema vs snowflake schema?

A star schema has denormalized dimension tables directly connected to a central fact table, forming a star shape. A snowflake schema normalizes dimensions into sub-dimensions (e.g., product → category → department). Star schemas are preferred for analytics because they're simpler to query and perform better.

What are slowly changing dimensions (SCD)?

Slowly changing dimensions handle attribute changes over time. Type 1 overwrites the old value (no history). Type 2 creates a new row with effective dates (full history). Type 3 adds columns for previous values (limited history). Type 2 is most common for analytics requiring historical accuracy.

Should I use surrogate keys or natural keys?

Use surrogate keys (auto-generated integers) as primary keys in dimension tables. Natural keys (business identifiers like customer_id) should be stored as attributes. Surrogate keys are stable, performant, and handle SCD Type 2 correctly. Natural keys can change and cause join issues.

Visualize Your Dimensional Model

Create clear star schema diagrams showing facts, dimensions, and relationships. Document your data model so analysts can query it without asking questions.