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.
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
-- 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 areNOT NULLbecause a missing or unknown key points at the-1Unknown 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_amountandnet_amountare 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.
| date_sk | customer_sk | product_sk | order_id | line_no | quantity | net_amount | _batch_id | |
|---|---|---|---|---|---|---|---|---|
| inserted | 20250301 | 1 | 10 | SO-1 | 1 | 2 | 49.98 | B1 |
| inserted | 20250301 | 1 | 11 | SO-1 | 2 | 1 | 79.00 | B1 |
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_sk | customer_code | first_name | city | effective_from | effective_to | is_current | version |
|---|---|---|---|---|---|---|---|
| -1 | UNKNOWN | Unknown | Unknown | 1900-01-01 | 9999-12-31 | TRUE | 1 |
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
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_skis unique per version, not per customer. When a customer moves, they get a new row and a new surrogate key.customer_codeisNOT NULLbut notUNIQUE. It repeats across versions. "This customer today" iscustomer_code = … AND is_current.effective_from/effective_toare the validity window of a version. They power point-in-time joins (Q25).9999-12-31marks "still valid": a real far-future date keeps range predicates simple, where a NULL end date would need special handling in every join.is_currentduplicates what the window says, but makes "the current version" a cheap filter.row_hashfingerprints the tracked attributes, so a load can detect change with one comparison (Q19).versioncounts 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.
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_code | first_name | last_name | email | city | state | country | signup_ts | updated_at |
|---|---|---|---|---|---|---|---|---|
| C-001 | Ana | Lopez | Ana@Example.com | Austin | TX | us | 2024-11-02 10:00 | 2025-01-05 09:00 |
| C-001 | Ana | Lopez | ana@example.com | Austin | TX | US | 2024-11-02 10:00 | 2025-02-10 09:00 |
| C-002 | Raj | Patel | RAJ@EXAMPLE.COM | Pune | MH | in | 2024-12-01 08:00 | 2025-01-20 12:00 |
| NULL | Ghost | User | ghost@x.com | NULL | NULL | US | NULL | 2025-01-21 12:00 |
| C-003 | Li | Wei | li@example.com | Austin | TX | US | 2025-01-15 08:30 | NULL |
| C-003 | Li | Wei | li@example.com | Dallas | TX | US | 2025-01-15 08:30 | 2025-02-01 08:30 |
Sample data as SQL (to run it yourself)
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_code | first_name | email | city | country | updated_at | row_hash |
|---|---|---|---|---|---|---|
| C-001 | Ana | ana@example.com | Austin | US | 2025-02-10 09:00 | 25f696a3… |
| C-002 | Raj | raj@example.com | Pune | IN | 2025-01-20 12:00 | 7f751425… |
| C-003 | Li | li@example.com | Dallas | US | 2025-02-01 08:30 | 9cad7709… |
Hint
Standardise before you deduplicate, or ' C-002' and 'C-002' count as different customers.
Answer
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,LOWERandUPPERmake 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 NULLdrops rows you can't key, including blank strings, whichIS NOT NULLalone would keep. In production these go to a quarantine table rather than vanishing (post 03, Q51).NULLS LASTstops C-003's pull with no timestamp from winning (Q11). C-003's latest real pull says Dallas.row_hashis computed once here so every later load compares one column instead of six.
| customer_code | first_name | city | country | updated_at | ||
|---|---|---|---|---|---|---|
| C-001 | Ana | Ana@Example.com | Austin | us | 2025-01-05 09:00 | |
| C-001 | Ana | ana@example.com | Austin | US | 2025-02-10 09:00 | |
| C-002 | Raj | RAJ@EXAMPLE.COM | Pune | in | 2025-01-20 12:00 | |
| removed | NULL | Ghost | ghost@x.com | NULL | US | 2025-01-21 12:00 |
| C-003 | Li | li@example.com | Austin | US | NULL | |
| C-003 | Li | li@example.com | Dallas | US | 2025-02-01 08:30 |
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_id | a | b |
|---|---|---|
| 1 | AB | C |
| 2 | A | BC |
| 3 | X | NULL |
| 4 | NULL | X |
| 5 | X |
Sample data as SQL (to run it yourself)
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_id | a | b | row_hash |
|---|---|---|---|
| 1 | AB | C | ec9d2345be23e4b02532a752d67f891e |
| 2 | A | BC | 569e76369ed02c0c64c92cc917c4858a |
| 3 | X | NULL | dd5f5749caa0d5b9a5055c73aeb5e445 |
| 4 | NULL | X | aaf1e0c22de74e5475301440bc37922c |
| 5 | X | 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
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 || bturns 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_wsskips 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_wsand||return NULL if any argument is NULL (docs). Every row with any NULL then hashes to NULL, andNULL <> NULLis 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).
- DuckDB and Postgres's
md5turns the string into a fixed-length fingerprint that's identical on every engine, which matters when you reconcile across systems. Snowflake'sHASH()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.
| row_id | a | b | concatenated | |
|---|---|---|---|---|
| wrong | 1 | AB | C | ABC |
| wrong | 2 | A | BC | ABC |
| wrong | 3 | X | NULL | NULL |
| wrong | 4 | NULL | X | NULL |
| 5 | X | X |
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_code | first_name | last_name | email | city | state | country | signup_ts | row_hash |
|---|---|---|---|---|---|---|---|---|
| C-001 | Ana | Lopez | ana@example.com | Austin | TX | US | 2024-11-02 10:00 | h-c001-a |
| C-002 | Raj | Patel | raj@example.com | Pune | MH | IN | 2024-12-01 08:00 | h-c002-a |
| C-003 | Li | Wei | li@example.com | Dallas | TX | US | NULL | h-c003-a |
Sample data as SQL (to run it yourself)
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_sk | customer_code | city | effective_from | effective_to | is_current | version |
|---|---|---|---|---|---|---|
| -1 | UNKNOWN | Unknown | 1900-01-01 | 9999-12-31 | TRUE | 1 |
| 1 | C-001 | Austin | 2024-11-02 | 9999-12-31 | TRUE | 1 |
| 2 | C-002 | Pune | 2024-12-01 | 9999-12-31 | TRUE | 1 |
| 3 | C-003 | Dallas | 2025-03-01 | 9999-12-31 | TRUE | 1 |
Hint
Leave the surrogate key out of the column list. And make the load harmless if it runs on a non-empty table.
Answer
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_skin the column list, so the sequence default numbers each new row. The Unknown member keeps its-1. effective_fromstarts 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 EXISTSis 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).
| customer_code | first_name | last_name | city | state | country | signup_ts | row_hash | |
|---|---|---|---|---|---|---|---|---|
| C-001 | Ana | Lopez | ana@example.com | Austin | TX | US | 2024-11-02 10:00 | h-c001-a |
| C-002 | Raj | Patel | raj@example.com | Pune | MH | IN | 2024-12-01 08:00 | h-c002-a |
| C-003 | Li | Wei | li@example.com | Dallas | TX | US | NULL | h-c003-a |
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_sk | product_code | product_name | category | list_price | effective_from | effective_to | is_current | version |
|---|---|---|---|---|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | Accessories | 24.99 | 2025-01-01 | 9999-12-31 | TRUE | 1 |
| 101 | SKU-002 | Keyboard | Accessories | 98.00 | 2025-01-01 | 9999-12-31 | TRUE | 1 |
stg_product
product_code | product_name | category | list_price |
|---|---|---|---|
| SKU-001 | Wireless Mouse | Accessories | 24.99 |
| SKU-002 | Mechanical Keyboard | Accessories | 89.00 |
| SKU-003 | USB Hub | Accessories | 19.00 |
Sample data as SQL (to run it yourself)
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_sk | product_code | product_name | list_price | is_current | version |
|---|---|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | 24.99 | TRUE | 1 |
| 101 | SKU-002 | Mechanical Keyboard | 89.00 | TRUE | 1 |
| 102 | SKU-003 | USB Hub | 19.00 | TRUE | 1 |
Hint
A plain WHEN MATCHED THEN UPDATE rewrites every matched row, changed or not. Add a condition to the matched branch.
Answer
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_currentmatches each staged product to its current row, so the sameMERGEstays 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 FROMis 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 INSERTadds SKU-003 as a new current product.
| product_sk | product_code | product_name | category | list_price | effective_from | effective_to | is_current | version |
|---|---|---|---|---|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | Accessories | 24.99 | 2025-01-01 | 9999-12-31 | TRUE | 1 |
| 101 | SKU-002 | Keyboard | Accessories | 98.00 | 2025-01-01 | 9999-12-31 | TRUE | 1 |
| product_code | product_name | category | list_price |
|---|---|---|---|
| SKU-001 | Wireless Mouse | Accessories | 24.99 |
| SKU-002 | Mechanical Keyboard | Accessories | 89.00 |
| SKU-003 | USB Hub | Accessories | 19.00 |
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_sk | customer_code | first_name | last_name | email | city | state | country | effective_from | effective_to | is_current | row_hash | version |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | C-001 | Ana | Lopez | ana@example.com | Austin | TX | US | 2024-11-02 | 9999-12-31 | TRUE | h-austin | 1 |
| 2 | C-002 | Raj | Patel | raj@example.com | Pune | MH | IN | 2024-12-01 | 9999-12-31 | TRUE | h-pune | 1 |
stg_customer
customer_code | first_name | last_name | email | city | state | country | row_hash |
|---|---|---|---|---|---|---|---|
| C-001 | Ana | Lopez | ana@example.com | Denver | CO | US | h-denver |
| C-002 | Raj | Patel | raj@example.com | Pune | MH | IN | h-pune |
| C-003 | Li | Wei | li@example.com | Dallas | TX | US | h-dallas |
Sample data as SQL (to run it yourself)
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_sk | customer_code | city | effective_from | effective_to | is_current | version |
|---|---|---|---|---|---|---|
| 1 | C-001 | Austin | 2024-11-02 | 2025-05-31 | FALSE | 1 |
| 3 | C-001 | Denver | 2025-06-01 | 9999-12-31 | TRUE | 2 |
| 2 | C-002 | Pune | 2024-12-01 | 9999-12-31 | TRUE | 1 |
| 4 | C-003 | Dallas | 2025-06-01 | 9999-12-31 | TRUE | 1 |
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
-- 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
MERGEacts 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-MERGEvariant exists, using a source that lists each changed key twice, but it's harder to read.) - Step 1's
t.row_hash <> s.row_hashis the change gate: only C-001 is expired, and C-002 is untouched. effective_to = load date - 1closes 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) + 1is 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.
| customer_sk | customer_code | first_name | last_name | city | state | country | effective_from | effective_to | is_current | row_hash | version | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | C-001 | Ana | Lopez | ana@example.com | Austin | TX | US | 2024-11-02 | 9999-12-31 | TRUE | h-austin | 1 |
| 2 | C-002 | Raj | Patel | raj@example.com | Pune | MH | IN | 2024-12-01 | 9999-12-31 | TRUE | h-pune | 1 |
| customer_code | first_name | last_name | city | state | country | row_hash | |
|---|---|---|---|---|---|---|---|
| C-001 | Ana | Lopez | ana@example.com | Denver | CO | US | h-denver |
| C-002 | Raj | Patel | raj@example.com | Pune | MH | IN | h-pune |
| C-003 | Li | Wei | li@example.com | Dallas | TX | US | h-dallas |
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_sk | product_code | product_name | category |
|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | Accessories |
| 101 | SKU-004 | Webcam | Accessories |
stg_product
product_code | category |
|---|---|
| SKU-001 | Accessories |
| SKU-004 | Video |
Sample data as SQL (to run it yourself)
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_sk | product_code | product_name | category | previous_category | category_changed_at |
|---|---|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | Accessories | NULL | NULL |
| 101 | SKU-004 | Webcam | Video | Accessories | 2025-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
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.categoryworks in one statement because every expression inSETsees the row's values from before the update.IS DISTINCT FROMlimits the update to real changes. Without it, SKU-001's unchanged category would be copied intoprevious_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".
| product_sk | product_code | product_name | category |
|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | Accessories |
| 101 | SKU-004 | Webcam | Accessories |
| product_code | category |
|---|---|
| SKU-001 | Accessories |
| SKU-004 | Video |
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_id | customer_code | order_ts |
|---|---|---|
| SO-1 | C-001 | 2025-03-02 10:00 |
| SO-2 | C-999 | 2025-03-03 15:30 |
src_order_line
order_id | line_no | product_code | quantity | unit_price | discount_amt |
|---|---|---|---|---|---|
| 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 |
Sample data as SQL (to run it yourself)
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_sk | customer_sk | product_sk | order_id | line_no | quantity | unit_price | discount_amt | gross_amount | net_amount |
|---|---|---|---|---|---|---|---|---|---|
| 20250302 | 1 | 100 | SO-1 | 1 | 2 | 24.99 | 0.00 | 49.98 | 49.98 |
| 20250302 | 1 | 101 | SO-1 | 2 | 1 | 89.00 | 10.00 | 89.00 | 79.00 |
| 20250303 | -1 | -1 | SO-2 | 1 | 3 | 5.00 | 0.00 | 15.00 | 15.00 |
Hint
Which joins are allowed to drop a row? Only the one that defines the grain.
Answer
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_orderis 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 JOINto 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, andx − NULLis NULL, so without it the line's net amount would be NULL and drop out ofSUM.AND is_currentmakes 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.
| order_id | customer_code | order_ts |
|---|---|---|
| SO-1 | C-001 | 2025-03-02 10:00 |
| SO-2 | C-999 | 2025-03-03 15:30 |
| order_id | line_no | product_code | quantity | unit_price | discount_amt |
|---|---|---|---|---|---|
| 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 |
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_sk | customer_code | loyalty_tier | effective_from | effective_to | is_current |
|---|---|---|---|---|---|
| 1 | C-001 | BRONZE | 2024-11-02 | 2025-03-31 | FALSE |
| 4 | C-001 | GOLD | 2025-04-01 | 9999-12-31 | TRUE |
| 2 | C-002 | SILVER | 2024-12-01 | 9999-12-31 | TRUE |
src_order
order_id | customer_code | order_ts |
|---|---|---|
| SO-1 | C-001 | 2025-03-05 10:00 |
| SO-2 | C-001 | 2025-03-20 18:00 |
| SO-3 | C-001 | 2025-04-02 09:00 |
| SO-4 | C-002 | 2025-04-03 12:00 |
Sample data as SQL (to run it yourself)
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_purchase | orders |
|---|---|
| BRONZE | 2 |
| GOLD | 1 |
| SILVER | 1 |
Hint
Instead of is_current, join on the version whose validity window contains the order date.
Answer
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_topicks 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_currentcredits 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 oncustomer_skwith no date logic at all.
| order_id | order_date | customer_sk | loyalty_tier | effective_from | effective_to | |
|---|---|---|---|---|---|---|
| kept | SO-1 | 2025-03-05 | 1 | BRONZE | 2024-11-02 | 2025-03-31 |
| kept | SO-2 | 2025-03-20 | 1 | BRONZE | 2024-11-02 | 2025-03-31 |
| kept | SO-3 | 2025-04-02 | 4 | GOLD | 2025-04-01 | 9999-12-31 |
| kept | SO-4 | 2025-04-03 | 2 | SILVER | 2024-12-01 | 9999-12-31 |
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_date | product | revenue | cost |
|---|---|---|---|
| 2025-03-01 | Mouse | 100.00 | 60.00 |
| 2025-03-01 | Keyboard | 900.00 | 810.00 |
| 2025-03-31 | Mouse | 300.00 | 150.00 |
| 2025-03-31 | Keyboard | 100.00 | 90.00 |
fact_inventory
snapshot_date | product | on_hand |
|---|---|---|
| 2025-03-01 | Mouse | 40 |
| 2025-03-01 | Keyboard | 10 |
| 2025-03-31 | Mouse | 25 |
| 2025-03-31 | Keyboard | 12 |
Sample data as SQL (to run it yourself)
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
revenue | month_end_on_hand | margin_pct |
|---|---|---|
| 1400.00 | 37 | 20.7 |
Hint
Revenue adds up across anything. Stock is a level, not a flow. A percentage is a ratio of two sums.
Answer
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.
| revenue | sum_of_all_snapshots | avg_of_daily_margins | |
|---|---|---|---|
| wrong | 1400.00 | 87 | 27.5 |
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_tsthat lands today), - leave the watermark alone when a run finds nothing new.
Sample data
src_sales_feed
order_id | line_no | amount | order_ts | _loaded_at |
|---|---|---|---|---|
| SO-1 | 1 | 49.98 | 2025-03-01 10:00 | 2025-03-01 10:05 |
| SO-2 | 1 | 79.00 | 2025-03-01 11:00 | 2025-03-01 11:02 |
| SO-3 | 1 | 15.00 | 2025-03-02 09:00 | 2025-03-02 09:01 |
load_control
table_name | watermark_ts |
|---|---|
| fact_sales | 1900-01-01 00:00 |
Sample data as SQL (to run it yourself)
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_loaded | watermark_ts |
|---|---|
| 3 | 2025-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
-- 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 oldorder_ts, so a watermark onorder_tshas already passed it and skips it forever._loaded_atsays 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 insertsWHERE _loaded_at > watermarkand afterwards sets the watermark toMAX(_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. Freezingupper_tsin step 1 and loading up to it closes the gap; anything that lands later has_loaded_at > upper_tsand waits for the next run. COALESCE(upper_ts, watermark_ts)keeps the watermark when the batch is empty (MAXover no rows is NULL). Don't reach forGREATEST(old, new)here: in Snowflake,GREATESTreturns NULL if any argument is NULL (docs), so an empty run would set the watermark to NULL, and the next run's> NULLfilter would match nothing, ever. (GREATEST_IGNORE_NULLSexists for this.) DuckDB's and Postgres'sGREATESTignore 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.
| order_id | amount | order_ts | _loaded_at | |
|---|---|---|---|---|
| inserted | SO-1 | 49.98 | 2025-03-01 10:00 | 2025-03-01 10:05 |
| inserted | SO-2 | 79.00 | 2025-03-01 11:00 | 2025-03-01 11:02 |
| inserted | SO-3 | 15.00 | 2025-03-02 09:00 | 2025-03-02 09:01 |
| table_name | watermark_ts |
|---|---|
| fact_sales | 2025-03-02 09:01 |
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_date | order_id | line_no | quantity | net_amount |
|---|---|---|---|---|
| 2025-03-26 | SO-7 | 1 | 1 | 10.00 |
| 2025-03-27 | SO-8 | 1 | 2 | 40.00 |
| 2025-03-27 | SO-8 | 2 | 1 | 15.00 |
| 2025-03-27 | SO-9 | 1 | 1 | 30.00 |
src_fixed
sale_date | order_id | line_no | quantity | net_amount |
|---|---|---|---|---|
| 2025-03-27 | SO-8 | 1 | 3 | 60.00 |
| 2025-03-27 | SO-9 | 1 | 1 | 30.00 |
| 2025-03-27 | SO-9 | 2 | 1 | 12.00 |
Sample data as SQL (to run it yourself)
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_date | order_id | line_no | quantity | net_amount |
|---|---|---|---|---|
| 2025-03-26 | SO-7 | 1 | 1 | 10.00 |
| 2025-03-27 | SO-8 | 1 | 3 | 60.00 |
| 2025-03-27 | SO-9 | 1 | 1 | 30.00 |
| 2025-03-27 | SO-9 | 2 | 1 | 12.00 |
Hint
One of the two approaches can't see rows that disappeared from the source.
Answer
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.
MERGEon(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 withWHEN NOT MATCHED BY SOURCE … THEN DELETE(DuckDB, SQL Server, Databricks), but Snowflake'sMERGEhas 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.
| sale_date | order_id | line_no | quantity | net_amount | |
|---|---|---|---|---|---|
| 2025-03-26 | SO-7 | 1 | 1 | 10.00 | |
| look here | 2025-03-27 | SO-8 | 1 | 2 | 40.00 |
| look here | 2025-03-27 | SO-8 | 2 | 1 | 15.00 |
| look here | 2025-03-27 | SO-9 | 1 | 1 | 30.00 |
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_sk | product_code | product_name | category | is_inferred |
|---|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | Accessories | FALSE |
src_order_line
order_id | line_no | product_code | net_amount |
|---|---|---|---|
| SO-1 | 1 | SKU-001 | 49.98 |
| SO-1 | 2 | SKU-009 | 120.00 |
stg_product_late
product_code | product_name | category |
|---|---|---|
| SKU-009 | 4K Monitor | Displays |
Sample data as SQL (to run it yourself)
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_id | line_no | product_sk | product_name | category | is_inferred | net_amount |
|---|---|---|---|---|---|---|
| SO-1 | 1 | 100 | Wireless Mouse | Accessories | FALSE | 49.98 |
| SO-1 | 2 | 101 | 4K Monitor | Displays | FALSE | 120.00 |
Hint
The placeholder needs a real surrogate key and the real natural key; only its attributes are unknown.
Answer
-- 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 = TRUEso 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-1facts 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.
| product_sk | product_code | product_name | category | is_inferred | |
|---|---|---|---|---|---|
| 100 | SKU-001 | Wireless Mouse | Accessories | FALSE | |
| inserted | 101 | SKU-009 | UNKNOWN | UNKNOWN | TRUE |
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_date | product |
|---|---|
| 2025-03-10 | Mouse |
| 2025-03-10 | Keyboard |
| 2025-03-11 | Mouse |
| 2025-03-11 | Webcam |
fact_sales
sale_date | product | quantity |
|---|---|---|
| 2025-03-10 | Mouse | 3 |
| 2025-03-11 | Mouse | 1 |
| 2025-03-11 | Keyboard | 2 |
Sample data as SQL (to run it yourself)
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_date | product |
|---|---|
| 2025-03-10 | Keyboard |
| 2025-03-11 | Webcam |
Hint
The sales fact can't show a sale that didn't happen. Start from the table that records what could have happened.
Answer
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.
| sale_date | product |
|---|---|
| 2025-03-10 | Mouse |
| 2025-03-10 | Keyboard |
| 2025-03-11 | Mouse |
| 2025-03-11 | Webcam |
| sale_date | product | quantity |
|---|---|---|
| 2025-03-10 | Mouse | 3 |
| 2025-03-11 | Mouse | 1 |
| 2025-03-11 | Keyboard | 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_id | line_no | net_amount |
|---|---|---|
| SO-1 | 1 | 60.00 |
| SO-1 | 2 | 40.00 |
| SO-2 | 1 | 50.00 |
order_coupon
order_id | coupon_code |
|---|---|
| SO-1 | SPRING |
| SO-1 | FREESHIP |
| SO-2 | SPRING |
Sample data as SQL (to run it yourself)
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_code | attributed_revenue |
|---|---|
| FREESHIP | 50.0 |
| SPRING | 100.0 |
Hint
Each order's revenue has to be split among its coupons, not copied to each of them.
Answer
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.
| order_id | line_no | net_amount | coupon_code | |
|---|---|---|---|---|
| wrong | SO-1 | 1 | 60.00 | FREESHIP |
| wrong | SO-1 | 1 | 60.00 | SPRING |
| wrong | SO-1 | 2 | 40.00 | FREESHIP |
| wrong | SO-1 | 2 | 40.00 | SPRING |
| SO-2 | 1 | 50.00 | SPRING |
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_date | store | product | on_hand |
|---|---|---|---|
| 2025-01-30 | Downtown | Mouse | 50 |
| 2025-01-31 | Downtown | Mouse | 48 |
| 2025-01-31 | Downtown | Keyboard | 12 |
| 2025-02-26 | Downtown | Mouse | 30 |
| 2025-02-27 | Downtown | Mouse | 28 |
| 2025-02-27 | Downtown | Keyboard | 9 |
Sample data as SQL (to run it yourself)
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
month | store | snapshot_used | month_end_on_hand |
|---|---|---|---|
| 2025-01-01 | Downtown | 2025-01-31 | 60 |
| 2025-02-01 | Downtown | 2025-02-27 | 37 |
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
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:QUALIFYruns afterGROUP 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_usedshows 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.
| snapshot_date | store | product | on_hand | |
|---|---|---|---|---|
| removed | 2025-01-30 | Downtown | Mouse | 50 |
| kept | 2025-01-31 | Downtown | Keyboard | 12 |
| kept | 2025-01-31 | Downtown | Mouse | 48 |
| removed | 2025-02-26 | Downtown | Mouse | 30 |
| kept | 2025-02-27 | Downtown | Keyboard | 9 |
| kept | 2025-02-27 | Downtown | Mouse | 28 |
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_id | ordered_date | shipped_date | delivered_date |
|---|---|---|---|
| SO-1 | 2025-03-01 | 2025-03-02 | 2025-03-05 |
| SO-2 | 2025-03-02 | NULL | NULL |
| SO-3 | 2025-03-03 | 2025-03-04 | 2025-03-10 |
shipping_events
order_id | ship_ts |
|---|---|
| SO-2 | 2025-03-06 14:00 |
Sample data as SQL (to run it yourself)
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_deliver | pct_delivered |
|---|---|
| 5.5 | 66.7 |
Hint
Unlike other facts, this one is updated as orders move. And which rows should an average of 'days to deliver' include?
Answer
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
MERGEsource is grouped per order, so two shipping events for one order can't make the merge nondeterministic (Q10), andMINkeeps the first shipment. AND t.shipped_date IS NULLstamps 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 itsdelivered_dateis NULL and so is the difference. That's correct: an undelivered order has no delivery time yet. The share delivered usesCOUNT(delivered_date)(non-NULL only) overCOUNT(*).- The payoff of the design: lag between any two milestones is a single subtraction.
| order_id | ordered_date | shipped_date | delivered_date |
|---|---|---|---|
| SO-1 | 2025-03-01 | 2025-03-02 | 2025-03-05 |
| SO-2 | 2025-03-02 | NULL | NULL |
| SO-3 | 2025-03-03 | 2025-03-04 | 2025-03-10 |
| order_id | ship_ts |
|---|---|
| SO-2 | 2025-03-06 14:00 |
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
month | product_sk | net_amount |
|---|---|---|
| 2025-03-01 | 100 | 100.00 |
| 2025-03-01 | 100 | 50.00 |
| 2025-03-01 | 101 | 300.00 |
fact_returns
month | product_sk | refund_amount |
|---|---|---|
| 2025-03-01 | 100 | 20.00 |
| 2025-03-01 | 100 | 10.00 |
| 2025-04-01 | 101 | 300.00 |
Sample data as SQL (to run it yourself)
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
category | month | revenue | refunds | net_after_refunds |
|---|---|---|---|---|
| Accessories | 2025-03-01 | 150.00 | 30.00 | 120.00 |
| Displays | 2025-03-01 | 300.00 | 0.00 | 300.00 |
| Displays | 2025-04-01 | 0.00 | 300.00 | -300.00 |
Hint
Bring each fact to the shared grain on its own first, then combine the results.
Answer
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 JOINkeeps 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.
| month | product_sk | net_amount | refund_amount | |
|---|---|---|---|---|
| wrong | 2025-03-01 | 100 | 50.00 | 10.00 |
| wrong | 2025-03-01 | 100 | 50.00 | 20.00 |
| wrong | 2025-03-01 | 100 | 100.00 | 10.00 |
| wrong | 2025-03-01 | 100 | 100.00 | 20.00 |
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_id | line_no | product_sk | net_amount |
|---|---|---|---|
| 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 |
Sample data as SQL (to run it yourself)
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
rule | order_id | line_no | rows_found |
|---|---|---|---|
| DUPLICATE_GRAIN | SO-2 | 1 | 2 |
| NEGATIVE_NET | SO-3 | 1 | 1 |
| ORPHAN_PRODUCT | SO-4 | 1 | 1 |
Hint
Write each rule as a query that returns the rows breaking it, then stack them.
Answer
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 ALLbranch is one assertion, written as "return the rows that break it", and labelled so a failure says which rule broke and where. - Uniqueness:
GROUP BYthe 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 EXISTSagainst the dimension, neverNOT 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.
| order_id | line_no | product_sk | net_amount |
|---|---|---|---|
| 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 |
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_date | order_id | line_no | quantity | unit_price | discount_amt |
|---|---|---|---|---|---|
| 2025-03-01 | SO-1 | 1 | 2 | 24.99 | 0.00 |
| 2025-03-01 | SO-1 | 2 | 1 | 89.00 | 10.00 |
| 2025-03-02 | SO-2 | 1 | 3 | 5.00 | 0.00 |
| 2025-03-03 | SO-3 | 1 | 1 | 30.00 | 0.00 |
fact_sales
sale_date | order_id | line_no | net_amount |
|---|---|---|---|
| 2025-03-01 | SO-1 | 1 | 49.98 |
| 2025-03-01 | SO-1 | 2 | 69.00 |
| 2025-03-02 | SO-2 | 1 | 15.00 |
Sample data as SQL (to run it yourself)
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
d | src_rows | fact_rows | src_net | fact_net | diff |
|---|---|---|---|---|---|
| 2025-03-01 | 2 | 2 | 128.98 | 118.98 | -10.00 |
| 2025-03-03 | 1 | NULL | 30.00 | NULL | -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
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 JOINon 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.01is 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.
| order_date | order_id | line_no | quantity | unit_price | discount_amt |
|---|---|---|---|---|---|
| 2025-03-01 | SO-1 | 1 | 2 | 24.99 | 0.00 |
| 2025-03-01 | SO-1 | 2 | 1 | 89.00 | 10.00 |
| 2025-03-02 | SO-2 | 1 | 3 | 5.00 | 0.00 |
| 2025-03-03 | SO-3 | 1 | 1 | 30.00 | 0.00 |
| sale_date | order_id | line_no | net_amount |
|---|---|---|---|
| 2025-03-01 | SO-1 | 1 | 49.98 |
| 2025-03-01 | SO-1 | 2 | 69.00 |
| 2025-03-02 | SO-2 | 1 | 15.00 |
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
month | category | net_amount |
|---|---|---|
| 2025-03-01 | Accessories | 500.00 |
| 2025-03-01 | Displays | 900.00 |
| 2025-03-01 | Audio | 300.00 |
| 2025-03-01 | Cables | 100.00 |
| 2025-04-01 | Accessories | 200.00 |
| 2025-04-01 | Displays | 400.00 |
| 2025-04-01 | Audio | 700.00 |
| 2025-04-01 | Cables | 250.00 |
Sample data as SQL (to run it yourself)
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
month | category | revenue |
|---|---|---|
| 2025-03-01 | Displays | 900.00 |
| 2025-03-01 | Accessories | 500.00 |
| 2025-03-01 | Audio | 300.00 |
| 2025-03-01 | ALL | 1800.00 |
| 2025-04-01 | Audio | 700.00 |
| 2025-04-01 | Displays | 400.00 |
| 2025-04-01 | Cables | 250.00 |
| 2025-04-01 | ALL | 1550.00 |
| GRAND TOTAL | ALL | 3350.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
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; threeUNION ALLqueries 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_catis the fix. The obviousPARTITION BY monthputs 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 byg_catas well ranks categories only against categories.WHERE g_cat = 1 OR rnk <= 3keeps 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.
| month | category | revenue | g_month | g_cat | |
|---|---|---|---|---|---|
| 2025-03-01 | Displays | 900.00 | 0 | 0 | |
| 2025-03-01 | Accessories | 500.00 | 0 | 0 | |
| 2025-03-01 | Audio | 300.00 | 0 | 0 | |
| 2025-03-01 | Cables | 100.00 | 0 | 0 | |
| look here | 2025-03-01 | NULL | 1800.00 | 0 | 1 |
| 2025-04-01 | Audio | 700.00 | 0 | 0 | |
| 2025-04-01 | Displays | 400.00 | 0 | 0 | |
| 2025-04-01 | Cables | 250.00 | 0 | 0 | |
| 2025-04-01 | Accessories | 200.00 | 0 | 0 | |
| look here | 2025-04-01 | NULL | 1550.00 | 0 | 1 |
| look here | NULL | NULL | 3350.00 | 1 | 1 |
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
| Concept | In one line | Why it matters |
|---|---|---|
| Grain | What one fact row represents | Every load, join and sum has to respect it |
Unknown member (-1) | The row facts point at when a key is missing | Totals stay complete and visible |
| NULL-safe hash | Separator plus a NULL placeholder, then md5 | Change detection without false matches |
| SCD Type 1 / 2 / 3 | Overwrite / new row per version / one previous value | Corrections vs history vs "current vs previous" |
| Point-in-time join | Match the version whose window contains the event date | Reports show what was true when it happened |
| Additivity | Additive, semi-additive, non-additive | Stock isn't summed over time; ratios are recomputed |
| Race-free watermark | Fix the upper bound, load (lower, upper], advance with COALESCE | No skipped rows, and empty runs are safe |
| Partition reload | Delete the day, insert the day, in one transaction | Handles deletions that MERGE can't see |
| Inferred member | A placeholder with the real natural key | Late dimensions fill in without fact updates |
| Bridge with weights | Split a value across many-to-many links | Totals don't inflate |
| Drill-across | Aggregate each fact, then join on conformed dimensions | Two facts never fan each other out |
| Quality gate and control totals | Assertions that must return nothing; sums compared with the source | Problems 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
- Kimball Group: dimensional modeling techniques (grain, SCD types, factless facts, bridge tables, snapshots, conformed dimensions)
- Snowflake: MERGE, GREATEST and GREATEST_IGNORE_NULLS, CONCAT_WS, QUALIFY, constraints and join elimination
- DuckDB: MERGE INTO, GROUPING SETS
- PostgreSQL: identity columns