Vineet Ganti
SQL for Data Engineers: Building a Dimensional Warehouse
All posts

Data Engineering/Data Engineering SQL · Part 2 of 6

·66 min read

SQL for Data Engineers: Building a Dimensional Warehouse

Part 2 of a data engineering SQL practice bank: a retail star schema built question by question, with grain, SCD Types 1 to 3, point-in-time joins, race-free watermarks, partition reloads, inferred members, bridge tables, snapshots and quality gates. Every answer is run on real sample data.

Data EngineeringSQLPracticeData Modeling

ShopWave is an online retailer whose orders, product catalog and customer records feed a warehouse. This post builds that warehouse as a Kimball star schema: a sales fact at order-line grain, dimensions with surrogate keys and history, and the loads, checks and reports around them. Each question uses small, self-contained tables, so you can run any of them on its own.

The post covers five areas:

  • Modeling the star: grain, SCD scaffolding, staging and change detection
  • Keeping history: SCD Types 1, 2 and 3, and point-in-time lookups
  • Loading facts: Unknown members, measure types, watermarks, reloads and late-arriving dimensions
  • Other fact types: factless, bridge, periodic and accumulating snapshots, drill-across
  • Quality and serving: a pre-publish gate, reconciliation, and a top-N report with subtotals

#How to use this post

Each question has a difficulty, the pattern it drills, a small sample of data and the expected output. Try it before you open the dropdowns: Hint nudges you, and Answer has the SQL, a line-by-line explanation and a step-through of the data. The step-through shows the real tables at each stage, with changed rows tinted and labelled: inserted or kept rows in green, removed rows in red, changed rows in amber. Every answer ends with an Engineer check: the grain, what happens if it runs twice, and what NULL does. The first post explains how the answers were run and checked.

#Modeling the star

Before any load, decide what a row means and how each dimension will keep its history.

#Q16 · Declare the grain, then create the fact table

Easy · Grain and fact DDL

ShopWave's sales fact is one row per order line. Create fact_sales with surrogate foreign keys date_sk, customer_sk, product_sk; the order number and line number as degenerate dimensions; the measures quantity, unit_price, discount_amt, gross_amount, net_amount; and audit columns _loaded_at, _batch_id. Make the grain enforceable where the engine allows it.

Hint

Write the grain down first. The primary key should be exactly the columns that identify one row of that grain.

