Analytical Data Modeling: Star Schemas, Facts, and Dimensions for BI That Works
A schema that is perfect for running an application is usually terrible for analyzing one. The normalized model that keeps your transactions clean forces analysts to write ten-table joins for a simple revenue-by-region chart โ and when every dashboard query reinvents those joins, every dashboard finds a different number. Analytical data modeling (also called dimensional modeling) solves this by reorganizing data around one goal: making business questions easy and consistent to answer.
This post is for data engineers and analysts building a warehouse or BI layer. It covers the star schema, the concept of grain, dimension design, and the classic problem of slowly changing dimensions.
Why OLTP Models Fail at Analytics
Transactional (OLTP) schemas optimize for many small writes: insert an order, update a status, one row at a time. Analytical (OLAP) workloads are the opposite โ few writes, massive reads: โsum revenue across 50 million orders, grouped by month and product category.โ
Run that against a normalized schema and you join orders โ order_items โ products โ categories โ customers โ regions, six tables deep, and every analyst on the team writes that join slightly differently. Dimensional modeling, popularized by Ralph Kimball in the 1990s, restructures the data so the join logic is decided once, by the modeler โ not nightly, by whoever is writing SQL at 6 p.m.
The Star Schema: Facts and Dimensions
A star schema has two kinds of tables:
Fact tables record measurements of business events โ a sale happened, a payment cleared, a shipment left. Facts are narrow but enormously long: mostly numeric measures (amount, quantity) plus foreign keys to dimensions. They grow forever.
Dimension tables hold the descriptive context โ who, what, where, when. A product dimension carries name, brand, category, size. Dimensions are wide but comparatively short, and theyโre where humans filter and group.
Every business question now follows one shape: filter and group by dimension attributes, aggregate fact measures. โRevenue by brand by quarterโ is one join from fact_sales to dim_product and dim_date. The star is deliberately denormalized โ dim_product repeats the category name on every product row โ and thatโs fine, because dimensions are small, rebuilt by pipelines, and read a million times more often than theyโre written.
Grain: The Most Important Decision Youโll Make
The grain is the answer to โwhat does one row of the fact table represent?โ โ one order line? one order? one daily store total? Everything else in the design flows from this answer, and getting it wrong is nearly impossible to patch later.
The rule from decades of practice: declare the grain first, and choose the finest grain the source data supports. For retail sales, thatโs one row per product per order line. You can always aggregate a fine grain up to daily totals; you can never disaggregate a daily total back into transactions when someone asks โwhat time of day do we sell the most coffee?โ
Two mistakes to guard against:
- Mixed grain: a fact table where some rows are line items and others are order-level shipping fees. Every SUM over it is silently wrong. Shipping fees at order grain belong in a separate fact table.
- Facts that donโt match the grain: putting
order_totalon a line-item-grain row duplicates the total across lines, and the first analyst to SUM it triples your revenue.
Designing Dimensions Analysts Actually Enjoy
Good dimensions are what make a warehouse feel effortless. Practices that pay off:
Flatten hierarchies in. Put category and department directly on dim_product rather than making analysts join through a snowflaked chain of lookup tables. (The โsnowflake schemaโ โ normalizing dimensions โ saves trivial storage and costs real usability; Kimballโs advice to avoid it has aged well.)
Use surrogate keys. Dimension rows get warehouse-generated integer keys, not source-system IDs. This insulates the warehouse from source-system key reuse and โ critically โ makes dimension history possible, as weโll see below.
Build a real date dimension. A dim_date table with one row per calendar day, carrying month, quarter, fiscal period, week number, and holiday flags, turns painful date math into simple filters. Itโs the single highest-value table in any warehouse and takes an hour to generate.
Spell values out. status = 'Returned' beats status = 3. Storage is cheap; every decoded lookup an analyst doesnโt need is a small gift.
Slowly Changing Dimensions: Handling History Honestly
Hereโs the classic trap. A customer lives in Pune and places 50 orders. She moves to Mumbai. If you simply overwrite city on her dimension row, all 50 historical orders now report as Mumbai revenue โ youโve silently rewritten history, and last yearโs regional report no longer reproduces.
The industry patterns are numbered as SCD types:
- Type 1 โ overwrite. Update the row in place. History is lost, intentionally. Right for corrections (a typo in a name) and attributes where history is noise.
- Type 2 โ add a new row. Close the old row (
valid_to = today,is_current = false) and insert a new row with a new surrogate key. Facts recorded before the move keep pointing at the Pune-era row; facts after point to the Mumbai row. History is preserved perfectly. This is the default for attributes that matter to reporting. - Type 3 โ add a column. Keep
current_cityandprevious_citycolumns. Rarely useful outside โbefore vs. after reorganizationโ comparisons.
Type 2 is where surrogate keys earn their keep: the same real-world customer legitimately has multiple rows, distinguished by validity dates, and the fact tableโs foreign key pins each event to the version that was true at the time.
Decide the SCD treatment per attribute, with the business: โIf a customer changes segment, should last yearโs numbers move with them or stay put?โ is a policy question wearing a technical costume.
A Worked Grain Exercise
Suppose the business asks for: daily revenue by store, top products by margin, and repeat-purchase rate by signup channel. Working backwards:
- Repeat-purchase analysis needs customer identity per transaction โ grain must be transaction-level, not daily aggregate.
- Margin by product needs cost and price per line โ grain is the order line, with
quantity,net_amount, andcost_amountas measures. - Dimensions needed: date, product, store, customer (with signup channel โ Type 2 if channels get reclassified).
Ten minutes of working backwards from questions just prevented a month of remodeling. Make this exercise a habit: collect the questions before you design the schema.
Practical Guidance
- One star per business process (sales, returns, inventory) โ resist the mega-fact-table that tracks everything.
- Conform your dimensions: the same
dim_customershould serve the sales star and the support-tickets star, so โcustomerโ means one thing company-wide. - Document the grain in the tableโs description. Future-you will thank present-you.
- Donโt compute ratios in the fact table โ store additive components (revenue, cost) and let queries derive margin percentages. Ratios donโt aggregate; components do.
Dimensional modeling has survived thirty years of platform churn โ from on-prem Oracle to Snowflake and BigQuery โ because it models something more stable than technology: the shape of business questions. Later in this series weโll see how cloud warehouses bend some old rules (wide tables get cheaper, storage stops mattering) while the core ideas of grain, facts, and conformed dimensions remain exactly as load-bearing as ever.