powershelldba.de · Uwe Janke

Fact and Dimension Tables: How They Join Together, With a Real-World Example

What separates a fact table from a dimension table, why grain is the decision everything else depends on, and a full worked star-schema example: FactSales joined to DimDate, DimProduct, DimCustomer, and DimStore.

Every data warehouse conversation eventually lands on the same two words: fact and dimension. The concept is simple once it clicks, one table holds the numbers, the other tables describe the context around those numbers, but most explanations stay abstract enough that it never quite clicks. This one uses a single running example: a retail chain's sales data, built table by table, queried, and joined for real.

Fact vs. Dimension, in One Table

Fact table Dimension table
What it stores Measurements/events: amounts, quantities, counts Descriptive context: names, categories, attributes
Typical size Large, grows with every transaction Small to medium, grows slowly
Keys Mostly foreign keys pointing at dimensions One surrogate primary key per row
Changes over time Rows are appended, rarely updated Rows are updated as attributes change (see Slowly Changing Dimensions)
Example in this article FactSales, one row per sold order line DimDate, DimProduct, DimCustomer, DimStore

Grain: The One Decision Everything Else Depends On

Before any column is named, decide what one row in the fact table represents, that's the grain. "One row per order line" and "one row per order" sound similar and behave completely differently: the first lets you analyze individual products within an order, the second collapses that detail into an order total you can never get back. Get the grain wrong and every report built on top inherits the mistake; there's no query that reconstructs detail that was never stored.

For this example, the grain is: one row per product sold, per order, per store. That's specific enough to answer questions at the product level while still rolling up cleanly to order, store, month, or region totals.

The Schema

CREATE TABLE dbo.DimProduct
(
    ProductKey    INT           NOT NULL PRIMARY KEY,
    ProductName   VARCHAR(100)  NOT NULL,
    Category      VARCHAR(50)   NOT NULL,
    Subcategory   VARCHAR(50)   NOT NULL
);

CREATE TABLE dbo.DimCustomer
(
    CustomerKey   INT           NOT NULL PRIMARY KEY,
    CustomerName  VARCHAR(100)  NOT NULL,
    Segment       VARCHAR(30)   NOT NULL   -- Retail, Wholesale, Online
);

CREATE TABLE dbo.DimStore
(
    StoreKey      INT           NOT NULL PRIMARY KEY,
    StoreName     VARCHAR(50)   NOT NULL,
    Region        VARCHAR(50)   NOT NULL
);

-- DimDate: see "How to Build a Date Dimension Table"

CREATE TABLE dbo.FactSales
(
    DateKey           INT     NOT NULL REFERENCES dbo.DimDate(DateKey),
    ProductKey        INT     NOT NULL REFERENCES dbo.DimProduct(ProductKey),
    CustomerKey       INT     NOT NULL REFERENCES dbo.DimCustomer(CustomerKey),
    StoreKey          INT     NOT NULL REFERENCES dbo.DimStore(StoreKey),
    OrderLineNumber   INT     NOT NULL,
    Quantity          INT     NOT NULL,
    UnitPrice         MONEY   NOT NULL,
    DiscountAmount    MONEY   NOT NULL DEFAULT (0),
    SalesAmount       MONEY   NOT NULL   -- Quantity * UnitPrice - DiscountAmount
);
CREATE INDEX IX_FactSales_DateKey    ON dbo.FactSales(DateKey);
CREATE INDEX IX_FactSales_ProductKey ON dbo.FactSales(ProductKey);
CREATE INDEX IX_FactSales_StoreKey   ON dbo.FactSales(StoreKey);

Notice the fact table's own columns: only foreign keys, a line number, and numbers. No product name, no store name, nothing descriptive lives here, that's what the joins are for.

A Real Business Question

The question: "What was total sales amount and quantity sold, by month and product category, for stores in the North region during 2026?"
SELECT
    d.CalendarYear,
    d.MonthName,
    p.Category,
    SUM(f.SalesAmount)  AS TotalSalesAmount,
    SUM(f.Quantity)     AS TotalQuantity
FROM dbo.FactSales AS f
INNER JOIN dbo.DimDate    AS d ON f.DateKey    = d.DateKey
INNER JOIN dbo.DimProduct AS p ON f.ProductKey = p.ProductKey
INNER JOIN dbo.DimStore   AS s ON f.StoreKey   = s.StoreKey
WHERE d.CalendarYear = 2026
  AND s.Region = 'North'
GROUP BY d.CalendarYear, d.MonthName, d.MonthNumber, p.Category
ORDER BY d.MonthNumber, p.Category;

One fact table, three dimension joins (a fourth, DimCustomer, simply isn't needed for this particular question, and isn't included), a filter on two different dimensions, and a business-meaningful grouping. This is the entire point of the model: the question was asked in business language (region, category, month) and answered without a single line of application code.

CalendarYear MonthName Category TotalSalesAmount TotalQuantity
2026JanuaryBeverages48,210.503,912
2026JanuarySnacks21,340.002,105
2026FebruaryBeverages52,880.754,203
2026FebruarySnacks19,875.251,980

Architecture at a Glance

FactSales DateKey, ProductKey, CustomerKey, StoreKey Quantity, SalesAmount DimDate DateKey (PK) DimProduct ProductKey (PK) DimCustomer CustomerKey (PK) DimStore StoreKey (PK)

Types of Facts

Common Gotchas

The Bottom Line

A fact table is just numbers and foreign keys; the dimensions around it carry all the meaning. Decide the grain before anything else, keep the fact table free of descriptive columns, and every business question becomes the same shape of query: join the fact to whichever dimensions the question actually mentions, filter, group, sum. The retail example here is deliberately small, four dimensions, one fact table, but the pattern scales to warehouses with dozens of fact tables without changing.