Answer
sql
-- Grain: one row per order line, i.e. per (order_id, line_no).
CREATE TABLE fact_sales (
    date_sk      INTEGER       NOT NULL,   -- FK to dim_date
    customer_sk  INTEGER       NOT NULL,   -- FK to dim_customer (-1 = unknown)
    product_sk   INTEGER       NOT NULL,   -- FK to dim_product  (-1 = unknown)
    order_id     VARCHAR(30)   NOT NULL,   -- degenerate dimension
    line_no      INTEGER       NOT NULL,   -- degenerate dimension
    quantity     INTEGER,
    unit_price   DECIMAL(12,2),
    discount_amt DECIMAL(12,2),
    gross_amount DECIMAL(14,2),
    net_amount   DECIMAL(14,2),
    _loaded_at   TIMESTAMP     DEFAULT current_timestamp,
    _batch_id    VARCHAR(50),
    PRIMARY KEY (order_id, line_no)
);
  • The grain comes first. "One row per order line" decides every column: measures must be true for one line, and every join and sum has to respect it.
  • Surrogate foreign keys (date_sk, customer_sk, product_sk) point at dimension rows. They are NOT NULL because a missing or unknown key points at the -1 Unknown member (Q17), never at NULL.
  • Degenerate dimensions (order_id, line_no) are identifiers with no dimension table of their own. They keep every fact row traceable to its source line.
  • gross_amount and net_amount are stored, not computed at query time, so every report uses the same arithmetic.
  • PRIMARY KEY (order_id, line_no) is the grain written as a constraint. DuckDB and Postgres reject a duplicate, as the step-through shows. Snowflake accepts it silently (it doesn't enforce primary keys), so there the grain is protected by the load logic and the quality gate in Q35.
Two lines of order SO-1 load.
fact_sales
date_skcustomer_skproduct_skorder_idline_noquantitynet_amount_batch_id
inserted20250301110SO-11249.98B1
inserted20250301111SO-12179.00B1
Step 1 of 2

Engineer check. Grain: one row per (order_id, line_no), enforced only on engines that enforce primary keys. Run twice: a plain reload double-counts unless it's a MERGE or a delete-and-insert (Q28). NULL: foreign keys are NOT NULL by design; unknown means -1.

#Q17 · An SCD Type 2 dimension, with an Unknown member

Easy · SCD2 scaffolding

Create dim_customer so it can keep history (SCD Type 2): a surrogate key, the natural key customer_code, the tracked attributes (first_name, last_name, email, city, state, country), and the columns that make history work. Then insert the Unknown member, with surrogate key -1, that facts point at when their customer is missing.

Expected output

customer_skcustomer_codefirst_namecityeffective_fromeffective_tois_currentversion
-1UNKNOWNUnknownUnknown1900-01-019999-12-31TRUE1
Hint

One customer will have several rows over time. What makes each row unique, and what tells you which row was true on a given date?

Answer
sql
CREATE SEQUENCE customer_sk_seq START 1;
CREATE TABLE dim_customer (
    customer_sk INTEGER PRIMARY KEY DEFAULT nextval('customer_sk_seq'),
    customer_code VARCHAR NOT NULL, first_name VARCHAR, last_name VARCHAR, email VARCHAR,
    city VARCHAR, state VARCHAR, country VARCHAR,
    effective_from DATE NOT NULL, effective_to DATE NOT NULL, is_current BOOLEAN NOT NULL,
    row_hash VARCHAR, version INTEGER NOT NULL
);

INSERT INTO dim_customer (customer_sk, customer_code, first_name, last_name, email, city, state, country,
                          effective_from, effective_to, is_current, row_hash, version)
VALUES (-1, 'UNKNOWN', 'Unknown', 'Unknown', NULL, 'Unknown', 'Unknown', 'Unknown',
        DATE '1900-01-01', DATE '9999-12-31', TRUE, 'UNKNOWN', 1);
  • customer_sk is unique per version, not per customer. When a customer moves, they get a new row and a new surrogate key.
  • customer_code is NOT NULL but not UNIQUE. It repeats across versions. "This customer today" is customer_code = … AND is_current.
  • effective_from / effective_to are the validity window of a version. They power point-in-time joins (Q25). 9999-12-31 marks "still valid": a real far-future date keeps range predicates simple, where a NULL end date would need special handling in every join.
  • is_current duplicates what the window says, but makes "the current version" a cheap filter.
  • row_hash fingerprints the tracked attributes, so a load can detect change with one comparison (Q19).
  • version counts changes per customer, which helps when debugging.

The Unknown member. Every dimension reserves -1 for "unknown, not applicable or not yet arrived". A fact whose customer can't be resolved points at -1 instead of NULL, so inner joins never drop it, totals stay complete, and "Unknown" shows up as a bucket you can monitor. Its window covers all time, so any fact date falls inside it.

The sample data before the answer runs.
Step 1 of 2

On Postgres, an IDENTITY column declared GENERATED ALWAYS refuses an explicit -1 unless the insert says OVERRIDING SYSTEM VALUE; declaring it GENERATED BY DEFAULT avoids that. Snowflake lets you insert an explicit value into an AUTOINCREMENT column.

Engineer check. Grain: one row per customer version. Run twice: the Unknown-member insert fails on the primary key the second time; in production it's a MERGE or INSERT … WHERE NOT EXISTS. NULL: the window uses a sentinel end date instead of NULL so that BETWEEN works.

#Q18 · Clean and deduplicate the staging layer

Medium · Staging and dedup

src_customer holds repeated CDC pulls with messy formatting. Build stg_customer with exactly one row per customer: the latest pull, with names and codes trimmed, email lower-cased, country upper-cased, and rows without a customer_code removed. Add the row_hash (Q19's recipe) so later loads can detect change.

Sample data

src_customer

customer_codefirst_namelast_nameemailcitystatecountrysignup_tsupdated_at
C-001AnaLopezAna@Example.comAustinTXus2024-11-02 10:002025-01-05 09:00
C-001AnaLopezana@example.comAustinTXUS2024-11-02 10:002025-02-10 09:00
C-002RajPatelRAJ@EXAMPLE.COMPuneMHin2024-12-01 08:002025-01-20 12:00
NULLGhostUserghost@x.comNULLNULLUSNULL2025-01-21 12:00
C-003LiWeili@example.comAustinTXUS2025-01-15 08:30NULL
C-003LiWeili@example.comDallasTXUS2025-01-15 08:302025-02-01 08:30
Sample data as SQL (to run it yourself)
sql
CREATE TABLE src_customer (customer_code VARCHAR, first_name VARCHAR, last_name VARCHAR, email VARCHAR,
                           city VARCHAR, state VARCHAR, country VARCHAR, signup_ts TIMESTAMP, updated_at TIMESTAMP);
INSERT INTO src_customer VALUES
    ('C-001', ' Ana ', 'Lopez', 'Ana@Example.com', 'Austin', 'TX', 'us',  TIMESTAMP '2024-11-02 10:00', TIMESTAMP '2025-01-05 09:00'),
    ('C-001', 'Ana',   'Lopez', 'ana@example.com', 'Austin', 'TX', 'US',  TIMESTAMP '2024-11-02 10:00', TIMESTAMP '2025-02-10 09:00'),
    (' C-002', 'Raj',  'Patel', 'RAJ@EXAMPLE.COM', 'Pune',   'MH', ' in', TIMESTAMP '2024-12-01 08:00', TIMESTAMP '2025-01-20 12:00'),
    (NULL,     'Ghost','User',  'ghost@x.com',     NULL,     NULL, 'US',  NULL,                         TIMESTAMP '2025-01-21 12:00'),
    ('C-003', 'Li',    'Wei',   'li@example.com',  'Austin', 'TX', 'US',  TIMESTAMP '2025-01-15 08:30', NULL),
    ('C-003', 'Li',    'Wei',   'li@example.com',  'Dallas', 'TX', 'US',  TIMESTAMP '2025-01-15 08:30', TIMESTAMP '2025-02-01 08:30');

Expected output

customer_codefirst_nameemailcitycountryupdated_atrow_hash
C-001Anaana@example.comAustinUS2025-02-10 09:0025f696a3…
C-002Rajraj@example.comPuneIN2025-01-20 12:007f751425…
C-003Lili@example.comDallasUS2025-02-01 08:309cad7709…
Hint

Standardise before you deduplicate, or ' C-002' and 'C-002' count as different customers.

Answer
sql
CREATE OR REPLACE TABLE stg_customer AS
WITH cleaned AS (
    SELECT
        TRIM(customer_code)  AS customer_code,
        TRIM(first_name)     AS first_name,
        TRIM(last_name)      AS last_name,
        LOWER(TRIM(email))   AS email,
        TRIM(city)           AS city,
        TRIM(state)          AS state,
        UPPER(TRIM(country)) AS country,
        signup_ts,
        updated_at
    FROM src_customer
    WHERE NULLIF(TRIM(customer_code), '') IS NOT NULL
    QUALIFY ROW_NUMBER() OVER (PARTITION BY TRIM(customer_code)
                               ORDER BY updated_at DESC NULLS LAST) = 1
)
SELECT *, md5(concat_ws('|', coalesce(first_name,'<NULL>'), coalesce(last_name,'<NULL>'), coalesce(email,'<NULL>'), coalesce(city,'<NULL>'), coalesce(state,'<NULL>'), coalesce(country,'<NULL>'))) AS row_hash
FROM cleaned;
  • Standardise at the staging boundary. TRIM, LOWER and UPPER make equal things equal, so ' in' and 'IN' aren't two countries and ' C-002' isn't a separate customer.
  • Partition by the cleaned key (TRIM(customer_code)). Deduplicating on the raw key would leave a padded and an unpadded copy of the same customer.
  • NULLIF(TRIM(customer_code), '') IS NOT NULL drops rows you can't key, including blank strings, which IS NOT NULL alone would keep. In production these go to a quarantine table rather than vanishing (post 03, Q51).
  • NULLS LAST stops C-003's pull with no timestamp from winning (Q11). C-003's latest real pull says Dallas.
  • row_hash is computed once here so every later load compares one column instead of six.
Six pulls: duplicates, padding, mixed case, a row with no code and a pull with no timestamp.
src_customer
customer_codefirst_nameemailcitycountryupdated_at
C-001 Ana Ana@Example.comAustinus2025-01-05 09:00
C-001Anaana@example.comAustinUS2025-02-10 09:00
C-002RajRAJ@EXAMPLE.COMPune in2025-01-20 12:00
removedNULLGhostghost@x.comNULLUS2025-01-21 12:00
C-003Lili@example.comAustinUSNULL
C-003Lili@example.comDallasUS2025-02-01 08:30
Step 1 of 2

Engineer check. Grain: one row per cleaned customer_code. Run twice: CREATE OR REPLACE rebuilds the same result. NULL: keyless rows are removed and NULL timestamps sort last.

#Q19 · A change-detection hash that can't be fooled

Hard · Change detection

SCD loads compare a hash of the tracked attributes to decide whether a row changed. The rows below are five different attribute pairs. Write a hash expression that gives each a different value, and show how the two obvious approaches, plain concatenation and concat_ws, give false matches.

Sample data

pairs

row_idab
1ABC
2ABC
3XNULL
4NULLX
5X 
Sample data as SQL (to run it yourself)
sql
CREATE TABLE pairs (row_id INT, a VARCHAR, b VARCHAR);
INSERT INTO pairs VALUES
    (1, 'AB', 'C'),
    (2, 'A',  'BC'),
    (3, 'X',  NULL),
    (4, NULL, 'X'),
    (5, 'X',  '');

Expected output

row_idabrow_hash
1ABCec9d2345be23e4b02532a752d67f891e
2ABC569e76369ed02c0c64c92cc917c4858a
3XNULLdd5f5749caa0d5b9a5055c73aeb5e445
4NULLXaaf1e0c22de74e5475301440bc37922c
5X 17706d095d47daaf49ef60eba2e34957
Hint

Two things break naive hashing: values that run into each other, and NULLs. Fix the first with a separator and the second with a placeholder that can't occur in real data.

Answer
sql
SELECT
    row_id, a, b,
    md5(concat_ws('|', coalesce(a, '<NULL>'), coalesce(b, '<NULL>'))) AS row_hash
FROM pairs
ORDER BY row_id;
  • The separator stops boundary collisions: plain a || b turns both ('AB', 'C') and ('A', 'BC') into 'ABC'. With '|' they become 'AB|C' and 'A|BC'.
  • The NULL placeholder is needed for a different reason on each engine:
    • DuckDB and Postgres's concat_ws skips NULLs, so ('X', NULL) and (NULL, 'X') both become 'X'. A customer whose city moved into the state column would look unchanged.
    • Snowflake's concat_ws and || return NULL if any argument is NULL (docs). Every row with any NULL then hashes to NULL, and NULL <> NULL is never true, so changes to those rows are never detected.
    • coalesce(col, '<NULL>') fixes both, and also keeps NULL apart from the empty string (rows 3 and 5).
  • md5 turns the string into a fixed-length fingerprint that's identical on every engine, which matters when you reconcile across systems. Snowflake's HASH() is faster but engine-specific.

Choose a separator and a placeholder that can't appear in the data, or hash each column on its own and concatenate the hashes.

`a || b`: rows 1 and 2 collide, and any NULL makes the whole value NULL.
a || b
row_idabconcatenated
wrong1ABCABC
wrong2ABCABC
wrong3XNULLNULL
wrong4NULLXNULL
5XX
Step 1 of 4

Engineer check. NULL: the whole question is NULL behaviour. Decide what a NULL means in the hash, and make sure it can't equal an empty string or a value shifted between columns.

#Q20 · The first full load of a dimension

Medium · Surrogate keys and SCD2 seed

dim_customer holds only the Unknown member. Load every customer from stg_customer as version 1, current, valid from their signup date to 9999-12-31, with surrogate keys assigned by the table and the staged row_hash.

Sample data

stg_customer

customer_codefirst_namelast_nameemailcitystatecountrysignup_tsrow_hash
C-001AnaLopezana@example.comAustinTXUS2024-11-02 10:00h-c001-a
C-002RajPatelraj@example.comPuneMHIN2024-12-01 08:00h-c002-a
C-003LiWeili@example.comDallasTXUSNULLh-c003-a
Sample data as SQL (to run it yourself)
sql
CREATE SEQUENCE customer_sk_seq START 1;
CREATE TABLE dim_customer (
    customer_sk INTEGER PRIMARY KEY DEFAULT nextval('customer_sk_seq'),
    customer_code VARCHAR NOT NULL, first_name VARCHAR, last_name VARCHAR, email VARCHAR,
    city VARCHAR, state VARCHAR, country VARCHAR,
    effective_from DATE NOT NULL, effective_to DATE NOT NULL, is_current BOOLEAN NOT NULL,
    row_hash VARCHAR, version INTEGER NOT NULL
);

INSERT INTO dim_customer (customer_sk, customer_code, first_name, last_name, email, city, state, country, effective_from, effective_to, is_current, row_hash, version)
VALUES (-1, 'UNKNOWN', 'Unknown', 'Unknown', NULL, 'Unknown', 'Unknown', 'Unknown', DATE '1900-01-01', DATE '9999-12-31', TRUE, 'UNKNOWN', 1);

CREATE TABLE stg_customer (customer_code VARCHAR, first_name VARCHAR, last_name VARCHAR, email VARCHAR, city VARCHAR,
                           state VARCHAR, country VARCHAR, signup_ts TIMESTAMP, row_hash VARCHAR);
INSERT INTO stg_customer VALUES
    ('C-001', 'Ana', 'Lopez', 'ana@example.com', 'Austin', 'TX', 'US', TIMESTAMP '2024-11-02 10:00', 'h-c001-a'),
    ('C-002', 'Raj', 'Patel', 'raj@example.com', 'Pune',   'MH', 'IN', TIMESTAMP '2024-12-01 08:00', 'h-c002-a'),
    ('C-003', 'Li',  'Wei',   'li@example.com',  'Dallas', 'TX', 'US', NULL,                         'h-c003-a');

Expected output

customer_skcustomer_codecityeffective_fromeffective_tois_currentversion
-1UNKNOWNUnknown1900-01-019999-12-31TRUE1
1C-001Austin2024-11-029999-12-31TRUE1
2C-002Pune2024-12-019999-12-31TRUE1
3C-003Dallas2025-03-019999-12-31TRUE1
Hint

Leave the surrogate key out of the column list. And make the load harmless if it runs on a non-empty table.

Answer
sql
INSERT INTO dim_customer (customer_code, first_name, last_name, email, city, state, country,
                          effective_from, effective_to, is_current, row_hash, version)
SELECT
    s.customer_code, s.first_name, s.last_name, s.email, s.city, s.state, s.country,
    COALESCE(CAST(s.signup_ts AS DATE), DATE '2025-03-01') AS effective_from,  -- load date if unknown
    DATE '9999-12-31',
    TRUE,
    s.row_hash,
    1
FROM stg_customer s
WHERE NOT EXISTS (SELECT 1 FROM dim_customer d WHERE d.customer_code = s.customer_code);
  • No customer_sk in the column list, so the sequence default numbers each new row. The Unknown member keeps its -1.
  • effective_from starts each customer's history at signup; C-003 has no signup time, so it falls back to the load date. History before the warehouse existed is unknowable, and a date is still better than a NULL that breaks range joins.
  • 9999-12-31, is_current = TRUE, version = 1: every customer starts as their own first, open, current version.
  • WHERE NOT EXISTS is the guard that makes the "first load" safe to re-run: the build ran it twice and the second run inserted nothing. A seed load that can double a dimension is a real incident waiting to happen; ongoing loads are SCD2 (Q22).
The sample data before the answer runs.
stg_customer
customer_codefirst_namelast_nameemailcitystatecountrysignup_tsrow_hash
C-001AnaLopezana@example.comAustinTXUS2024-11-02 10:00h-c001-a
C-002RajPatelraj@example.comPuneMHIN2024-12-01 08:00h-c002-a
C-003LiWeili@example.comDallasTXUSNULLh-c003-a
Step 1 of 2

Engineer check. Grain: one current version per customer. Run twice: no change, verified. NULL: a missing signup time gets the load date, never a NULL window start.

#Keeping history

Three ways a dimension can respond to change, and the join that reads history back correctly.

#Q21 · SCD Type 1: overwrite in place

Medium · SCD1 with MERGE

Product corrections (a fixed price, a renamed brand) need no history: the current row is simply overwritten (Type 1). Apply stg_product to dim_product, updating changed products and inserting new ones, without rewriting products that didn't change.

Sample data

dim_product

product_skproduct_codeproduct_namecategorylist_priceeffective_fromeffective_tois_currentversion
100SKU-001Wireless MouseAccessories24.992025-01-019999-12-31TRUE1
101SKU-002KeyboardAccessories98.002025-01-019999-12-31TRUE1

stg_product

product_codeproduct_namecategorylist_price
SKU-001Wireless MouseAccessories24.99
SKU-002Mechanical KeyboardAccessories89.00
SKU-003USB HubAccessories19.00
Sample data as SQL (to run it yourself)
sql
CREATE SEQUENCE product_sk_seq START 100;
CREATE TABLE dim_product (product_sk INTEGER PRIMARY KEY DEFAULT nextval('product_sk_seq'), product_code VARCHAR NOT NULL,
                          product_name VARCHAR, category VARCHAR, list_price DECIMAL(10,2),
                          effective_from DATE, effective_to DATE, is_current BOOLEAN, version INTEGER);
INSERT INTO dim_product (product_code, product_name, category, list_price, effective_from, effective_to, is_current, version) VALUES
    ('SKU-001', 'Wireless Mouse', 'Accessories', 24.99, DATE '2025-01-01', DATE '9999-12-31', TRUE, 1),
    ('SKU-002', 'Keyboard',       'Accessories', 98.00, DATE '2025-01-01', DATE '9999-12-31', TRUE, 1);

CREATE TABLE stg_product (product_code VARCHAR, product_name VARCHAR, category VARCHAR, list_price DECIMAL(10,2));
INSERT INTO stg_product VALUES
    ('SKU-001', 'Wireless Mouse',      'Accessories', 24.99),   -- unchanged
    ('SKU-002', 'Mechanical Keyboard', 'Accessories', 89.00),   -- corrected name and price
    ('SKU-003', 'USB Hub',             'Accessories', 19.00);   -- new

Expected output

product_skproduct_codeproduct_namelist_priceis_currentversion
100SKU-001Wireless Mouse24.99TRUE1
101SKU-002Mechanical Keyboard89.00TRUE1
102SKU-003USB Hub19.00TRUE1
Hint

A plain WHEN MATCHED THEN UPDATE rewrites every matched row, changed or not. Add a condition to the matched branch.

Answer
sql
MERGE INTO dim_product t
USING stg_product s
   ON t.product_code = s.product_code AND t.is_current
WHEN MATCHED AND (t.product_name IS DISTINCT FROM s.product_name
               OR t.category     IS DISTINCT FROM s.category
               OR t.list_price   IS DISTINCT FROM s.list_price) THEN UPDATE SET
    product_name = s.product_name,
    category     = s.category,
    list_price   = s.list_price
WHEN NOT MATCHED THEN INSERT
    (product_code, product_name, category, list_price, effective_from, effective_to, is_current, version)
    VALUES (s.product_code, s.product_name, s.category, s.list_price, DATE '2025-03-01', DATE '9999-12-31', TRUE, 1);
  • ON … AND t.is_current matches each staged product to its current row, so the same MERGE stays correct once the table also holds Type 2 history.
  • WHEN MATCHED AND (… IS DISTINCT FROM …) updates only rows that actually changed. Without the condition, SKU-001 would be rewritten with identical values every night: wasted writes, misleading "last modified" audit columns, and on Snowflake rewritten micro-partitions that cost storage and Time Travel space. IS DISTINCT FROM is the NULL-safe comparison (Q12), so a change from NULL to a value is caught.
  • Type 1 means the old value is gone. The keyboard's old price of 98.00 can't be recovered. That's right for corrections and wrong for anything analysts slice history by; those attributes need Type 2 (Q22).
  • WHEN NOT MATCHED THEN INSERT adds SKU-003 as a new current product.
The sample data before the answer runs.
dim_product
product_skproduct_codeproduct_namecategorylist_priceeffective_fromeffective_tois_currentversion
100SKU-001Wireless MouseAccessories24.992025-01-019999-12-31TRUE1
101SKU-002KeyboardAccessories98.002025-01-019999-12-31TRUE1
stg_product
product_codeproduct_namecategorylist_price
SKU-001Wireless MouseAccessories24.99
SKU-002Mechanical KeyboardAccessories89.00
SKU-003USB HubAccessories19.00
Step 1 of 2

Engineer check. Grain: one current row per product. Run twice: verified unchanged. NULL: IS DISTINCT FROM catches changes to and from NULL that <> would miss.

#Q22 · SCD Type 2: expire, then insert

Hard · SCD2

Customers move, and reports must show where a customer lived when they bought. Given today's stg_customer (load date 2025-06-01), expire the current row of every customer whose tracked attributes changed, insert their new version, and insert brand-new customers. Unchanged customers must not be touched, and the load must be safe to re-run.

Sample data

dim_customer

customer_skcustomer_codefirst_namelast_nameemailcitystatecountryeffective_fromeffective_tois_currentrow_hashversion
1C-001AnaLopezana@example.comAustinTXUS2024-11-029999-12-31TRUEh-austin1
2C-002RajPatelraj@example.comPuneMHIN2024-12-019999-12-31TRUEh-pune1

stg_customer

customer_codefirst_namelast_nameemailcitystatecountryrow_hash
C-001AnaLopezana@example.comDenverCOUSh-denver
C-002RajPatelraj@example.comPuneMHINh-pune
C-003LiWeili@example.comDallasTXUSh-dallas
Sample data as SQL (to run it yourself)
sql
CREATE SEQUENCE customer_sk_seq START 1;
CREATE TABLE dim_customer (
    customer_sk INTEGER PRIMARY KEY DEFAULT nextval('customer_sk_seq'),
    customer_code VARCHAR NOT NULL, first_name VARCHAR, last_name VARCHAR, email VARCHAR,
    city VARCHAR, state VARCHAR, country VARCHAR,
    effective_from DATE NOT NULL, effective_to DATE NOT NULL, is_current BOOLEAN NOT NULL,
    row_hash VARCHAR, version INTEGER NOT NULL
);

INSERT INTO dim_customer (customer_code, first_name, last_name, email, city, state, country, effective_from, effective_to, is_current, row_hash, version) VALUES
    ('C-001', 'Ana', 'Lopez', 'ana@example.com', 'Austin', 'TX', 'US', DATE '2024-11-02', DATE '9999-12-31', TRUE, 'h-austin', 1),
    ('C-002', 'Raj', 'Patel', 'raj@example.com', 'Pune',   'MH', 'IN', DATE '2024-12-01', DATE '9999-12-31', TRUE, 'h-pune',   1);

CREATE TABLE stg_customer (customer_code VARCHAR, first_name VARCHAR, last_name VARCHAR, email VARCHAR,
                           city VARCHAR, state VARCHAR, country VARCHAR, row_hash VARCHAR);
INSERT INTO stg_customer VALUES
    ('C-001', 'Ana', 'Lopez', 'ana@example.com', 'Denver', 'CO', 'US', 'h-denver'),   -- moved
    ('C-002', 'Raj', 'Patel', 'raj@example.com', 'Pune',   'MH', 'IN', 'h-pune'),     -- unchanged
    ('C-003', 'Li',  'Wei',   'li@example.com',  'Dallas', 'TX', 'US', 'h-dallas');   -- new

Expected output

customer_skcustomer_codecityeffective_fromeffective_tois_currentversion
1C-001Austin2024-11-022025-05-31FALSE1
3C-001Denver2025-06-019999-12-31TRUE2
2C-002Pune2024-12-019999-12-31TRUE1
4C-003Dallas2025-06-019999-12-31TRUE1
Hint

One MERGE can't both close the old row and insert the new one for the same key. Use two statements, and think about what Step 1 leaves behind for Step 2 to find.

Answer
sql
-- Step 1: close the current version of customers whose attributes changed
UPDATE dim_customer AS t
SET is_current   = FALSE,
    effective_to = DATE '2025-06-01' - 1          -- last day the old version was true
FROM stg_customer s
WHERE t.customer_code = s.customer_code
  AND t.is_current
  AND t.row_hash <> s.row_hash;

-- Step 2: open a new current version for changed AND brand-new customers
INSERT INTO dim_customer (customer_code, first_name, last_name, email, city, state, country,
                          effective_from, effective_to, is_current, row_hash, version)
SELECT
    s.customer_code, s.first_name, s.last_name, s.email, s.city, s.state, s.country,
    DATE '2025-06-01', DATE '9999-12-31', TRUE, s.row_hash,
    COALESCE((SELECT MAX(d.version) FROM dim_customer d WHERE d.customer_code = s.customer_code), 0) + 1
FROM stg_customer s
WHERE NOT EXISTS (SELECT 1 FROM dim_customer cur
                  WHERE cur.customer_code = s.customer_code AND cur.is_current);
  • Two statements, in order. A standard MERGE acts once per matched target row, so it can't expire C-001's row and insert C-001's new row. The usual pattern is expire, then insert. (A single-MERGE variant exists, using a source that lists each changed key twice, but it's harder to read.)
  • Step 1's t.row_hash <> s.row_hash is the change gate: only C-001 is expired, and C-002 is untouched.
  • effective_to = load date - 1 closes the old window the day before the new one opens. The windows are contiguous and never overlap, so exactly one version matches any date (Q25 depends on that).
  • Step 2's NOT EXISTS (… cur.is_current) finds every staged customer without a current row. After Step 1 that is exactly the changed customers (just expired) plus the new ones.
  • version = MAX(version) + 1 is computed over all of the customer's versions, so numbering stays correct through any number of changes.
  • Re-run: the second run finds no hash differences and every customer already current, so it changes nothing. The build verified it.
The sample data before the answer runs.
dim_customer
customer_skcustomer_codefirst_namelast_nameemailcitystatecountryeffective_fromeffective_tois_currentrow_hashversion
1C-001AnaLopezana@example.comAustinTXUS2024-11-029999-12-31TRUEh-austin1
2C-002RajPatelraj@example.comPuneMHIN2024-12-019999-12-31TRUEh-pune1
stg_customer
customer_codefirst_namelast_nameemailcitystatecountryrow_hash
C-001AnaLopezana@example.comDenverCOUSh-denver
C-002RajPatelraj@example.comPuneMHINh-pune
C-003LiWeili@example.comDallasTXUSh-dallas
Step 1 of 2

Engineer check. Grain: one row per customer version, one current. Run twice: verified unchanged. NULL: the hash must be NULL-safe (Q19), or a change from NULL is invisible to <>.

#Q23 · SCD Type 3: keep only the previous value

Medium · SCD3

Marketing only ever compares a product's current category with its immediately previous one. Add previous_category and category_changed_at to dim_product, and write the update that moves the old category aside when it changes (load date 2025-06-01).

Sample data

dim_product

product_skproduct_codeproduct_namecategory
100SKU-001Wireless MouseAccessories
101SKU-004WebcamAccessories

stg_product

product_codecategory
SKU-001Accessories
SKU-004Video
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_product (product_sk INT, product_code VARCHAR, product_name VARCHAR, category VARCHAR);
INSERT INTO dim_product VALUES (100, 'SKU-001', 'Wireless Mouse', 'Accessories'), (101, 'SKU-004', 'Webcam', 'Accessories');
CREATE TABLE stg_product (product_code VARCHAR, category VARCHAR);
INSERT INTO stg_product VALUES ('SKU-001', 'Accessories'), ('SKU-004', 'Video');

Expected output

product_skproduct_codeproduct_namecategoryprevious_categorycategory_changed_at
100SKU-001Wireless MouseAccessoriesNULLNULL
101SKU-004WebcamVideoAccessories2025-06-01
Hint

The SET clause reads the row's old values, so you can copy the old category before overwriting it in the same statement.

Answer
sql
ALTER TABLE dim_product ADD COLUMN IF NOT EXISTS previous_category   VARCHAR;
ALTER TABLE dim_product ADD COLUMN IF NOT EXISTS category_changed_at DATE;

UPDATE dim_product AS t
SET previous_category   = t.category,
    category            = s.category,
    category_changed_at = DATE '2025-06-01'
FROM stg_product s
WHERE t.product_code = s.product_code
  AND t.category IS DISTINCT FROM s.category;
  • Type 3 keeps a fixed, small amount of history in extra columns of the same row: here exactly one previous value and when it changed. Type 2 keeps unlimited history as extra rows.
  • SET previous_category = t.category, category = s.category works in one statement because every expression in SET sees the row's values from before the update.
  • IS DISTINCT FROM limits the update to real changes. Without it, SKU-001's unchanged category would be copied into previous_category, erasing the true previous value.
  • Re-run: the second run finds the categories already equal, so nothing changes, and the history stays intact. The build verified it.

Use Type 3 when people only ask "current versus previous", such as sales under the old and new category after a reorganisation. It can't answer "what was it two changes ago".

The sample data before the answer runs.
dim_product
product_skproduct_codeproduct_namecategory
100SKU-001Wireless MouseAccessories
101SKU-004WebcamAccessories
stg_product
product_codecategory
SKU-001Accessories
SKU-004Video
Step 1 of 2

Engineer check. Grain: unchanged, one row per product. Run twice: verified. NULL: a change from NULL to a category is caught by IS DISTINCT FROM, and NULL becomes the previous value.

#Loading facts

Loading the sales fact so that missing keys, late rows, retries and corrections can't corrupt it.

#Q24 · Load the fact table, routing missing keys to Unknown

Medium · Fact loading

Load fact_sales from src_order and src_order_line. Resolve each natural key to the current dimension row, compute gross_amount = quantity × unit_price and net_amount = gross_amount − discount_amt, and make sure an order line whose customer or product is missing from the dimensions still loads.

Sample data

src_order

order_idcustomer_codeorder_ts
SO-1C-0012025-03-02 10:00
SO-2C-9992025-03-03 15:30

src_order_line

order_idline_noproduct_codequantityunit_pricediscount_amt
SO-11SKU-001224.99NULL
SO-12SKU-002189.0010.00
SO-21SKU-77735.000.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_date (date_key INT, full_date DATE);
INSERT INTO dim_date SELECT CAST(strftime(d, '%Y%m%d') AS INT), CAST(d AS DATE) FROM generate_series(DATE '2025-03-01', DATE '2025-04-30', INTERVAL 1 DAY) t(d);

CREATE TABLE dim_customer (customer_sk INT, customer_code VARCHAR, is_current BOOLEAN);
INSERT INTO dim_customer VALUES (-1, 'UNKNOWN', TRUE), (1, 'C-001', TRUE), (2, 'C-002', TRUE);
CREATE TABLE dim_product (product_sk INT, product_code VARCHAR, is_current BOOLEAN);
INSERT INTO dim_product VALUES (-1, 'UNKNOWN', TRUE), (100, 'SKU-001', TRUE), (101, 'SKU-002', TRUE);

CREATE TABLE src_order (order_id VARCHAR, customer_code VARCHAR, order_ts TIMESTAMP);
INSERT INTO src_order VALUES ('SO-1', 'C-001', TIMESTAMP '2025-03-02 10:00'), ('SO-2', 'C-999', TIMESTAMP '2025-03-03 15:30');
CREATE TABLE src_order_line (order_id VARCHAR, line_no INT, product_code VARCHAR, quantity INT, unit_price DECIMAL(10,2), discount_amt DECIMAL(10,2));
INSERT INTO src_order_line VALUES
    ('SO-1', 1, 'SKU-001', 2, 24.99, NULL),
    ('SO-1', 2, 'SKU-002', 1, 89.00, 10.00),
    ('SO-2', 1, 'SKU-777', 3, 5.00, 0.00);

CREATE TABLE fact_sales (date_sk INT, customer_sk INT, product_sk INT, order_id VARCHAR, line_no INT,
                         quantity INT, unit_price DECIMAL(10,2), discount_amt DECIMAL(10,2),
                         gross_amount DECIMAL(12,2), net_amount DECIMAL(12,2));

Expected output

date_skcustomer_skproduct_skorder_idline_noquantityunit_pricediscount_amtgross_amountnet_amount
202503021100SO-11224.990.0049.9849.98
202503021101SO-12189.0010.0089.0079.00
20250303-1-1SO-2135.000.0015.0015.00
Hint

Which joins are allowed to drop a row? Only the one that defines the grain.

Answer
sql
INSERT INTO fact_sales
SELECT
    dd.date_key,
    COALESCE(dc.customer_sk, -1),
    COALESCE(dp.product_sk,  -1),
    ol.order_id,
    ol.line_no,
    ol.quantity,
    ol.unit_price,
    COALESCE(ol.discount_amt, 0),
    ol.quantity * ol.unit_price,
    ol.quantity * ol.unit_price - COALESCE(ol.discount_amt, 0)
FROM src_order_line ol
JOIN      src_order    o  ON o.order_id = ol.order_id
LEFT JOIN dim_date     dd ON dd.full_date = CAST(o.order_ts AS DATE)
LEFT JOIN dim_customer dc ON dc.customer_code = o.customer_code AND dc.is_current
LEFT JOIN dim_product  dp ON dp.product_code  = ol.product_code AND dp.is_current;
  • JOIN src_order is an inner join on purpose: an order line without its header is broken source data, and the grain (order line) needs the header's date and customer.
  • LEFT JOIN to every dimension: an unmatched key must not drop the line. SO-2 belongs to customer C-999 and product SKU-777, neither in the dimensions, and it still loads.
  • COALESCE(sk, -1) points unresolved keys at the Unknown member. The $15 of SO-2 stays in every total, as an "Unknown" bucket that a monitor can alert on, instead of silently vanishing.
  • COALESCE(discount_amt, 0): SO-1 line 1 has no discount, and x − NULL is NULL, so without it the line's net amount would be NULL and drop out of SUM.
  • AND is_current makes this a current-version lookup. That's fine for a first load, but it labels every historical order with the customer's attributes today. Q25 fixes that.
The sample data before the answer runs.
src_order
order_idcustomer_codeorder_ts
SO-1C-0012025-03-02 10:00
SO-2C-9992025-03-03 15:30
src_order_line
order_idline_noproduct_codequantityunit_pricediscount_amt
SO-11SKU-001224.99NULL
SO-12SKU-002189.0010.00
SO-21SKU-77735.000.00
Step 1 of 2

Engineer check. Grain: one row per order line; dimension joins can't multiply rows, because each is_current lookup returns at most one row per key. Run twice: this plain insert doubles the fact; incremental loads use Q27, reloads Q28. NULL: missing keys become -1, missing discounts 0.

#Q25 · Point-in-time lookup: the tier at the time of purchase

Hard · As-of join

dim_customer tracks loyalty_tier as SCD2. C-001 was BRONZE until 31 March and GOLD from 1 April. Count orders by the tier the customer had when they ordered, and show how the answer changes if you join on the current tier instead.

Sample data

dim_customer

customer_skcustomer_codeloyalty_tiereffective_fromeffective_tois_current
1C-001BRONZE2024-11-022025-03-31FALSE
4C-001GOLD2025-04-019999-12-31TRUE
2C-002SILVER2024-12-019999-12-31TRUE

src_order

order_idcustomer_codeorder_ts
SO-1C-0012025-03-05 10:00
SO-2C-0012025-03-20 18:00
SO-3C-0012025-04-02 09:00
SO-4C-0022025-04-03 12:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_customer (customer_sk INT, customer_code VARCHAR, loyalty_tier VARCHAR,
                           effective_from DATE, effective_to DATE, is_current BOOLEAN);
INSERT INTO dim_customer VALUES
    (1, 'C-001', 'BRONZE', DATE '2024-11-02', DATE '2025-03-31', FALSE),
    (4, 'C-001', 'GOLD',   DATE '2025-04-01', DATE '9999-12-31', TRUE),
    (2, 'C-002', 'SILVER', DATE '2024-12-01', DATE '9999-12-31', TRUE);
CREATE TABLE src_order (order_id VARCHAR, customer_code VARCHAR, order_ts TIMESTAMP);
INSERT INTO src_order VALUES
    ('SO-1', 'C-001', TIMESTAMP '2025-03-05 10:00'), ('SO-2', 'C-001', TIMESTAMP '2025-03-20 18:00'),
    ('SO-3', 'C-001', TIMESTAMP '2025-04-02 09:00'), ('SO-4', 'C-002', TIMESTAMP '2025-04-03 12:00');

Expected output

tier_at_purchaseorders
BRONZE2
GOLD1
SILVER1
Hint

Instead of is_current, join on the version whose validity window contains the order date.

Answer
sql
SELECT dc.loyalty_tier AS tier_at_purchase, COUNT(*) AS orders
FROM src_order o
JOIN dim_customer dc
  ON dc.customer_code = o.customer_code
 AND CAST(o.order_ts AS DATE) BETWEEN dc.effective_from AND dc.effective_to
GROUP BY dc.loyalty_tier
ORDER BY dc.loyalty_tier;
  • order date BETWEEN effective_from AND effective_to picks the version that was true on the day of the order: SO-1 and SO-2 find the BRONZE row, SO-3 the GOLD row. This is a point-in-time (as-of) join.
  • It returns exactly one version per order because Q22's windows are contiguous and don't overlap. If they did overlap, this join would duplicate orders.
  • Joining on is_current credits all three of C-001's orders to GOLD, as the step-through shows. Any "did customers spend more after upgrading?" analysis is then meaningless, because the history has been rewritten.
  • In a real warehouse you resolve this once, at load time: the fact load uses the as-of join to pick customer_sk, and since each version has its own surrogate key, the fact row is pinned to the right version forever. Reports then join on customer_sk with no date logic at all.
Each order matched to the version valid on its date.
as-of match
order_idorder_datecustomer_skloyalty_tiereffective_fromeffective_to
keptSO-12025-03-051BRONZE2024-11-022025-03-31
keptSO-22025-03-201BRONZE2024-11-022025-03-31
keptSO-32025-04-024GOLD2025-04-019999-12-31
keptSO-42025-04-032SILVER2024-12-019999-12-31
Step 1 of 3

Engineer check. Grain: one row per order, because the windows don't overlap. NULL: a window with a NULL end date would match nothing; that's why the sentinel 9999-12-31 exists.

#Q26 · Additive, semi-additive and non-additive measures

Medium · Measures

From two small facts, compute (1) total revenue for March, (2) total stock on hand at the end of March across both products, and (3) the March margin %. Then show the wrong answer each one gets if you aggregate it like revenue.

Sample data

fact_sales_daily

sale_dateproductrevenuecost
2025-03-01Mouse100.0060.00
2025-03-01Keyboard900.00810.00
2025-03-31Mouse300.00150.00
2025-03-31Keyboard100.0090.00

fact_inventory

snapshot_dateproducton_hand
2025-03-01Mouse40
2025-03-01Keyboard10
2025-03-31Mouse25
2025-03-31Keyboard12
Sample data as SQL (to run it yourself)
sql
CREATE TABLE fact_sales_daily (sale_date DATE, product VARCHAR, revenue DECIMAL(10,2), cost DECIMAL(10,2));
INSERT INTO fact_sales_daily VALUES
    (DATE '2025-03-01', 'Mouse', 100.00, 60.00), (DATE '2025-03-01', 'Keyboard', 900.00, 810.00),
    (DATE '2025-03-31', 'Mouse', 300.00, 150.00), (DATE '2025-03-31', 'Keyboard', 100.00, 90.00);
CREATE TABLE fact_inventory (snapshot_date DATE, product VARCHAR, on_hand INT);
INSERT INTO fact_inventory VALUES
    (DATE '2025-03-01', 'Mouse', 40), (DATE '2025-03-01', 'Keyboard', 10),
    (DATE '2025-03-31', 'Mouse', 25), (DATE '2025-03-31', 'Keyboard', 12);

Expected output

revenuemonth_end_on_handmargin_pct
1400.003720.7
Hint

Revenue adds up across anything. Stock is a level, not a flow. A percentage is a ratio of two sums.

Answer
sql
SELECT
    (SELECT SUM(revenue) FROM fact_sales_daily)                                   AS revenue,
    (SELECT SUM(on_hand) FROM fact_inventory
      WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM fact_inventory))     AS month_end_on_hand,
    (SELECT ROUND(100 * SUM(revenue - cost) / NULLIF(SUM(revenue), 0), 1)
       FROM fact_sales_daily)                                                     AS margin_pct;
  • Additive (revenue): sums correctly across every dimension, including time. $1,400.
  • Semi-additive (stock on hand): you can sum it across products at one moment, but not across dates: the 40 mice on 1 March and the 25 on 31 March are partly the same mice. Across time you take the end-of-period value (or an average). Summing all four snapshots gives 87, an amount of stock that never existed.
  • Non-additive (margin %): a ratio can't be summed or averaged. Recompute it from its parts: SUM(revenue − cost) ÷ SUM(revenue) = 290 ÷ 1,400 = 20.7%. The average of the four daily percentages is 27.5%, because it gives the $100 keyboard day the same weight as the $900 one. NULLIF(…, 0) avoids dividing by zero.

