Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 19 – SQL for data analysis

Star schemas: facts, dimensions and SCD type 2

Model sales as a fact table at a stated grain with dimension tables around it, write star-join reports, and keep customer history with SCD type 2.

  • Advanced
  • 35 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free intro, Pro: 8 of 35 minutes free
Free: A sales star built from the practice shop. With a pass: the other 10 sections and 3 interview answers, about 27 minutes.

What you will learn

  • Design a fact table at a stated grain with dimension tables around it
  • Write star-join queries that group facts by the columns of their dimensions
  • Build a date dimension with financial-year columns, integer keys and an unknown row
  • Combine two fact tables through a shared dimension without counting rows twice
  • Track a dimension's history with SCD type 2 and report by the value at the time of each fact

Before you start

What you’ll learn · Before you start

What you will learn

  • Design a fact table at a stated grain with dimension tables around it
  • Write star-join queries that group facts by the columns of their dimensions
  • Build a date dimension with financial-year columns, integer keys and an unknown row
  • Combine two fact tables through a shared dimension without counting rows twice
  • Track a dimension's history with SCD type 2 and report by the value at the time of each fact

Before you start

On this page

A star schema stores one business process, such as sales, as a fact table surrounded by dimension tables. Each row of the fact table records one event at a stated grain, for example one product line of one order, with its measures, the quantity and the amount, and with keys that point to the dimensions. The dimensions describe the event: who bought, what, when and where. Drawn, the fact table sits in the middle and the dimensions around it like the points of a star, and every report follows one pattern: join the fact to the dimensions it needs and group by their columns. Dimensional modelling is the method behind it: choose the process, declare the grain, then pick the dimensions and the facts. Dimensions also change slowly, and two classic answers exist: SCD type 1 overwrites the old value, while SCD type 2 keeps it in a row of its own with the dates it was valid.

A sales star built from the practice shop

The practice shop’s four tables, with the day of each order added, become a star of one fact table and three dimensions, and one star join answers “revenue by month and category”. The queries run on SQLite 3.49.1 in your browser:

The shop's tables loaded into a star, and a star-join report SQL · star_from_mart.sql
-- The source: the practice shop's tables, with the day of each order added.
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL);
CREATE TABLE products (product_id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL,
                       price_paise INTEGER NOT NULL);
CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, ordered_on TEXT NOT NULL,
                     status TEXT NOT NULL);
CREATE TABLE order_items (order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL,
                          PRIMARY KEY (order_id, product_id));
INSERT INTO customers VALUES
  (1, 'Asha Kulkarni', 'Pune'), (2, 'Vikram Nair', 'Kochi'), (3, 'Meera Rathore', 'Jaipur'), (4, 'Tenzin Dorjee', 'Gangtok');
INSERT INTO products VALUES
  (1, 'Basmati rice 1 kg', 'Grains', 14999), (2, 'Toor dal 500 g', 'Pulses', 8950),
  (3, 'Masala chai 250 g', 'Beverages', 12000), (4, 'Coconut oil 1 L', 'Oils', 21500), (5, 'Ragi flour 1 kg', 'Grains', 6800);
INSERT INTO orders VALUES
  (101, 1, '2026-04-03', 'delivered'), (102, 2, '2026-04-11', 'delivered'), (103, 1, '2026-04-28', 'delivered'),
  (104, 3, '2026-05-02', 'cancelled'), (105, 4, '2026-05-09', 'delivered'), (106, 2, '2026-05-20', 'delivered');
INSERT INTO order_items VALUES
  (101, 1, 2), (101, 2, 1), (102, 4, 1), (103, 3, 2), (103, 5, 1), (104, 2, 3), (105, 1, 1), (105, 3, 1), (106, 5, 4);

-- The star. Grain of fact_sales: one row per product line of a delivered order.
CREATE TABLE dim_date (date_key INTEGER PRIMARY KEY, full_date TEXT NOT NULL, month TEXT NOT NULL,
                       fy_quarter TEXT NOT NULL);
CREATE TABLE dim_customer (customer_key INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, city TEXT NOT NULL);
CREATE TABLE dim_product (product_key INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, category TEXT NOT NULL);
CREATE TABLE fact_sales (
  date_key     INTEGER NOT NULL REFERENCES dim_date (date_key),
  customer_key INTEGER NOT NULL REFERENCES dim_customer (customer_key),
  product_key  INTEGER NOT NULL REFERENCES dim_product (product_key),
  order_id     INTEGER NOT NULL,       -- kept for drilling down to the order
  quantity     INTEGER NOT NULL,
  amount_paise INTEGER NOT NULL
);

-- Every day of April and May, keyed as the number yyyymmdd.
INSERT INTO dim_date
WITH RECURSIVE days (d) AS (
  SELECT '2026-04-01' UNION ALL SELECT date(d, '+1 day') FROM days WHERE d < '2026-05-31'
)
SELECT cast(strftime('%Y%m%d', d) AS INTEGER), d, strftime('%Y-%m', d),
       printf('FY%d-%02d Q%d', y - (m < 4), (y - (m < 4) + 1) % 100, (m + 8) % 12 / 3 + 1)
FROM (SELECT d, cast(strftime('%Y', d) AS INTEGER) AS y, cast(strftime('%m', d) AS INTEGER) AS m FROM days);
INSERT INTO dim_customer (customer_id, city) SELECT customer_id, city FROM customers;
INSERT INTO dim_product (product_id, category) SELECT product_id, category FROM products;
INSERT INTO fact_sales
SELECT cast(strftime('%Y%m%d', o.ordered_on) AS INTEGER), c.customer_key, p.product_key, o.order_id,
       i.quantity, i.quantity * s.price_paise
