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.

Database Management Systems (DBMS)  Module 14 – Storage engines, NoSQL and analytics

Data warehousing, star schemas and OLAP

Design star schemas with a stated grain, classify measures as distributive, algebraic or holistic, and roll up with GROUPING SETS, ROLLUP and CUBE.

  • Intermediate
  • 35 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free core, Pro depth: 12 of 35 minutes free
Free: A star schema, starting from the grain · OLAP operations in SQL · Measures: distributive, algebraic and holistic · 2 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 23 minutes.

What you will learn

  • Design star, snowflake and fact-constellation schemas with a stated grain
  • Classify measures as distributive, algebraic or holistic and compute roll-ups correctly
  • Apply roll-up, drill-down, slice, dice and pivot in SQL, with GROUPING SETS, ROLLUP and CUBE or their emulation
  • Handle slowly changing dimensions and concept hierarchies

Before you start

What you’ll learn · Before you start

What you will learn

  • Design star, snowflake and fact-constellation schemas with a stated grain
  • Classify measures as distributive, algebraic or holistic and compute roll-ups correctly
  • Apply roll-up, drill-down, slice, dice and pivot in SQL, with GROUPING SETS, ROLLUP and CUBE or their emulation
  • Handle slowly changing dimensions and concept hierarchies

Before you start

On this page

Data warehousing is the practice of copying data from the systems that run a business into a separate database built for analysis, where it is cleaned, integrated and kept with its history. Chaudhuri and Dayal’s survey describes the warehouse as kept apart from the operational databases and designed for decision support (Chaudhuri and Dayal). Inside it, star schemas are the usual design: one fact table of measurements, such as sales, surrounded by dimension tables that describe them, such as date, shop and product. OLAP, online analytical processing, is the kind of querying a warehouse serves: totals and averages grouped by the dimensions, rolled up to coarser levels or drilled down to finer ones. Where an operational database answers “what is in order 1001?” thousands of times a second, a warehouse answers “revenue by state and month, this year against last” over millions of rows.

A star schema, starting from the grain

The first decision is the grain: what one row of the fact table records. The Kimball Group calls declaring it the pivotal step of a dimensional design and says it must come before choosing dimensions or facts (Kimball Group: grain). For a chain of six shops, a grain of “one product, one shop, one day” gives this star:

A star schema: the fact table fact_sales with keys to three dimension tables, dim_date, dim_store and dim_product, and two measures.fact_sales (one product, one shop, one day)date_keyINTEGER, key of dim_datePK, FKstore_keyINTEGER, key of dim_storePK, FKproduct_keyINTEGER, key of dim_productPK, FKqtyINTEGER, a measureamountINTEGER, a measuredim_date (time hierarchy)date_keyINTEGERPKdayTEXTmonthTEXTquarterTEXTyearINTEGERdim_store (place hierarchy)store_keyINTEGERPKstoreTEXTcityTEXTstateTEXTdim_productproduct_keyINTEGERPKproductTEXTcategoryTEXTpriceINTEGER

The star schema of the lesson's chain of shops

Text description of the diagram

The figure shows four tables, one under the other.

  1. At the top, the fact table fact_sales. Its grain is one row per product, per shop, per day. Its primary key is made of three foreign keys, date_key, store_key and product_key, each the key of one dimension table, and it holds two measures, qty (the quantity sold) and amount (the money taken).
  2. dim_date describes each day with its day, month, quarter and year: the levels of the time hierarchy.
  3. dim_store describes each shop with its store code, city and state: the levels of the place hierarchy.
  4. dim_product describes each product with its name, category and price.

Every query joins the fact table to the dimensions it needs and groups by their columns, for example revenue by state and month.

The fact table holds keys to the dimensions and two measures, the quantity and the amount. Each dimension holds the attributes people group and filter by, and those attributes form concept hierarchies: day, month, quarter and year for time; shop, city and state for place. Two variations have their own names:

  • A snowflake schema normalises some dimensions into further tables, for example city and state moved out of dim_store into their own tables.
  • A fact constellation has several fact tables that share dimensions, for example sales and stock levels both keyed by date, shop and product.

Star schemas stay the default because each query is one join from the fact table to every dimension it needs.

OLAP operations in SQL

