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.
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:
The star schema of the lesson's chain of shops
Text description of the diagram
The figure shows four tables, one under the other.
- 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).
- dim_date describes each day with its day, month, quarter and year: the levels of the time hierarchy.
- dim_store describes each shop with its store code, city and state: the levels of the place hierarchy.
- 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_storeinto 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 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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,MINandMAX, 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.
-- 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
-
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.
-
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?
-
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.
-
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.
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
- J. Gray et al.: Data Cube: A Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals (Data Mining and Knowledge Discovery (Springer))
- S. Chaudhuri and U. Dayal: An overview of data warehousing and OLAP technology (ACM SIGMOD Record)
- PostgreSQL 18 Documentation: Table Expressions (GROUPING SETS, CUBE and ROLLUP) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Aggregate Functions (GROUPING) (The PostgreSQL Global Development Group)
- Kimball Group: Grain (Kimball Group)
- Kimball Group: Additive, Semi-Additive, and Non-Additive Facts (Kimball Group)
- Kimball Group: Type 2: Add New Row (Kimball Group)
- SQLite: Date And Time Functions (SQLite)
- GATE 2027 syllabus for Data Science and Artificial Intelligence (DA) (Indian Institute of Technology Madras (GATE 2027))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress