Skip to content

Grokem · https://www.grokem.com/data/data-management/data-modeling/star-schema-sales-reporting

Design a star schema for sales reporting

Turn a flat sales export into one fact table and three dimensions, decide the grain, and answer questions with a single join pattern.

Grokem Editorial

Editorial desk

Published
  • Intermediate
  • 35 min to complete
Tested withSQLite3.42

Before you start

  • SQL joins and GROUP BY
  • A sales or event dataset you want to report on (a toy one is provided)

A star schema arranges reporting data as one central fact table (the things that happened, with numbers) surrounded by dimension tables (the things you describe and filter them by). Most dashboards and BI tools expect this shape because it keeps queries simple and fast.

Step 1: Decide the grain

The grain is the meaning of one row in the fact table. Choose it first and write it down in a sentence, because everything else follows from it.

One row per product on one sales order line.

A grain that is too coarse (one row per day) throws away detail you cannot get back. A grain that is too fine costs storage and time. Order-line level is the usual choice for sales.

Step 2: List the facts and the dimensions

Look at what you want to report and what you want to slice it by.

  • Facts are numeric and additive: quantity and net amount.
  • Dimensions answer who, what, when and where: date, product and customer.

Keep descriptive text out of the fact table. If a column is a label or a category, it belongs in a dimension.

Step 3: Create the tables

Each dimension gets a surrogate key (a meaningless integer) plus the business key from the source system. The fact table holds only keys and measures.

sql
CREATE TABLE dim_date (
    date_key     INTEGER PRIMARY KEY,   -- 20250131
    full_date    TEXT NOT NULL,
    year         INTEGER NOT NULL,
    month        INTEGER NOT NULL,
    month_name   TEXT NOT NULL,
    is_weekend   INTEGER NOT NULL
);
CREATE TABLE dim_product (
    product_key  INTEGER PRIMARY KEY,
    sku          TEXT NOT NULL UNIQUE,
    product_name TEXT NOT NULL,
    category     TEXT NOT NULL
);
CREATE TABLE dim_customer (
    customer_key INTEGER PRIMARY KEY,
    customer_id  TEXT NOT NULL UNIQUE,
    country      TEXT NOT NULL,
    segment      TEXT NOT NULL
);
CREATE TABLE fact_sales (
    sale_id       INTEGER PRIMARY KEY,
    date_key      INTEGER NOT NULL REFERENCES dim_date (date_key),
    product_key   INTEGER NOT NULL REFERENCES dim_product (product_key),
    customer_key  INTEGER NOT NULL REFERENCES dim_customer (customer_key),
    quantity      INTEGER NOT NULL,
    net_amount    REAL    NOT NULL
);

A date dimension looks like overkill until you need "weekends", "fiscal quarter" or "month name" in a report. Putting those in a table once is far easier than computing them in every query.

Step 4: Load a little data

Load the dimensions first, then the fact table, so every key already exists.

sql
INSERT INTO dim_date VALUES
 (20250105, '2025-01-05', 2025, 1, 'January', 1),
 (20250106, '2025-01-06', 2025, 1, 'January', 0),
 (20250207, '2025-02-07', 2025, 2, 'February', 0);
INSERT INTO dim_product VALUES
 (1, 'NB-01', 'Notebook', 'Stationery'), (2, 'PN-01', 'Pen', 'Stationery'), (3, 'LP-01', 'Desk lamp', 'Furniture');
INSERT INTO dim_customer VALUES
 (1, 'C-100', 'DE', 'Business'), (2, 'C-200', 'FR', 'Consumer'), (3, 'C-300', 'DE', 'Consumer');
INSERT INTO fact_sales (date_key, product_key, customer_key, quantity, net_amount) VALUES
 (20250105, 1, 1, 10, 45.0), (20250105, 3, 2, 1, 24.0), (20250106, 2, 1, 50, 60.0),
 (20250207, 3, 3, 2, 48.0),  (20250207, 1, 2, 3, 13.5);

Step 5: Query it