The rule for fact design follows: store the additive parts (revenue, cost), never the ratio, so any aggregation can rebuild the ratio correctly.

Each measure aggregated like revenue.
naive
revenuesum_of_all_snapshotsavg_of_daily_margins
wrong1400.008727.5
Step 1 of 2

Engineer check. Grain: know each measure's additivity before you sum it. NULL: NULLIF(denominator, 0) turns divide-by-zero into NULL rather than an error.

#Q27 · An incremental load with a watermark that can't skip rows

Hard · Watermarking

Load only new rows from src_sales_feed into fact_sales on each run, tracking progress in load_control. The load must:

  • not skip a row that lands while the load is running,
  • not skip an order that arrives late (an old order_ts that lands today),
  • leave the watermark alone when a run finds nothing new.

Sample data

src_sales_feed

order_idline_noamountorder_ts_loaded_at
SO-1149.982025-03-01 10:002025-03-01 10:05
SO-2179.002025-03-01 11:002025-03-01 11:02
SO-3115.002025-03-02 09:002025-03-02 09:01

load_control

table_namewatermark_ts
fact_sales1900-01-01 00:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE src_sales_feed (order_id VARCHAR, line_no INT, amount DECIMAL(10,2), order_ts TIMESTAMP, _loaded_at TIMESTAMP);
INSERT INTO src_sales_feed VALUES
    ('SO-1', 1, 49.98, TIMESTAMP '2025-03-01 10:00', TIMESTAMP '2025-03-01 10:05'),
    ('SO-2', 1, 79.00, TIMESTAMP '2025-03-01 11:00', TIMESTAMP '2025-03-01 11:02'),
    ('SO-3', 1, 15.00, TIMESTAMP '2025-03-02 09:00', TIMESTAMP '2025-03-02 09:01');