FROM orders AS o
JOIN order_items AS i  ON i.order_id = o.order_id
JOIN products AS s     ON s.product_id = i.product_id
JOIN dim_customer AS c ON c.customer_id = o.customer_id
JOIN dim_product AS p  ON p.product_id = i.product_id
WHERE o.status = 'delivered';

SELECT count(*) AS fact_rows, sum(quantity) AS units, sum(amount_paise) / 100.0 AS revenue_rupees FROM fact_sales;

-- A star join: the fact, two of its dimensions, and a GROUP BY on their columns.
SELECT d.month, p.category, sum(f.quantity) AS units, sum(f.amount_paise) / 100.0 AS revenue_rupees
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
GROUP BY d.month, p.category
ORDER BY d.month, p.category;

Output

┌───────────┬───────┬────────────────┐
│ fact_rows │ units │ revenue_rupees │
├───────────┼───────┼────────────────┤
│ 8         │ 13    │ 1454.47        │
└───────────┴───────┴────────────────┘
┌─────────┬───────────┬───────┬────────────────┐
│  month  │ category  │ units │ revenue_rupees │
├─────────┼───────────┼───────┼────────────────┤
│ 2026-04 │ Beverages │ 2     │ 240.0          │
│ 2026-04 │ Grains    │ 3     │ 367.98         │
│ 2026-04 │ Oils      │ 1     │ 215.0          │
│ 2026-04 │ Pulses    │ 1     │ 89.5           │
│ 2026-05 │ Beverages │ 1     │ 120.0          │
│ 2026-05 │ Grains    │ 5     │ 421.99         │
└─────────┴───────────┴───────┴────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < star_from_mart.sql

The fact table has 8 rows, one per product line of the five delivered orders; the cancelled order is left out while loading, so no report has to remember to exclude it. Each fact row holds numbers that add up, units and paise, and three keys. The report joins the fact to two dimensions, groups by their columns and sums the measures: grains brought in 367.98 rupees in April. The order id stays in the fact as a plain column, so any figure can be traced back to its orders.

A star schema: the fact table fact_sales in the middle, linked by keys to dim_date, dim_customer and dim_product.dim_datedate_keyINTEGERPKfull_dateTEXTmonthTEXTfy_quarterTEXTdim_customercustomer_keyINTEGERPKcustomer_idINTEGERcityTEXTfact_salesdate_keyINTEGERFKcustomer_keyINTEGERFKproduct_keyINTEGERFKorder_idINTEGERquantityINTEGERamount_paiseINTEGERdim_productproduct_keyINTEGERPKproduct_idINTEGERcategoryTEXT

One fact table and three dimensions

Text description of the diagram

The diagram shows the four tables of the star built in the first example.

1. In the middle, fact_sales has one row per product line of a delivered order. Three of its columns are keys of the dimensions, date_key, customer_key and product_key; the others are the order's id and the measures, quantity and amount_paise. 2. Above it, dim_date has one row per day, keyed by date_key, with the month and the quarter of the financial year, and dim_customer has one row per customer, keyed by customer_key, with the customer's id from the shop and the city. 3. Below it, dim_product has one row per product, keyed by product_key, with the product's id and category.

Each line joins a key of the fact table to the key of one dimension. A report joins the fact to the dimensions it needs and groups by their columns.

A few ideas make such a star correct and lasting, each in one sentence:

  • The grain says what one fact row is, and every measure must be true at that grain.
  • Surrogate keys are integers the warehouse assigns, so that one customer can have several dimension rows.
  • A date dimension holds one row per day with its week, month, quarter and financial year.
  • Conformed dimensions are shared by several fact tables, so their figures can be set side by side.
  • SCD type 1 overwrites a changed attribute, and history then shows the new value everywhere.
  • SCD type 2 adds a row per change with valid_from, valid_to and is_current, so each fact keeps the value of its time.
  • A snowflake schema splits dimensions into further normalised tables, at the cost of more joins.
  • Semi-additive measures, such as balances, add up across shops or accounts but not across days.

Declaring the grain, and what a mixed grain does to a total Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • A date dimension with financial-year columns and integer keys
  • Two fact tables: drilling across instead of joining facts
  • SCD type 2 for customers who move city
  • Point-in-time lookups for late facts, and the checks a type 2 table needs
  • Snowflakes, and measures that must not be summed over time
  • How the engines differ
  • Your turn
    • Sales by the city a customer lived in on the day of each order
  • Check yourself
  • Interview questions
    • What is the grain of a fact table, and why do you state it before choosing its columns?
    • Design a star schema for an online grocery's orders: each order has product lines, one delivery fee, and an order-level discount coupon. Revenue by category and delivery fees by city must both add up to what was charged. What is the grain, and where do the fee and the discount go?
    • Customers move between cities. Finance wants sales by the city a customer lived in at the time of each sale, marketing wants sales by the customer's city today, and some orders arrive days late. Design the customer dimension and the load, and write both reports.
    • A monthly report joins the sales fact table and the returns fact table through the product dimension and shows return rates that are far too high, and some products are missing. Explain what happened and rewrite the report.
  • Key takeaways

5 runnable examples (SQL) · 1 exercise with sample tests · 3 interview answers · about 23 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.