Every question has the same shape: join the fact to the dimensions you need, group by their attributes, aggregate the measures.

sql
SELECT d.year, d.month_name, p.category, c.country,
       SUM(f.quantity)               AS units,
       ROUND(SUM(f.net_amount), 2)   AS revenue
FROM fact_sales AS f
JOIN dim_date     AS d ON d.date_key     = f.date_key
JOIN dim_product  AS p ON p.product_key  = f.product_key
JOIN dim_customer AS c ON c.customer_key = f.customer_key
GROUP BY d.year, d.month, d.month_name, p.category, c.country
ORDER BY d.year, d.month, p.category, c.country;

The result:

year month_name category country units revenue
2025 January Furniture FR 1 24.0
2025 January Stationery DE 60 105.0
2025 February Furniture DE 2 48.0
2025 February Stationery FR 3 13.5

Adding "by segment" or "by weekday" means adding one column to the SELECT and GROUP BY. The joins never change.

Step 6: Check the model

Two cheap checks catch most mistakes.

sql
PRAGMA foreign_key_check;                              -- no rows: every key matches a dimension row
SELECT COUNT(*), COUNT(DISTINCT sale_id) FROM fact_sales;   -- both numbers equal: the grain holds

In SQLite PRAGMA foreign_key_check lists violations, so an empty result is what you want. On a real warehouse, compare the fact table's total revenue with the source system's total: if they differ, a join is dropping or duplicating rows.

Design rules worth keeping

  • Declare the grain and never mix grains in one fact table.
  • Measures are numbers you can add up. Averages and ratios are calculated from measures at query time, not stored.
  • Dimensions are wide and descriptive. Denormalising a product's category into dim_product is intended. It saves a join on every query.
  • Use an explicit "Unknown" member so a late-arriving or missing value does not break a join.

Expected result

Four tables, a join query that reports units and revenue by month, category and country, and two integrity checks that return clean results.

Troubleshooting

ProblemLikely causeFix
Revenue is higher than the source total after joiningA dimension has two rows for the same key (for example a customer who changed address), so the join duplicates fact rows.Make the join key unique in the dimension. Add a UNIQUE constraint on the business key, and check COUNT(*) before and after the join.
A fact row has no matching dimension rowThe fact was loaded before the dimension, or the source uses a value the dimension has never seen.Load dimensions first, and map unknown values to an explicit 'Unknown' member rather than leaving the key NULL.

Frequently asked questions

Is a star schema only for warehouses?

It suits any reporting workload. Operational databases are usually normalised for correct writes; a star schema is arranged for fast, simple reads.

What about slowly changing dimensions?

When an attribute such as a customer's segment changes, you either overwrite it (type 1) or add a new dated row (type 2) so history stays correct. Decide this per attribute, not per table.

Next steps

  • Add a dim_store dimension and a second fact table for returns
  • Add effective dates to dim_customer to track history
  • Build the same model in your warehouse and compare query plans

References

From Grokem: https://www.grokem.com/data/data-management/data-modeling/star-schema-sales-reporting

How did this strike you?

Be the first to react – it is anonymous.

Community

Questions about this?

Ask in Questions and help and get help from other readers.

Ask a questionBrowse Questions and help

Comments

No comments yet. Share what worked for you, or ask a question.

Choose what we may use. You can change this at any time with “Privacy choices” at the foot of every page. Details are in the Cookie policy and the Privacy policy.

Strictly necessary

Remembers your theme, the lessons you marked as done and this choice. It stays on your device, tracks nothing and cannot be switched off.

Always on
What exactly is collected, and why

Grokem’s own counts

Collects
The pages you open and how long you stay, how far you scroll, where you came from, your device type, browser, screen size, language and time zone. Your IP address is turned into a code that changes every day; it is never stored as it is.
Why
To see how long lessons are read and where readers leave. Not shared with anyone.
Stored
A random visitor ID, a visit ID and the time of your last page view in your browser’s storage (gr_vid, gr_sid, gr_last). No name or email is attached.