CREATE TABLE fact_sales (order_id VARCHAR, line_no INT, amount DECIMAL(10,2), order_ts TIMESTAMP, _loaded_at TIMESTAMP);
CREATE TABLE load_control (table_name VARCHAR, watermark_ts TIMESTAMP);
INSERT INTO load_control VALUES ('fact_sales', TIMESTAMP '1900-01-01');

Expected output

rows_loadedwatermark_ts
32025-03-02 09:01
Hint

Three decisions: which column the watermark follows, when you read the upper bound, and what the watermark becomes when MAX finds no rows.

Answer
sql
-- 1. Fix this run's upper bound BEFORE loading anything
CREATE OR REPLACE TEMP TABLE batch AS
SELECT
    (SELECT watermark_ts FROM load_control WHERE table_name = 'fact_sales') AS lower_ts,
    MAX(_loaded_at)                                                         AS upper_ts
FROM src_sales_feed
WHERE _loaded_at > (SELECT watermark_ts FROM load_control WHERE table_name = 'fact_sales');

-- 2. Load exactly the rows in (lower, upper]
INSERT INTO fact_sales
SELECT f.*
FROM src_sales_feed f, batch b
WHERE f._loaded_at >  b.lower_ts
  AND f._loaded_at <= b.upper_ts;