Each OLAP operation is a way of choosing which dimensions and levels to group by:

  • Roll-up moves up a hierarchy: from city to state to the whole country.
  • Drill-down moves the other way, from a state to its cities to its shops.
  • Slice fixes one dimension to one value, such as only the Beverages category.
  • Dice keeps a sub-cube: several values on several dimensions at once.
  • Pivot turns one dimension’s values into columns, such as one column per month.

SQL has three operators for producing several groupings in one query. GROUPING SETS lists the groupings wanted, ROLLUP produces every prefix of its list of columns, and CUBE produces every combination of them (PostgreSQL 18: table expressions). PostgreSQL supports all three; SQLite supports none, so the same roll-up is written as one GROUP BY per level, stacked with UNION ALL:

A star schema, a roll-up that SQLite refuses, the same roll-up by hand, and a pivot SQL · star_rollup.sql
-- A star schema for a chain of six shops: one fact table at the grain "one product, one shop, one day",
-- and three dimension tables around it.
CREATE TABLE dim_store (store_key INTEGER PRIMARY KEY, store TEXT, city TEXT, state TEXT);
INSERT INTO dim_store VALUES (1, 'S1', 'Pune', 'Maharashtra'), (2, 'S2', 'Mumbai', 'Maharashtra'),
  (3, 'S3', 'Nagpur', 'Maharashtra'), (4, 'S4', 'Bengaluru', 'Karnataka'), (5, 'S5', 'Mysuru', 'Karnataka'),
  (6, 'S6', 'Kochi', 'Kerala');
CREATE TABLE dim_product (product_key INTEGER PRIMARY KEY, product TEXT, category TEXT, price INTEGER);
INSERT INTO dim_product VALUES (1, 'Rice 5 kg', 'Staples', 520), (2, 'Wheat flour 10 kg', 'Staples', 480),
  (3, 'Tea 500 g', 'Beverages', 260), (4, 'Coffee 200 g', 'Beverages', 310), (5, 'Soap 4-pack', 'Personal care', 180);
CREATE TABLE dim_date (date_key INTEGER PRIMARY KEY, day TEXT, month TEXT, quarter TEXT, year INTEGER);
WITH RECURSIVE d(n) AS (SELECT 0 UNION ALL SELECT n + 1 FROM d WHERE n < 90)  -- 91 days: April to June
INSERT INTO dim_date
SELECT CAST(strftime('%Y%m%d', '2025-04-01', '+' || n || ' days') AS INTEGER), date('2025-04-01', '+' || n || ' days'),
       strftime('%Y-%m', '2025-04-01', '+' || n || ' days'), '2025-Q2', 2025
FROM d;
CREATE TABLE fact_sales (
  date_key INTEGER REFERENCES dim_date, store_key INTEGER REFERENCES dim_store,
  product_key INTEGER REFERENCES dim_product, qty INTEGER, amount INTEGER,
  PRIMARY KEY (date_key, store_key, product_key));
-- Made-up but repeatable quantities: bigger shops sell more (the factor after the remainder).
INSERT INTO fact_sales
SELECT date_key, store_key, product_key, qty, qty * price FROM (
  SELECT d.date_key, s.store_key, p.product_key, p.price,
         (1 + (d.date_key * 31 + s.store_key * 17 + p.product_key * 7) % 9)
           * CASE s.store_key WHEN 2 THEN 5 WHEN 4 THEN 4 WHEN 1 THEN 3 WHEN 6 THEN 3 ELSE 2 END AS qty
  FROM dim_date d, dim_store s, dim_product p);
SELECT count(*) AS fact_rows, count(DISTINCT date_key) AS days FROM fact_sales;

-- Roll-up with subtotals: PostgreSQL writes GROUP BY ROLLUP (state, city). SQLite has no ROLLUP:
SELECT state, city, sum(amount) FROM fact_sales JOIN dim_store USING (store_key) GROUP BY ROLLUP (state, city);

-- The same roll-up in SQLite: one GROUP BY per level, stacked with UNION ALL.
SELECT state, city, revenue FROM (
  SELECT state, city, sum(amount) AS revenue, 0 AS lvl FROM fact_sales JOIN dim_store USING (store_key) GROUP BY state, city
  UNION ALL
  SELECT state, '(all cities)', sum(amount), 1 FROM fact_sales JOIN dim_store USING (store_key) GROUP BY state
  UNION ALL
  SELECT '(all states)', '(all cities)', sum(amount), 2 FROM fact_sales)
