Data Modeling Interview Questions Explained

Data modelling interviews test design decisions, and real data forces them. Seven questions are answered by building a star schema from a year of real online retail orders: declaring the grain, where price belongs, messy product names, customers who changed country, unknown members, a date dimension and additivity.

GloryTecks Team

Training & Placement Faculty

6 May 2026 10 min read

Last updated: 8 October 2026

Share:

Data modelling interviews rarely ask you to recite the difference between a star and a snowflake schema. They give you a business process and ask you to design for it, and then ask what you would do when the data misbehaves. These data modeling interview questions are answered by building a star schema from a real year of online retail orders, because real data forces the design decisions that interviewers ask about.

The data is the UCI Online Retail set: every order line from a UK online gift retailer between December 2010 and December 2011 (UCI: Online Retail). The model is built in DuckDB: a sales fact table with product, customer and date dimensions.

python
import duckdb

db = duckdb.connect()
db.execute("INSTALL excel; LOAD excel")
db.execute("CREATE TABLE raw AS SELECT * FROM read_xlsx('Online Retail.xlsx', all_varchar = true)")   # UCI Online Retail
db.execute("""CREATE TABLE lines AS SELECT InvoiceNo AS invoice, StockCode AS product, TRIM(Description) AS description,
    CAST(Quantity AS INTEGER) AS qty, ROUND(CAST(UnitPrice AS DOUBLE), 2) AS price,
    CAST(CAST(CustomerID AS DOUBLE) AS INTEGER) AS customer, Country AS country,
    TIMESTAMP '1899-12-30' + to_seconds(CAST(ROUND(CAST(InvoiceDate AS DOUBLE) * 86400) AS BIGINT)) AS ts
    FROM raw""")


def ask(title, sql):
    print(f"-- {title}")
    for row in db.execute(sql).fetchall():
        print("  ", *row)


ask("D1 grain: one fact row per invoice line", "SELECT COUNT(*) AS lines, COUNT(DISTINCT invoice) AS invoices FROM lines")

ask("D2 does price belong to the product? prices recorded for product 85123A", """
    SELECT COUNT(DISTINCT price), MIN(price), MAX(price), ANY_VALUE(description) FROM lines WHERE product = '85123A'""")

ask("D3 product codes with more than one description, and some of the extra ones", """
    SELECT COUNT(*) FROM (SELECT product FROM lines WHERE description IS NOT NULL
                          GROUP BY product HAVING COUNT(DISTINCT description) > 1)""")
ask("   descriptions seen on stock code 23071", """
    SELECT description, COUNT(*) AS lines, SUM(qty) AS units FROM lines WHERE product = '23071' GROUP BY description ORDER BY lines DESC""")
db.execute("""CREATE TABLE dim_product AS
    SELECT ROW_NUMBER() OVER (ORDER BY product) AS product_key, product AS stock_code, description
    FROM (SELECT product, description, ROW_NUMBER() OVER (PARTITION BY product ORDER BY COUNT(*) DESC, description) AS n
          FROM lines WHERE description IS NOT NULL AND price > 0 GROUP BY product, description)
    WHERE n = 1
    UNION ALL SELECT 0, NULL, 'Unknown product'""")
ask("   dim_product keeps the most used priced description per code", "SELECT COUNT(*) - 1, ANY_VALUE(description) FILTER (WHERE stock_code = '23071') FROM dim_product")

ask("D4 customers recorded under more than one country", """
    SELECT COUNT(*) FROM (SELECT customer FROM lines WHERE customer IS NOT NULL GROUP BY customer HAVING COUNT(DISTINCT country) > 1)""")
db.execute("""CREATE TABLE dim_customer AS
    WITH visits AS (SELECT customer, country, MIN(ts) AS first_seen FROM lines WHERE customer IS NOT NULL GROUP BY customer, invoice, country),
         changes AS (SELECT *, LAG(country) OVER (PARTITION BY customer ORDER BY first_seen) AS previous FROM visits),
         versions AS (SELECT customer, country, first_seen AS valid_from FROM changes WHERE previous IS NULL OR previous <> country)
    SELECT ROW_NUMBER() OVER (ORDER BY customer, valid_from) AS customer_key, customer, country, valid_from,
           LEAD(valid_from) OVER (PARTITION BY customer ORDER BY valid_from) AS valid_to
    FROM versions
    UNION ALL SELECT 0, NULL, 'Unknown', TIMESTAMP '1900-01-01', NULL""")