-- 3. Advance to the bound you loaded up to; an empty run leaves it unchanged
UPDATE load_control
SET watermark_ts = COALESCE((SELECT upper_ts FROM batch), watermark_ts)
WHERE table_name = 'fact_sales';
  • Follow the ingestion time (_loaded_at), not the business time (order_ts). A late order has an old order_ts, so a watermark on order_ts has already passed it and skips it forever. _loaded_at says when the row reached you, which is what "new since last run" means.
  • Fix the upper bound first, then load (lower, upper]. The common version inserts WHERE _loaded_at > watermark and afterwards sets the watermark to MAX(_loaded_at). A row that lands between those two statements is above the old watermark but below the new one: never loaded, never seen again. Freezing upper_ts in step 1 and loading up to it closes the gap; anything that lands later has _loaded_at > upper_ts and waits for the next run.
  • COALESCE(upper_ts, watermark_ts) keeps the watermark when the batch is empty (MAX over no rows is NULL). Don't reach for GREATEST(old, new) here: in Snowflake, GREATEST returns NULL if any argument is NULL (docs), so an empty run would set the watermark to NULL, and the next run's > NULL filter would match nothing, ever. (GREATEST_IGNORE_NULLS exists for this.) DuckDB's and Postgres's GREATEST ignore NULLs, so the bug never appears in local testing.
  • Re-run: the second run finds nothing above the watermark and leaves both the fact and the watermark unchanged. The build verified it.
The first run loads all three rows and sets the watermark to the newest _loaded_at.
fact_sales
order_idamountorder_ts_loaded_at
insertedSO-149.982025-03-01 10:002025-03-01 10:05
insertedSO-279.002025-03-01 11:002025-03-01 11:02
insertedSO-315.002025-03-02 09:002025-03-02 09:01
load_control
table_namewatermark_ts
fact_sales2025-03-02 09:01
Step 1 of 4

One gap remains, and it's about how _loaded_at is assigned rather than about this SQL: a slow writer can commit a row whose _loaded_at is earlier than one already loaded. If your source can do that, re-read a small safety window (_loaded_at > watermark − 10 minutes) and load with MERGE on the grain, so the overlap is absorbed instead of duplicated.

Engineer check. Grain: one row per order line, provided each run's window (lower, upper] doesn't overlap the last. Run twice: verified unchanged. NULL: an empty batch's MAX is NULL, so the watermark update must be NULL-proof, and in Snowflake GREATEST is not.

#Q28 · Reload one day without duplicates or leftovers

Hard · Idempotent reprocessing

Upstream fixed 27 March: one line's quantity changed, one line was deleted in the source, and one line was added. Reload only that day in fact_sales, leaving other days alone, with no duplicates and no leftover rows. Compare a delete-and-insert with a MERGE on the grain.

Sample data

fact_sales

sale_dateorder_idline_noquantitynet_amount
2025-03-26SO-71110.00
2025-03-27SO-81240.00
2025-03-27SO-82115.00
2025-03-27SO-91130.00

src_fixed

sale_dateorder_idline_noquantitynet_amount
2025-03-27SO-81360.00
2025-03-27SO-91130.00
2025-03-27SO-92112.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE fact_sales (sale_date DATE, order_id VARCHAR, line_no INT, quantity INT, net_amount DECIMAL(10,2));
INSERT INTO fact_sales VALUES
    (DATE '2025-03-26', 'SO-7', 1, 1, 10.00),
    (DATE '2025-03-27', 'SO-8', 1, 2, 40.00),
    (DATE '2025-03-27', 'SO-8', 2, 1, 15.00),
    (DATE '2025-03-27', 'SO-9', 1, 1, 30.00);
CREATE TABLE src_fixed (sale_date DATE, order_id VARCHAR, line_no INT, quantity INT, net_amount DECIMAL(10,2));
INSERT INTO src_fixed VALUES
    (DATE '2025-03-27', 'SO-8', 1, 3, 60.00),    -- quantity corrected
                                                  -- SO-8 line 2 deleted upstream
    (DATE '2025-03-27', 'SO-9', 1, 1, 30.00),    -- unchanged
    (DATE '2025-03-27', 'SO-9', 2, 1, 12.00);    -- new line

Expected output

sale_dateorder_idline_noquantitynet_amount
2025-03-26SO-71110.00
2025-03-27SO-81360.00
2025-03-27SO-91130.00
2025-03-27SO-92112.00
Hint

One of the two approaches can't see rows that disappeared from the source.

Answer
sql
BEGIN TRANSACTION;

DELETE FROM fact_sales WHERE sale_date = DATE '2025-03-27';

INSERT INTO fact_sales
SELECT sale_date, order_id, line_no, quantity, net_amount
FROM src_fixed
WHERE sale_date = DATE '2025-03-27';

COMMIT;
  • Delete the day, insert the day. Whatever the fact held for 27 March is replaced by exactly what the source now holds. The corrected quantity, the new line and the deletion all come through, and 26 March is untouched.
  • The transaction means no reader ever sees the day half-deleted. Without it, a dashboard refreshing mid-load shows 27 March as empty.
  • MERGE on (order_id, line_no) handles the update and the new line, but not the deletion: SO-8 line 2 is no longer in the source, so it never matches anything, and it stays in the fact as a $15 leftover (step 2). Some engines can fix this with WHEN NOT MATCHED BY SOURCE … THEN DELETE (DuckDB, SQL Server, Databricks), but Snowflake's MERGE has no such clause (syntax), and you'd also have to limit it to the one day.
  • Re-run: deleting and re-inserting the same day gives the same result. The build verified it.

Use delete-and-insert by partition when the source can delete; MERGE is lighter when it only inserts and updates.

The fact holds the old version of 27 March.
fact_sales
sale_dateorder_idline_noquantitynet_amount
2025-03-26SO-71110.00
look here2025-03-27SO-81240.00
look here2025-03-27SO-82115.00
look here2025-03-27SO-91130.00
Step 1 of 4

Engineer check. Grain: one row per order line within the day. Run twice: verified unchanged. NULL: the delete must target exactly the reloaded partition; a NULL sale_date would survive every day's delete, so keep it NOT NULL.

#Q29 · A late-arriving dimension: the inferred member

Hard · Late-arriving dimension

An order line references SKU-009, which the product feed hasn't sent yet. Don't drop the line, and don't route it to -1 (that loses which product it was). Insert an inferred placeholder product, load the fact against it, and when the real product arrives, fill the placeholder in so every fact that already points at it gets the right attributes.

Sample data

dim_product

