Why Dimensional Modeling?
Relational normalization — decomposing data into third normal form (3NF) to eliminate redundancy — is ideal for transactional systems where writes dominate. For analytics, however, 3NF schemas are painful: a single business question might require joining ten or fifteen tables, the query plans are complex, and the SQL is hard to reason about. Dimensional modeling, popularized by Ralph Kimball, trades some write efficiency for dramatically better analytical ergonomics and query performance.
The core idea is to organize data around business processes (e.g., a sale, a web page view, a support ticket opened) rather than around entities. Each business process becomes a fact table whose rows represent individual events or measurements at a consistent grain. Surrounding the fact table are dimension tables that provide descriptive context.
Facts, Measures, and Grain
A fact table row represents one occurrence of a business event at a defined grain. Choosing the right grain is the most important modeling decision: it determines what questions the table can answer. Common choices are:
- Transaction grain: one row per individual sale line-item. Most flexible — any aggregation is possible.
- Snapshot grain: one row per entity per time period (e.g., one row per customer per day showing their account balance). Useful for period-over-period analysis.
- Accumulating snapshot grain: one row per business process instance that gets updated as the process moves through stages (e.g., an order that passes through placed → shipped → delivered).
Fact columns are either measures (additive numbers like revenue, quantity, duration<em>seconds) or foreign keys to dimension tables. Additive measures can be summed across any dimension. Semi-additive measures (e.g., account balance) can be summed across some dimensions but not time. Non-additive measures (e.g., ratios, percentages) should be stored as their components and computed at query time.
Dimension Tables
Dimension tables hold the who, what, where, when, why, and how of each event. They are typically wide (many columns, often 50–100) and relatively short (millions of rows at most, compared to billions in a fact table). Dimensions always have a surrogate primary key — a system-generated integer — separate from the natural key from the source system. This decoupling is essential for slowly changing dimensions (covered in the next section).
A date dimension is nearly universal. Pre-populating it with derived columns (day of week, fiscal quarter, isholiday, etc.) moves expensive EXTRACT and CASE logic out of every query:
CREATE TABLE dim_date (
date_key INT PRIMARY KEY, -- e.g., 20240315
full_date DATE NOT NULL,
year SMALLINT,
quarter SMALLINT,
month SMALLINT,
month_name VARCHAR(9),
week_of_year SMALLINT,
day_of_week SMALLINT,
is_weekend BOOLEAN,
is_holiday BOOLEAN,
fiscal_year SMALLINT,
fiscal_quarter SMALLINT
);Star Schema vs. Snowflake Schema
In a star schema, every dimension table connects directly to the fact table. The schema looks like a star when drawn as an entity-relationship diagram. Dimension tables are deliberately denormalized: a dim<em>product table might contain both product</em>name and category<em>name even though category is logically a separate entity.
In a snowflake schema, dimension tables are normalized — dim</em>product holds a foreign key to dim<em>category, which holds a foreign key to dim</em>department, and so on. The schema looks like a snowflake when drawn.
Which to choose? Star schemas are almost always preferred for analytics:
- Fewer joins means simpler SQL and faster queries.
- Modern columnar warehouses cache dimension tables aggressively; the storage cost of denormalization is negligible.
- Analysts can understand the schema without knowing the internal normalization hierarchy.
Snowflake schemas are occasionally justified when a dimension table is extremely large (tens of millions of rows) and the normalized sub-dimension adds significant filtering, or when ETL processes write to dimension sub-tables independently.
A Worked Example: E-Commerce Sales
Suppose an operational database has tables orders, order_items, customers, products, and stores. The business question is: "What was daily revenue by product category and store region?"
-- Fact table at order-line grain
CREATE TABLE fct_sales (
sale_key BIGINT PRIMARY KEY,
date_key INT REFERENCES dim_date(date_key),
customer_key INT REFERENCES dim_customer(customer_key),
product_key INT REFERENCES dim_product(product_key),
store_key INT REFERENCES dim_store(store_key),
quantity INT NOT NULL,
unit_price NUMERIC(10,2) NOT NULL,
discount_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
revenue NUMERIC(10,2) GENERATED ALWAYS AS
(quantity * unit_price - discount_amount) STORED
);
-- Answering the business question becomes a simple two-join query
SELECT
d.full_date,
p.category_name,
s.region,
SUM(f.revenue) AS total_revenue
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_store s ON f.store_key = s.store_key
GROUP BY 1, 2, 3
ORDER BY 1, 4 DESC;This query is readable, efficient, and would have required five or six joins against the normalized operational schema. The fact/dimension split makes the analytical layer both faster and more maintainable.