ask("   dim_customer as type 2: customers, rows, rows for customer 12417", """
    SELECT COUNT(DISTINCT customer), COUNT(*), (SELECT COUNT(*) FROM dim_customer WHERE customer = 12417) FROM dim_customer""")
ask("   customer 12417's versions", "SELECT customer_key, country, valid_from, valid_to FROM dim_customer WHERE customer = 12417 ORDER BY valid_from")

db.execute("""CREATE TABLE fact_sales AS
    SELECT l.invoice, CAST(l.ts AS DATE) AS date_key, COALESCE(c.customer_key, 0) AS customer_key,
           COALESCE(p.product_key, 0) AS product_key, l.qty, l.price, l.qty * l.price AS revenue
    FROM lines l
    LEFT JOIN dim_product p ON p.stock_code = l.product
    LEFT JOIN dim_customer c ON c.customer = l.customer AND l.ts >= c.valid_from AND (c.valid_to IS NULL OR l.ts < c.valid_to)""")
ask("D5 unknown members: lines without a customer or product, and revenue kept vs lost", """
    SELECT (SELECT COUNT(*) FROM fact_sales WHERE customer_key = 0) AS unknown_customer_lines,
           (SELECT COUNT(*) FROM fact_sales WHERE product_key = 0) AS unknown_product_lines,
           ROUND((SELECT SUM(revenue) FROM fact_sales f JOIN dim_customer c USING (customer_key))) AS revenue_with_unknown_member,
           ROUND((SELECT SUM(qty * price) FROM lines l JOIN (SELECT DISTINCT customer FROM dim_customer) c USING (customer))) AS revenue_joining_on_null_ids""")

db.execute("""CREATE TABLE dim_date AS
    SELECT CAST(d AS DATE) AS date_key, DAYNAME(d) AS weekday, MONTH(d) AS month
    FROM range(DATE '2010-12-01', DATE '2011-12-10', INTERVAL 1 DAY) t(d)""")
ask("D6 a date dimension shows the days with no sales at all", """
    SELECT d.weekday, COUNT(*) AS days, COUNT(*) FILTER (WHERE f.date_key IS NULL) AS days_without_sales
    FROM dim_date d LEFT JOIN (SELECT DISTINCT date_key FROM fact_sales) f USING (date_key)
    GROUP BY d.weekday ORDER BY days_without_sales DESC, d.weekday LIMIT 3""")

ask("D7 distinct customers are not additive", """
    SELECT (SELECT SUM(n) FROM (SELECT COUNT(DISTINCT customer_key) AS n FROM fact_sales
                                WHERE customer_key <> 0 GROUP BY MONTH(date_key), YEAR(date_key))) AS sum_of_monthly_counts,
           (SELECT COUNT(DISTINCT customer) FROM dim_customer WHERE customer IS NOT NULL) AS customers_in_the_year""")
text
-- D1 grain: one fact row per invoice line
   541909 25900
-- D2 does price belong to the product? prices recorded for product 85123A
   8 0.0 5.91 WHITE HANGING HEART T-LIGHT HOLDER
-- D3 product codes with more than one description, and some of the extra ones
   647
--    descriptions seen on stock code 23071
   MARIE ANTOINETTE TRINKET BOX GOLD 27 242
   damages 1 -65
   MARIE ANTOIENETT TRINKET BOX GOLD 1 1
--    dim_product keeps the most used priced description per code
   3937 MARIE ANTOINETTE TRINKET BOX GOLD
-- D4 customers recorded under more than one country
   8
--    dim_customer as type 2: customers, rows, rows for customer 12417
   4372 4389 3
--    customer 12417's versions
   60 Belgium 2010-12-17 11:51:00 2011-04-28 13:09:00
   61 Spain 2011-04-28 13:09:00 2011-06-05 15:46:00
   62 Belgium 2011-06-05 15:46:00 None
-- D5 unknown members: lines without a customer or product, and revenue kept vs lost
   135080 138 9747748.0 8300066.0
-- D6 a date dimension shows the days with no sales at all
   Saturday 53 53
   Monday 53 6
   Friday 54 4
-- D7 distinct customers are not additive
   13680 4372

D1. What is the first decision in a dimensional design?