product_skproduct_codeproduct_namecategoryis_inferred
100SKU-001Wireless MouseAccessoriesFALSE

src_order_line

order_idline_noproduct_codenet_amount
SO-11SKU-00149.98
SO-12SKU-009120.00

stg_product_late

product_codeproduct_namecategory
SKU-0094K MonitorDisplays
Sample data as SQL (to run it yourself)
sql
CREATE SEQUENCE product_sk_seq START 100;
CREATE TABLE dim_product (product_sk INTEGER DEFAULT nextval('product_sk_seq'), product_code VARCHAR,
                          product_name VARCHAR, category VARCHAR, is_inferred BOOLEAN DEFAULT FALSE);
INSERT INTO dim_product (product_code, product_name, category) VALUES ('SKU-001', 'Wireless Mouse', 'Accessories');
CREATE TABLE src_order_line (order_id VARCHAR, line_no INT, product_code VARCHAR, net_amount DECIMAL(10,2));
INSERT INTO src_order_line VALUES ('SO-1', 1, 'SKU-001', 49.98), ('SO-1', 2, 'SKU-009', 120.00);
CREATE TABLE fact_sales (order_id VARCHAR, line_no INT, product_sk INT, net_amount DECIMAL(10,2));
CREATE TABLE stg_product_late (product_code VARCHAR, product_name VARCHAR, category VARCHAR);
INSERT INTO stg_product_late VALUES ('SKU-009', '4K Monitor', 'Displays');

Expected output

order_idline_noproduct_skproduct_namecategoryis_inferrednet_amount
SO-11100Wireless MouseAccessoriesFALSE49.98
SO-121014K MonitorDisplaysFALSE120.00
Hint

The placeholder needs a real surrogate key and the real natural key; only its attributes are unknown.

Answer
sql
-- 1. Before the fact load: a placeholder for every unknown product code
INSERT INTO dim_product (product_code, product_name, category, is_inferred)
SELECT DISTINCT ol.product_code, 'UNKNOWN', 'UNKNOWN', TRUE
FROM src_order_line ol
WHERE NOT EXISTS (SELECT 1 FROM dim_product d WHERE d.product_code = ol.product_code);

-- 2. The fact load now resolves every code to a real surrogate key
INSERT INTO fact_sales
SELECT ol.order_id, ol.line_no, d.product_sk, ol.net_amount
FROM src_order_line ol
JOIN dim_product d ON d.product_code = ol.product_code;

-- 3. Later, when the product feed delivers it: fill the placeholder in place
UPDATE dim_product AS t
SET product_name = s.product_name, category = s.category, is_inferred = FALSE
FROM stg_product_late s
WHERE t.product_code = s.product_code AND t.is_inferred;
  • Step 1 creates a real dimension row for SKU-009, with the known natural key, a new surrogate key, placeholder attributes and is_inferred = TRUE so it can be monitored.
  • Step 2 can now use an inner join safely: every code resolves. The $120 line keeps its link to which product it was.
  • Step 3 fills the placeholder in place (a Type 1 fill), keeping the same surrogate key. Every fact row already pointing at product 101 shows "4K Monitor, Displays" immediately, with no fact update.
  • Why not -1? Unknown loses the natural key: when the real product arrives, nothing tells you which -1 facts were SKU-009.
  • Why not a new surrogate key for the real product? Then every fact pointing at the placeholder must be re-pointed: a large, risky fact UPDATE. Filling in place avoids it entirely.
SKU-009 gets a real surrogate key and placeholder attributes.
dim_product
product_skproduct_codeproduct_namecategoryis_inferred
100SKU-001Wireless MouseAccessoriesFALSE
inserted101SKU-009UNKNOWNUNKNOWNTRUE
Step 1 of 3

Engineer check. Grain: unchanged on both tables. Run twice: Step 1's NOT EXISTS and Step 3's is_inferred filter make both dimension steps re-runnable; Step 2 is a plain fact insert. Monitor how many inferred rows stay unfilled, and for how long.

#Other kinds of fact

Not every fact is a row per transaction. These record conditions, many-to-many links, balances and processes.

#Q30 · A factless fact: promoted but never sold

Medium · Factless fact

fact_promotion_coverage records that a product was on promotion on a date. It has no measures. Which products were on promotion on a day when they sold nothing?

Sample data

fact_promotion_coverage

sale_dateproduct
2025-03-10Mouse
2025-03-10Keyboard
2025-03-11Mouse
2025-03-11Webcam

fact_sales

sale_dateproductquantity
2025-03-10Mouse3
2025-03-11Mouse1
2025-03-11Keyboard2
Sample data as SQL (to run it yourself)
sql
CREATE TABLE fact_promotion_coverage (sale_date DATE, product VARCHAR);
INSERT INTO fact_promotion_coverage VALUES
    (DATE '2025-03-10', 'Mouse'), (DATE '2025-03-10', 'Keyboard'), (DATE '2025-03-11', 'Mouse'), (DATE '2025-03-11', 'Webcam');
CREATE TABLE fact_sales (sale_date DATE, product VARCHAR, quantity INT);
INSERT INTO fact_sales VALUES
    (DATE '2025-03-10', 'Mouse', 3), (DATE '2025-03-11', 'Mouse', 1), (DATE '2025-03-11', 'Keyboard', 2);

Expected output

sale_dateproduct
2025-03-10Keyboard
2025-03-11Webcam
Hint

The sales fact can't show a sale that didn't happen. Start from the table that records what could have happened.

Answer
sql
SELECT pc.sale_date, pc.product
FROM fact_promotion_coverage pc
WHERE NOT EXISTS (
    SELECT 1 FROM fact_sales fs
    WHERE fs.sale_date = pc.sale_date AND fs.product = pc.product
)
ORDER BY 1, 2;
  • A factless fact records that an event or a condition happened, with keys and no numbers: a promotion ran, a student was enrolled, a product was eligible.
  • It answers what didn't happen, which a measure fact can't: the sales fact has no row for the keyboard on 10 March, so you can't ask it "which promotions sold nothing?". Starting from the coverage table and anti-joining to sales (NOT EXISTS) finds the promoted-but-unsold product-days.
  • The keyboard sold on 11 March, but it wasn't promoted that day, so it isn't counted as a promotion success. The anti-join matches on both date and product.
The sample data before the answer runs.
fact_promotion_coverage
sale_dateproduct
2025-03-10Mouse
2025-03-10Keyboard
2025-03-11Mouse
2025-03-11Webcam
fact_sales
sale_dateproductquantity
2025-03-10Mouse3
2025-03-11Mouse1
2025-03-11Keyboard2
Step 1 of 2

Engineer check. Grain: one row per promoted product-day. NULL: NOT EXISTS is safe even if a sales key is NULL; NOT IN wouldn't be (Q14).

#Q31 · A bridge table that doesn't inflate revenue

Hard · Bridge and allocation

An order can use several coupons. Report revenue by coupon so that the coupon totals add up to the real revenue. Show how a plain join to the coupon list inflates it.

Sample data

fact_sales

order_idline_nonet_amount
SO-1160.00
SO-1240.00
SO-2150.00

order_coupon

order_idcoupon_code
SO-1SPRING
SO-1FREESHIP
SO-2SPRING
Sample data as SQL (to run it yourself)
sql
CREATE TABLE fact_sales (order_id VARCHAR, line_no INT, net_amount DECIMAL(10,2));
INSERT INTO fact_sales VALUES ('SO-1', 1, 60.00), ('SO-1', 2, 40.00), ('SO-2', 1, 50.00);
CREATE TABLE order_coupon (order_id VARCHAR, coupon_code VARCHAR);
INSERT INTO order_coupon VALUES ('SO-1', 'SPRING'), ('SO-1', 'FREESHIP'), ('SO-2', 'SPRING');

Expected output

coupon_codeattributed_revenue
FREESHIP50.0
SPRING100.0
Hint

Each order's revenue has to be split among its coupons, not copied to each of them.

Answer
sql
CREATE OR REPLACE TABLE bridge_order_coupon AS
SELECT order_id, coupon_code,
       1.0 / COUNT(*) OVER (PARTITION BY order_id) AS weight     -- weights per order sum to 1
FROM order_coupon;

SELECT b.coupon_code,
       ROUND(SUM(fs.net_amount * b.weight), 2) AS attributed_revenue
FROM fact_sales fs
JOIN bridge_order_coupon b ON b.order_id = fs.order_id
GROUP BY b.coupon_code
ORDER BY b.coupon_code;
  • The fan-out: joining order lines to an order-level coupon list repeats each line once per coupon. SO-1's $100 appears under SPRING and under FREESHIP, so the coupon totals sum to $250 for $150 of real revenue (step 2).
  • The bridge (bridge_order_coupon) resolves the many-to-many relationship and carries a weight: 1 / number of coupons on the order. SO-1's two coupons get 0.5 each.
  • SUM(net_amount × weight) splits each order's revenue across its coupons, so the totals sum to exactly the real revenue. An even split is the simplest rule; a business can weight by coupon value instead, as long as each order's weights sum to 1.
The plain join repeats SO-1's lines once per coupon.
fact_sales JOIN order_coupon
order_idline_nonet_amountcoupon_code
wrongSO-1160.00FREESHIP
wrongSO-1160.00SPRING
wrongSO-1240.00FREESHIP
wrongSO-1240.00SPRING
SO-2150.00SPRING
Step 1 of 3

Engineer check. Grain: the bridge is one row per order and coupon; the report is one row per coupon. Check: SUM(weight) per order is exactly 1, and the weighted total equals the fact total.

#Q32 · Month-end stock from a snapshot fact

Medium · Periodic snapshot

fact_inventory_snapshot holds end-of-day stock per store. Report stock on hand at the end of each month per store. February's snapshot for the 28th failed to run, so February's last available snapshot is the 27th.

Sample data

fact_inventory_snapshot

snapshot_datestoreproducton_hand
2025-01-30DowntownMouse50
2025-01-31DowntownMouse48
2025-01-31DowntownKeyboard12
2025-02-26DowntownMouse30
2025-02-27DowntownMouse28
2025-02-27DowntownKeyboard9
Sample data as SQL (to run it yourself)
sql
CREATE TABLE fact_inventory_snapshot (snapshot_date DATE, store VARCHAR, product VARCHAR, on_hand INT);
INSERT INTO fact_inventory_snapshot VALUES
    (DATE '2025-01-30', 'Downtown', 'Mouse', 50), (DATE '2025-01-31', 'Downtown', 'Mouse', 48), (DATE '2025-01-31', 'Downtown', 'Keyboard', 12),
    (DATE '2025-02-26', 'Downtown', 'Mouse', 30), (DATE '2025-02-27', 'Downtown', 'Mouse', 28), (DATE '2025-02-27', 'Downtown', 'Keyboard', 9);

Expected output

monthstoresnapshot_usedmonth_end_on_hand
2025-01-01Downtown2025-01-3160
2025-02-01Downtown2025-02-2737
Hint

On-hand stock can be summed across products but not across days. Pick one day per month first; which day, if the calendar's last day is missing?