ORDER BY lvl = 2, state, lvl, city;

-- Slice (one category), then pivot: months become columns.
SELECT s.state,
       sum(CASE WHEN d.month = '2025-04' THEN f.amount END) AS apr,
       sum(CASE WHEN d.month = '2025-05' THEN f.amount END) AS may,
       sum(CASE WHEN d.month = '2025-06' THEN f.amount END) AS jun
FROM fact_sales f JOIN dim_store s USING (store_key) JOIN dim_date d USING (date_key)
JOIN dim_product p USING (product_key)
WHERE p.category = 'Beverages'
GROUP BY s.state ORDER BY s.state;

Output (exit status 1)

┌───────────┬──────┐
│ fact_rows │ days │
├───────────┼──────┤
│ 2730      │ 91   │
└───────────┴──────┘
┌──────────────┬──────────────┬──────────┐
│    state     │     city     │ revenue  │
├──────────────┼──────────────┼──────────┤
│ Karnataka    │ Bengaluru    │ 3179120  │
│ Karnataka    │ Mysuru       │ 1593800  │
│ Karnataka    │ (all cities) │ 4772920  │
│ Kerala       │ Kochi        │ 2380860  │
│ Kerala       │ (all cities) │ 2380860  │
│ Maharashtra  │ Mumbai       │ 3979700  │
│ Maharashtra  │ Nagpur       │ 1590900  │
│ Maharashtra  │ Pune         │ 2392800  │
│ Maharashtra  │ (all cities) │ 7963400  │
│ (all states) │ (all cities) │ 15117180 │
└──────────────┴──────────────┴──────────┘
┌─────────────┬────────┬────────┬────────┐
│    state    │  apr   │  may   │  jun   │
├─────────────┼────────┼────────┼────────┤
│ Karnataka   │ 516060 │ 535500 │ 519840 │
│ Kerala      │ 261180 │ 261030 │ 256050 │
│ Maharashtra │ 843690 │ 886090 │ 846120 │
└─────────────┴────────┴────────┴────────┘

Printed as an error (standard error)

Parse error near line 30: no such function: ROLLUP

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

The roll-up shows the three levels in one result: six cities, three state subtotals and the grand total of 15,117,180. The last query slices the cube to Beverages and pivots the three months into columns.

Measures: distributive, algebraic and holistic

Roll-ups are only correct if each measure is combined the right way, and Gray and colleagues sorted aggregate functions into three classes for exactly this reason (Gray et al.):

  • Distributive measures, such as SUM, COUNT, MIN and MAX, combine from the answers for the parts.
  • Algebraic measures, such as the average, combine from a fixed number of distributive values per part: a sum and a count.
  • Holistic measures, such as the median and the mode, need all the underlying data; no fixed summary of the parts is enough.

The most common mistake follows from the second line: averaging averages.

A state's average order value, three ways SQL · average_of_averages.sql
-- Orders from two cities of one state: many small ones in Pune, two large ones in Nagpur.
CREATE TABLE orders (id INTEGER PRIMARY KEY, city TEXT NOT NULL, amount INTEGER NOT NULL);
INSERT INTO orders VALUES (1, 'Pune', 300), (2, 'Pune', 250), (3, 'Pune', 350), (4, 'Pune', 300),
  (5, 'Pune', 300), (6, 'Pune', 300), (7, 'Nagpur', 1200), (8, 'Nagpur', 1000);

-- The partials a summary table keeps per city.
SELECT city, count(*) AS orders, sum(amount) AS total, avg(amount) AS average FROM orders GROUP BY city ORDER BY city;

-- The state's average order value, three ways.
SELECT (SELECT avg(average) FROM (SELECT avg(amount) AS average FROM orders GROUP BY city)) AS average_of_averages,
       (SELECT sum(total) * 1.0 / sum(n) FROM (SELECT sum(amount) AS total, count(*) AS n FROM orders GROUP BY city))
         AS from_sums_and_counts,
       (SELECT avg(amount) FROM orders) AS over_all_orders;

Output

┌────────┬────────┬───────┬─────────┐
│  city  │ orders │ total │ average │
├────────┼────────┼───────┼─────────┤
│ Nagpur │ 2      │ 2200  │ 1100.0  │
│ Pune   │ 6      │ 1800  │ 300.0   │
└────────┴────────┴───────┴─────────┘
┌─────────────────────┬──────────────────────┬─────────────────┐
│ average_of_averages │ from_sums_and_counts │ over_all_orders │
├─────────────────────┼──────────────────────┼─────────────────┤
│ 700.0               │ 500.0                │ 500.0           │
└─────────────────────┴──────────────────────┴─────────────────┘

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