Declaring the grain: exactly what one fact row represents (Kimball: grain). Here it is one row per invoice line, 541,909 rows from 25,900 invoices. The lowest grain the source offers is the safest choice, because invoice and daily totals can always be rolled up from lines, but lines can never be recovered from totals. The invoice number stays in the fact table as a degenerate dimension: it groups lines, but has no attributes of its own to put in a dimension table (Kimball: degenerate dimensions).

D2. Does price belong in the fact table or the product dimension?

Let the data decide. One product, the white hanging heart T-light holder, was sold at 8 different prices, from 0 to 5.91. A price that varies by line is a measurement of the sale, so it belongs in the fact table. A list price could still live in the product dimension, but then it is a separate attribute with a separate meaning, and the model should name it as such.

D3. What do you do with a messy dimension attribute?

647 product codes have more than one description. Stock code 23071 shows why: its proper name appeared on 27 lines, a misspelling on one, and the word "damages" on a line that removed 65 units. The description field doubles as a note field for stock adjustments. The product dimension therefore keeps one description per code, the one used most often on priced sales lines, and the notes stay in the source. In an interview, say how you chose the canonical value and where the alternatives remain available.

D4. A customer's country changed. How do you model that?

Ask first whether history matters. If reports must show sales under the country the customer had at the time of the sale, use a type 2 slowly changing dimension: add a new row for each change, with its own surrogate key and validity dates (Kimball: type 2). Eight customers appear under two countries. Customer 12417 went from Belgium to Spain on 28 April 2011 and back to Belgium on 5 June, so the dimension has three rows for that customer. Each fact row joins to the version valid at the sale's timestamp. The dimension has 4,389 rows for 4,372 customers plus one unknown member.

What to add: this is also why surrogate keys exist. The customer number alone cannot identify a row any more.

D5. What happens to facts with no customer?

135,080 lines have no customer ID. Leave the key NULL, and any inner join to the customer dimension silently drops those lines: 8,300,066 in revenue survived such a join, against 9,747,748 in total. The standard fix is an "Unknown" member with its own key, here 0, so every fact row joins to something and totals stay complete. The same applies to products: 138 lines had codes with no usable description and point to an unknown product row.

D6. Why build a date dimension instead of using the date column?

Because a date column only contains dates that had sales; a date dimension contains every date, plus attributes such as weekday, month and holidays (Kimball: calendar date dimension). Joining the full calendar to the facts showed that none of the 53 Saturdays in the period had a single sale. That is a fact about the business, presumably closed on Saturdays, which a fact table alone would never reveal. Six Mondays also had no sales, worth checking against the holiday calendar.

D7. Which measures can you add up?

Revenue and quantity are additive: they sum across any dimension. Distinct customers are not (Kimball: additive facts). Adding up each month's distinct customers gave 13,680, while the year had 4,372 distinct customers, because most customers bought in several months. Store the parts of a non-additive measure, or recompute it from the atomic grain, rather than summing pre-computed counts.

How to answer modelling questions

  • Start with the business process and the grain.
  • Ask how each attribute changes over time, and whether history matters.
  • Plan for missing and messy values with unknown members and canonical attributes.
  • Say which measures are additive before anyone builds a summary table.

The theory behind these answers is covered in data modelling: star schema, snowflake and data vault, change handling in slowly changing dimensions explained, and the same ideas in a BI tool in data modelling in Power BI. For related rounds, see data warehouse interview questions and data engineering interview questions and answers.

What are common data modelling interview questions?

Declaring the grain, facts versus dimensions, star versus snowflake schemas, surrogate keys, slowly changing dimensions, handling missing dimension members, date dimensions, degenerate dimensions, and additive versus non-additive measures.

What is the grain of a fact table?

The exact meaning of one row, such as one invoice line, one daily account balance or one website session. Every dimension and measure in the table must be consistent with it.

When should I use a type 2 slowly changing dimension?

When reports must reflect attribute values as they were at the time of each fact, such as a customer's region at the time of a sale. Use type 1, overwriting, when only the current value matters.

Why use surrogate keys instead of natural keys?

They let one natural key have several versions in a type 2 dimension, protect the warehouse from changes in source keys, and allow special members such as Unknown.

Dimensional modelling is part of the Data Engineering course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Interview Questions?

Join GloryTecks’ expert-led Data Engineering training in Hyderabad with real-time projects and 100% placement support.

Explore course