Answer
sql
WITH last_snapshot AS (
    SELECT *
    FROM fact_inventory_snapshot
    QUALIFY snapshot_date = MAX(snapshot_date)
            OVER (PARTITION BY date_trunc('month', snapshot_date), store)
)
SELECT CAST(date_trunc('month', snapshot_date) AS DATE) AS month,
       store,
       MAX(snapshot_date)                 AS snapshot_used,
       SUM(on_hand)                       AS month_end_on_hand
FROM last_snapshot
GROUP BY ALL
ORDER BY month, store;
  • Stock is semi-additive (Q26): sum across products at one moment, never across days. So each month has to collapse to a single snapshot day first.
  • The CTE's QUALIFY snapshot_date = MAX(snapshot_date) OVER (…) keeps the rows of the last available snapshot of each month and store. It has to happen in its own step: QUALIFY runs after GROUP BY, so it can't filter rows before they're summed. The outer query then adds up the products on that one day.
  • The calendar-month-end version (join to the last calendar day of each month) loses February completely, because 28 February has no rows (step 2). A month disappearing from a stock report is worse than a slightly stale number, and the last-available rule degrades gracefully.
  • snapshot_used shows which day each figure comes from, so a missing month-end snapshot is visible in the report instead of hidden. Alert when it isn't the calendar month end.
The rows on each month's last available snapshot date are kept.
snapshots
snapshot_datestoreproducton_hand
removed2025-01-30DowntownMouse50
kept2025-01-31DowntownKeyboard12
kept2025-01-31DowntownMouse48
removed2025-02-26DowntownMouse30
kept2025-02-27DowntownKeyboard9
kept2025-02-27DowntownMouse28
Step 1 of 2

Engineer check. Grain: one row per month and store. NULL: a month with no snapshots at all still disappears; join to a month spine if the report must show it.

#Q33 · An accumulating snapshot of order fulfilment

Hard · Accumulating snapshot

fact_order_fulfillment has one row per order and a date column per milestone, filled in as the order progresses. Stamp the shipped date from today's shipping_events, then report the average days from order to delivery and the share of orders delivered.

Sample data

fact_order_fulfillment

order_idordered_dateshipped_datedelivered_date
SO-12025-03-012025-03-022025-03-05
SO-22025-03-02NULLNULL
SO-32025-03-032025-03-042025-03-10

shipping_events

order_idship_ts
SO-22025-03-06 14:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE fact_order_fulfillment (order_id VARCHAR PRIMARY KEY, ordered_date DATE, shipped_date DATE, delivered_date DATE);
INSERT INTO fact_order_fulfillment VALUES
    ('SO-1', DATE '2025-03-01', DATE '2025-03-02', DATE '2025-03-05'),
    ('SO-2', DATE '2025-03-02', NULL, NULL),
    ('SO-3', DATE '2025-03-03', DATE '2025-03-04', DATE '2025-03-10');
CREATE TABLE shipping_events (order_id VARCHAR, ship_ts TIMESTAMP);
INSERT INTO shipping_events VALUES ('SO-2', TIMESTAMP '2025-03-06 14:00');

Expected output

avg_days_to_deliverpct_delivered
5.566.7
Hint

Unlike other facts, this one is updated as orders move. And which rows should an average of 'days to deliver' include?

Answer
sql
MERGE INTO fact_order_fulfillment t
USING (SELECT order_id, MIN(CAST(ship_ts AS DATE)) AS ship_date FROM shipping_events GROUP BY order_id) s
   ON t.order_id = s.order_id
WHEN MATCHED AND t.shipped_date IS NULL THEN UPDATE SET shipped_date = s.ship_date;

SELECT
    ROUND(AVG(datediff('day', ordered_date, delivered_date)), 1)          AS avg_days_to_deliver,
    ROUND(100.0 * COUNT(delivered_date) / COUNT(*), 1)                     AS pct_delivered
FROM fact_order_fulfillment;
  • An accumulating snapshot has one row per process instance (an order) with a date per milestone. Rows are updated as milestones happen, unlike transaction facts, which only receive inserts.
  • The MERGE source is grouped per order, so two shipping events for one order can't make the merge nondeterministic (Q10), and MIN keeps the first shipment.
  • AND t.shipped_date IS NULL stamps a milestone once and never overwrites it, which makes the statement safe to re-run with the same events.
  • AVG(datediff(…)) skips SO-2, because its delivered_date is NULL and so is the difference. That's correct: an undelivered order has no delivery time yet. The share delivered uses COUNT(delivered_date) (non-NULL only) over COUNT(*).
  • The payoff of the design: lag between any two milestones is a single subtraction.
The sample data before the answer runs.
fact_order_fulfillment
order_idordered_dateshipped_datedelivered_date
SO-12025-03-012025-03-022025-03-05
SO-22025-03-02NULLNULL
SO-32025-03-032025-03-042025-03-10
shipping_events
order_idship_ts
SO-22025-03-06 14:00
Step 1 of 2

Engineer check. Grain: one row per order, updated in place. Run twice: the IS NULL guard makes the stamp idempotent. NULL: open milestones are NULL, and the averages correctly leave them out.

#Q34 · Drill across two facts without fanning out

Medium · Conformed dimensions

fact_sales and fact_returns share the conformed dim_product and a month. Report net revenue after refunds per category and month. Explain why joining the two facts directly gives the wrong answer.

Sample data

fact_sales

monthproduct_sknet_amount
2025-03-01100100.00
2025-03-0110050.00
2025-03-01101300.00

fact_returns

monthproduct_skrefund_amount
2025-03-0110020.00
2025-03-0110010.00
2025-04-01101300.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_product (product_sk INT, category VARCHAR);
INSERT INTO dim_product VALUES (100, 'Accessories'), (101, 'Displays');
CREATE TABLE fact_sales (month DATE, product_sk INT, net_amount DECIMAL(10,2));
INSERT INTO fact_sales VALUES (DATE '2025-03-01', 100, 100.00), (DATE '2025-03-01', 100, 50.00), (DATE '2025-03-01', 101, 300.00);
CREATE TABLE fact_returns (month DATE, product_sk INT, refund_amount DECIMAL(10,2));
INSERT INTO fact_returns VALUES (DATE '2025-03-01', 100, 20.00), (DATE '2025-03-01', 100, 10.00), (DATE '2025-04-01', 101, 300.00);

Expected output

categorymonthrevenuerefundsnet_after_refunds
Accessories2025-03-01150.0030.00120.00
Displays2025-03-01300.000.00300.00
Displays2025-04-010.00300.00-300.00
Hint

Bring each fact to the shared grain on its own first, then combine the results.

Answer
sql
WITH sales AS (
    SELECT p.category, s.month, SUM(s.net_amount) AS revenue
    FROM fact_sales s JOIN dim_product p USING (product_sk)
    GROUP BY ALL
),
returns AS (
    SELECT p.category, r.month, SUM(r.refund_amount) AS refunds
    FROM fact_returns r JOIN dim_product p USING (product_sk)
    GROUP BY ALL
)
SELECT
    COALESCE(s.category, r.category)               AS category,
    COALESCE(s.month, r.month)                     AS month,
    COALESCE(s.revenue, 0)                         AS revenue,
    COALESCE(r.refunds, 0)                         AS refunds,
    COALESCE(s.revenue, 0) - COALESCE(r.refunds, 0) AS net_after_refunds
FROM sales s
FULL OUTER JOIN returns r ON r.category = s.category AND r.month = s.month
ORDER BY month, category;
  • Aggregate each fact separately to the shared grain (category, month), then join the two results. This is drill-across, and it works because both facts use the same, conformed product dimension and calendar.
  • Joining the facts directly matches every sale row with every return row of the same product: the Accessories sales ($150 in two rows) meet two refunds, so revenue is counted twice and refunds twice (step 2).
  • FULL OUTER JOIN keeps a category-month that has sales but no returns (Displays in March) and one with returns but no sales (Displays in April, a return of an earlier sale).
  • COALESCE(…, 0) before subtracting, because revenue minus a missing refund total would otherwise be NULL.
Joining the facts row to row pairs every sale with every refund for the product.
fact_sales JOIN fact_returns
monthproduct_sknet_amountrefund_amount
wrong2025-03-0110050.0010.00
wrong2025-03-0110050.0020.00
wrong2025-03-01100100.0010.00
wrong2025-03-01100100.0020.00
Step 1 of 2

Engineer check. Grain: never join two facts at their own grains; meet at a shared, coarser grain. NULL: COALESCE each side to 0 after the FULL OUTER JOIN.

#Quality and serving

Checking the warehouse before anyone reads it, and one report that is easy to get subtly wrong.

#Q35 · A quality gate before publishing

Hard · Data quality assertions

Before fact_sales is published, return every violation of three rules in one query: the grain (order_id, line_no) is unique, net_amount is not negative, and every product_sk exists in dim_product. An empty result means the gate passes.

Sample data

fact_sales

order_idline_noproduct_sknet_amount
SO-1110049.98
SO-1210179.00
SO-2110015.00
SO-2110015.00
SO-31101-5.00
SO-4155520.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_product (product_sk INT);
INSERT INTO dim_product VALUES (-1), (100), (101);
CREATE TABLE fact_sales (order_id VARCHAR, line_no INT, product_sk INT, net_amount DECIMAL(10,2));
INSERT INTO fact_sales VALUES
    ('SO-1', 1, 100, 49.98), ('SO-1', 2, 101, 79.00),
    ('SO-2', 1, 100, 15.00), ('SO-2', 1, 100, 15.00),
    ('SO-3', 1, 101, -5.00),
    ('SO-4', 1, 555, 20.00);

Expected output

ruleorder_idline_norows_found
DUPLICATE_GRAINSO-212
NEGATIVE_NETSO-311
ORPHAN_PRODUCTSO-411
Hint

Write each rule as a query that returns the rows breaking it, then stack them.

Answer
sql
SELECT 'DUPLICATE_GRAIN' AS rule, order_id, line_no, COUNT(*) AS rows_found
FROM fact_sales
GROUP BY order_id, line_no
HAVING COUNT(*) > 1

UNION ALL
SELECT 'NEGATIVE_NET', order_id, line_no, 1
FROM fact_sales
WHERE net_amount < 0

UNION ALL
SELECT 'ORPHAN_PRODUCT', order_id, line_no, 1
FROM fact_sales f
WHERE NOT EXISTS (SELECT 1 FROM dim_product d WHERE d.product_sk = f.product_sk)

ORDER BY rule, order_id;
  • Each UNION ALL branch is one assertion, written as "return the rows that break it", and labelled so a failure says which rule broke and where.
  • Uniqueness: GROUP BY the grain, HAVING COUNT(*) > 1. This is the test that stands in for the primary key Snowflake doesn't enforce.
  • Business rule: net_amount < 0.
  • Referential integrity: NOT EXISTS against the dimension, never NOT IN (Q14).
  • How a pipeline uses it: load into a staging copy, run the gate, and only if it returns nothing swap the staging table in (or merge it into the published table). If it returns rows, fail the run and report them. It's far cheaper to block here than to explain a wrong dashboard later. This is what dbt tests and similar tools generate for you.