Pune’s six orders average 300 and Nagpur’s two average 1,100. Their plain average, 700, weights each city equally, but the state’s real average is 4,000 divided by 8 orders, 500, which the sums and counts give back exactly. An average of averages is right only when every group has the same number of rows.

GATE DA

The GATE 2027 syllabus for Data Science and Artificial Intelligence lists, under Database Management and Warehousing, “data warehouse modelling: schema for multidimensional data models, concept hierarchies, measures: categorization and computations” (GATE 2027 DA syllabus). The three measure classes and the roll-up rules of this lesson are those categories and computations.

Exercise

Exercise · Easy · SQL

Roll a city summary up to states, average included

A summary table city_summary (state, city, orders, revenue) holds one row per city: its number of orders and its revenue. Write one query that rolls it up to states, with four columns: state, orders, revenue and avg_order_value, the state's average order value rounded to 2 decimals. Leave out any state with no orders.

The starter query runs, but its average is wrong for most states. Run the sample tests to see why, then fix it.

Starter code · state_rollup.sql

-- Roll the city summary up to states: state, orders, revenue, avg_order_value (2 decimals).
SELECT state, sum(orders) AS orders, sum(revenue) AS revenue,
       round(avg(revenue * 1.0 / orders), 2) AS avg_order_value
FROM city_summary
GROUP BY state;
The sample tests · tests.yaml
# Sample tests: each one builds the table, runs your query on it and compares the rows it returns (in any order).
tests:
  - name: "cities of very different sizes"
    setup: |
      CREATE TABLE city_summary (state TEXT NOT NULL, city TEXT NOT NULL, orders INTEGER NOT NULL, revenue INTEGER NOT NULL);
      INSERT INTO city_summary VALUES
        ('Maharashtra', 'Pune', 6, 1800), ('Maharashtra', 'Nagpur', 2, 2200),
        ('Karnataka', 'Mysuru', 3, 1000), ('Karnataka', 'Bengaluru', 9, 4500);
    expected:
      columns: [state, orders, revenue, avg_order_value]
      rows:
        - [Maharashtra, 8, 4000, 500.0]
        - [Karnataka, 12, 5500, 458.33]
  - name: "a state with one city"
    setup: |
      CREATE TABLE city_summary (state TEXT NOT NULL, city TEXT NOT NULL, orders INTEGER NOT NULL, revenue INTEGER NOT NULL);
      INSERT INTO city_summary VALUES ('Kerala', 'Kochi', 7, 2450);
    expected:
      columns: [state, orders, revenue, avg_order_value]
      rows:
        - [Kerala, 7, 2450, 350.0]
  - name: "an average that is not a whole number, and a state with no orders"
    setup: |
      CREATE TABLE city_summary (state TEXT NOT NULL, city TEXT NOT NULL, orders INTEGER NOT NULL, revenue INTEGER NOT NULL);
      INSERT INTO city_summary VALUES ('Goa', 'Panaji', 2, 700), ('Goa', 'Margao', 1, 300), ('Sikkim', 'Gangtok', 0, 0);
    expected:
      columns: [state, orders, revenue, avg_order_value]
      rows:
        - [Goa, 3, 1000, 333.33]
A hint

The average order value is an algebraic measure: it is built from two distributive ones. Add up the orders and the revenue of the cities first, then divide the two sums. Averaging each city's average gives every city the same weight, whatever its number of orders.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A report shows the median delivery time per city and per state. Which class of measure is the median?

    Choose one answer.

    Show the answer to question 1

    Answer: Holistic

    No fixed set of numbers per city is enough to compute the state's median: it needs all the delivery times, so the median is holistic.

  2. Question 2 of 5 Which class of measure is the average basket value, and what must each group store so that it rolls up?

    Choose one answer.

    Show the answer to question 2

    Answer: Algebraic; each group stores its sum and its count

    An average is computed from two distributive values, a sum and a count. Adding the sums and the counts of the groups and dividing gives the exact average of the whole.

  3. Question 3 of 5 City A has 10 orders with an average of 500, and city B has 30 orders with an average of 300. What is the average order value of the two cities together?

    Type a number.

    Show the answer to question 3

    Answer: 350 rupees

    The sums are 10 × 500 = 5,000 and 30 × 300 = 9,000, so the average is 14,000 ÷ 40 = 350. Averaging the two averages would give 400, which weights the 10 orders of city A as much as the 30 of city B.

  4. Question 4 of 5 A sales report by city is changed to show the same totals by state instead. Which OLAP operation is that?

    Choose one answer.

    Show the answer to question 4

    Answer: Roll-up

    Moving from city to state goes up the place hierarchy to a coarser level, which is a roll-up. Going from state to city would be a drill-down.

  5. Question 5 of 5 What does the grain of a fact table state?

    Choose one answer.

    Show the answer to question 5

    Answer: What exactly one row of the fact table records, such as one product in one shop on one day

    The grain is the contract for every row. It is decided first, and every dimension and measure must be consistent with it.

Rolling up every kind of measure correctly, with numbers Pro: opens with a Pro, Premium or Ultimate pass

A summary table or a cube is only as good as the partials it stores, and the wrong partial makes every roll-up above it wrong. This part rebuilds distributive and algebraic measures from stored partials and checks them against direct answers, reproduces two holistic measures that refuse to roll up, and turns the result into a storage rule.

In this part:

  • Distributive and algebraic measures rebuilt from partials
  • Holistic measures that refuse to roll up, reproduced
  • What a summary table should store, measure by measure

1 runnable example (SQL) · about 2 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).

GROUPING SETS, ROLLUP and CUBE: expansions, row counts and the NULL trap Pro: opens with a Pro, Premium or Ultimate pass

Multi-level groupings save a query per level, but they also mark subtotal rows with NULLs that look like data. This part counts what each operator expands to, emulates a cube in SQLite with the flag PostgreSQL computes, and reproduces the report that mistakes a NULL in the data for a subtotal.

In this part:

  • What each operator expands to, counted
  • A cube emulated in SQLite, with PostgreSQL’s grouping flag
  • A subtotal mistaken for data: the NULL trap

1 runnable example (SQL) · about 3 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).

Slowly changing dimensions and concept hierarchies, worked Pro: opens with a Pro, Premium or Ultimate pass

Dimensions change and hierarchies are less tidy than they look, and both silently change last year’s numbers if the design ignores them. This part reproduces a regional report rewritten by a type 1 dimension and kept by type 2, rolls up a time hierarchy correctly, and shows the week that belongs to two months.

In this part:

  • Type 1 against type 2, on a shop that changes region
  • Concept hierarchies, rolled up and drilled down
  • Star or snowflake, decided

2 runnable examples (SQL) · about 3 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).

Interview questions

  1. Fresher to mid · Warm-up

    What is the grain of a fact table, and why is it decided before the dimensions and measures?

    The grain states exactly what one row of the fact table records, for example one product sold in one shop on one day, or one line of one order. It is decided first because everything else must agree with it: each dimension must have one value per row at that grain, and each measure must mean something at that level. A table whose rows mix grains, some daily totals and some single sales, double-counts as soon as it is summed. The finest grain a business process records is the safest choice, because coarser totals can always be computed from it, while finer detail can never be recovered from totals.

  2. Fresher to mid

    A dashboard shows each region's average order value as the average of its cities' averages, and finance's figure for the same region is lower. Which number is right, and how should the warehouse store this measure so that every roll-up is correct?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    Last year's sales by region changed this morning, although no sale was edited: two shops were moved to a new region overnight. Explain what the warehouse did, and how you would model the shop dimension so that both questions, as it was and as it is now, have an answer.

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    Design a star schema for a food-delivery platform's orders, so that analysts can ask for revenue, delivery times and cancellations by city, restaurant, hour and cuisine. State the grain, the fact tables, their measures and the dimensions.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Data warehousing keeps integrated history for analysis in a database apart from the systems that run the business.
  • A star schema puts measures in a fact table at a stated grain and describes them in dimension tables.
  • Roll-up, drill-down, slice, dice and pivot choose which dimensions and levels to group by; PostgreSQL has GROUPING SETS, ROLLUP and CUBE, and SQLite needs one GROUP BY per level.
  • Distributive measures combine from the parts’ answers, algebraic ones from a few numbers per part, and holistic ones only from all the data: never average averages.

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.