The sample data before the answer runs.
fact_sales
order_idline_noproduct_sknet_amount
SO-1110049.98
SO-1210179.00
SO-2110015.00
SO-2110015.00
SO-31101-5.00
SO-4155520.00
Step 1 of 2

Engineer check. Grain: the gate checks it. Run twice: read-only. NULL: a NULL net_amount passes < 0 and a NULL order_id groups as one key; add explicit IS NULL rules if those matter.

#Q36 · Reconcile the fact against its source

Hard · Reconciliation

Per day, compare SUM(net_amount) in fact_sales with the same total recomputed from src_order_line, and return only the days that differ by more than one cent, including days that exist on only one side. Then explain why matching row counts would not have been enough.

Sample data

src_order_line

order_dateorder_idline_noquantityunit_pricediscount_amt
2025-03-01SO-11224.990.00
2025-03-01SO-12189.0010.00
2025-03-02SO-2135.000.00
2025-03-03SO-31130.000.00

fact_sales

sale_dateorder_idline_nonet_amount
2025-03-01SO-1149.98
2025-03-01SO-1269.00
2025-03-02SO-2115.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE src_order_line (order_date DATE, order_id VARCHAR, line_no INT, quantity INT, unit_price DECIMAL(10,2), discount_amt DECIMAL(10,2));
INSERT INTO src_order_line VALUES
    (DATE '2025-03-01', 'SO-1', 1, 2, 24.99, 0.00), (DATE '2025-03-01', 'SO-1', 2, 1, 89.00, 10.00),
    (DATE '2025-03-02', 'SO-2', 1, 3, 5.00, 0.00),
    (DATE '2025-03-03', 'SO-3', 1, 1, 30.00, 0.00);
CREATE TABLE fact_sales (sale_date DATE, order_id VARCHAR, line_no INT, net_amount DECIMAL(10,2));
INSERT INTO fact_sales VALUES
    (DATE '2025-03-01', 'SO-1', 1, 49.98), (DATE '2025-03-01', 'SO-1', 2, 69.00),
    (DATE '2025-03-02', 'SO-2', 1, 15.00);

Expected output

dsrc_rowsfact_rowssrc_netfact_netdiff
2025-03-0122128.98118.98-10.00
2025-03-031NULL30.00NULL-30.00
Hint

Compute the same total two ways, line them up by day, and keep a day that appears on only one side.

Answer
sql
WITH fact_daily AS (
    SELECT sale_date AS d, SUM(net_amount) AS fact_net, COUNT(*) AS fact_rows
    FROM fact_sales GROUP BY sale_date
),
src_daily AS (
    SELECT order_date AS d,
           SUM(quantity * unit_price - COALESCE(discount_amt, 0)) AS src_net,
           COUNT(*) AS src_rows
    FROM src_order_line GROUP BY order_date
)
SELECT COALESCE(f.d, s.d) AS d, s.src_rows, f.fact_rows, s.src_net, f.fact_net,
       COALESCE(f.fact_net, 0) - COALESCE(s.src_net, 0) AS diff
FROM fact_daily f
FULL OUTER JOIN src_daily s ON s.d = f.d
WHERE ABS(COALESCE(f.fact_net, 0) - COALESCE(s.src_net, 0)) > 0.01
ORDER BY d;
  • Two CTEs compute the same measure two ways: from the published fact, and from the source with the same business rule. That is a control total.
  • FULL OUTER JOIN on the day surfaces a day missing from the fact (3 March was never loaded) as well as a day the source doesn't know.
  • > 0.01 is the tolerance for rounding, so only material differences are reported.
  • Why counts aren't enough: 1 March has two rows on both sides, so a row-count check passes, but the fact's line 2 holds 69.00 where the source says 79.00 (the $10 discount was applied twice). Counts catch missing and duplicated rows; sums catch wrong values. Mature pipelines check both.
The sample data before the answer runs.
src_order_line
order_dateorder_idline_noquantityunit_pricediscount_amt
2025-03-01SO-11224.990.00
2025-03-01SO-12189.0010.00
2025-03-02SO-2135.000.00
2025-03-03SO-31130.000.00
fact_sales
sale_dateorder_idline_nonet_amount
2025-03-01SO-1149.98
2025-03-01SO-1269.00
2025-03-02SO-2115.00
Step 1 of 2

Engineer check. Grain: one row per day with a discrepancy. Run twice: read-only. NULL: a day missing on one side gives NULL totals, so COALESCE before subtracting, or the missing day is filtered out by the WHERE.

#Q37 · Top three categories per month, with subtotals

Hard · Top-N with GROUPING SETS

For each month, return the top three categories by revenue, a subtotal row for the month (all categories, not just the top three), and one grand-total row, in a single query.

Sample data

v_sales_mart

monthcategorynet_amount
2025-03-01Accessories500.00
2025-03-01Displays900.00
2025-03-01Audio300.00
2025-03-01Cables100.00
2025-04-01Accessories200.00
2025-04-01Displays400.00
2025-04-01Audio700.00
2025-04-01Cables250.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE v_sales_mart (month DATE, category VARCHAR, net_amount DECIMAL(10,2));
INSERT INTO v_sales_mart VALUES
    (DATE '2025-03-01', 'Accessories', 500), (DATE '2025-03-01', 'Displays', 900),
    (DATE '2025-03-01', 'Audio', 300), (DATE '2025-03-01', 'Cables', 100),
    (DATE '2025-04-01', 'Accessories', 200), (DATE '2025-04-01', 'Displays', 400),
    (DATE '2025-04-01', 'Audio', 700), (DATE '2025-04-01', 'Cables', 250);

Expected output

monthcategoryrevenue
2025-03-01Displays900.00
2025-03-01Accessories500.00
2025-03-01Audio300.00
2025-03-01ALL1800.00
2025-04-01Audio700.00
2025-04-01Displays400.00
2025-04-01Cables250.00
2025-04-01ALL1550.00
GRAND TOTALALL3350.00
Hint

GROUPING SETS gives you all three levels in one pass. When you rank, make sure the subtotal rows aren't competing with the categories.

Answer
sql
WITH rolled AS (
    SELECT month, category, SUM(net_amount) AS revenue,
           GROUPING(month)    AS g_month,
           GROUPING(category) AS g_cat
    FROM v_sales_mart
    GROUP BY GROUPING SETS ((month, category), (month), ())
),
ranked AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY month, g_cat ORDER BY revenue DESC) AS rnk
    FROM rolled
)
SELECT
    CASE WHEN g_month = 1 THEN 'GRAND TOTAL' ELSE CAST(month AS VARCHAR) END AS month,
    CASE WHEN g_cat = 1 THEN 'ALL' ELSE category END                        AS category,
    revenue
FROM ranked
WHERE g_cat = 1 OR rnk <= 3
ORDER BY g_month, ranked.month, g_cat, revenue DESC;
  • GROUPING SETS ((month, category), (month), ()) computes per-category rows, a subtotal per month and the grand total in one scan; three UNION ALL queries would scan the data three times.
  • GROUPING(col) returns 1 on rows where that column was rolled up, which tells a subtotal apart from a category that is genuinely NULL.
  • PARTITION BY month, g_cat is the fix. The obvious PARTITION BY month puts the month's subtotal row in the same ranking as its categories. The subtotal is always the largest number, so it takes rank 1 and pushes the real third category out: the query returns the top two (step 2). Partitioning by g_cat as well ranks categories only against categories.
  • WHERE g_cat = 1 OR rnk <= 3 keeps every subtotal and total row, and the top three categories.
  • The subtotal sums all categories (March: 1,800, including Cables), which is usually what a report means by "month total". If you want the total of only the top three, compute it after filtering.
GROUPING SETS produces the category rows, a subtotal per month and a grand total.
rolled
monthcategoryrevenueg_monthg_cat
2025-03-01Displays900.0000
2025-03-01Accessories500.0000
2025-03-01Audio300.0000
2025-03-01Cables100.0000
look here2025-03-01NULL1800.0001
2025-04-01Audio700.0000
2025-04-01Displays400.0000
2025-04-01Cables250.0000
2025-04-01Accessories200.0000
look here2025-04-01NULL1550.0001
look hereNULLNULL3350.0011
Step 1 of 3

Engineer check. Grain: mixed on purpose (category rows, subtotals, a total), so label each row's level clearly for consumers. NULL: rolled-up columns are NULL; use GROUPING() rather than IS NULL to detect them.

#Summary

A dimensional warehouse is a set of promises, and each question here is one of them. The fact table promises a grain, so every load either replaces a partition or merges on the grain. Dimensions promise history: SCD2 closes one window the day before the next opens, and facts pick the version valid when they happened. Missing keys never become NULL; they become the Unknown member, or an inferred member that's filled in later without touching the facts. Measures promise to add up, which holds only if you know which ones are additive, split many-to-many relationships with weights, and never join two facts directly. And before anything is published, a gate checks the promises, and a reconciliation checks the money.

#Concepts at a glance

ConceptIn one lineWhy it matters
GrainWhat one fact row representsEvery load, join and sum has to respect it
Unknown member (-1)The row facts point at when a key is missingTotals stay complete and visible
NULL-safe hashSeparator plus a NULL placeholder, then md5Change detection without false matches
SCD Type 1 / 2 / 3Overwrite / new row per version / one previous valueCorrections vs history vs "current vs previous"
Point-in-time joinMatch the version whose window contains the event dateReports show what was true when it happened
AdditivityAdditive, semi-additive, non-additiveStock isn't summed over time; ratios are recomputed
Race-free watermarkFix the upper bound, load (lower, upper], advance with COALESCENo skipped rows, and empty runs are safe
Partition reloadDelete the day, insert the day, in one transactionHandles deletions that MERGE can't see
Inferred memberA placeholder with the real natural keyLate dimensions fill in without fact updates
Bridge with weightsSplit a value across many-to-many linksTotals don't inflate
Drill-acrossAggregate each fact, then join on conformed dimensionsTwo facts never fan each other out
Quality gate and control totalsAssertions that must return nothing; sums compared with the sourceProblems are stopped before anyone sees them

#Test yourself

Why does an SCD2 load set the old row's effective_to to the load date minus one?

So the windows are contiguous and never overlap: the old version ends the day before the new one starts. A point-in-time join then finds exactly one version for any date.

An empty incremental run sets the watermark to GREATEST(old_watermark, MAX(_loaded_at)). What goes wrong in Snowflake?

MAX over no rows is NULL, and Snowflake's GREATEST returns NULL if any argument is NULL, so the watermark becomes NULL and the next run's > NULL filter never matches again. Use COALESCE(MAX(...), old_watermark) or GREATEST_IGNORE_NULLS.

A source can delete rows. Why is a MERGE on the grain not enough to reload a day?

A deleted row isn't in the source any more, so it never matches anything and stays in the fact. Deleting the whole day and inserting it again removes it.

An order has two coupons. How do you report revenue per coupon without inflating the total?

Use a bridge table with a weight per coupon that sums to 1 per order (0.5 each here), and sum net_amount × weight.

Why does ranking categories with PARTITION BY month lose a category when the input has GROUPING SETS subtotals?

The month's subtotal row is in the same partition and is always the largest, so it takes rank 1. Partition by the GROUPING() flag as well.

#Sources