Vineet Ganti
SQL for Data Engineers: Foundations, from DDL to the NULL Traps
All posts

Data Engineering/Data Engineering SQL · Part 1 of 6

·40 min read

SQL for Data Engineers: Foundations, from DDL to the NULL Traps

A practice bank for data engineering SQL, part 1: defining and evolving tables, safe casting, idempotent upserts, deduplication and the NULL traps that silently lose rows. Every answer is run on real sample data.

Data EngineeringSQLPractice

Most SQL practice online is written for analysts: SELECT, joins and window functions over clean tables. The SQL a data engineer writes is different. It defines tables and their rules, loads data so that a re-run doesn't double it, keeps history, applies change streams, and proves the result is right before anyone sees it. I couldn't find a practice bank built around that, so this series is one.

This first post covers the foundations the rest of the series assumes: defining and changing tables, and the loading habits that keep a pipeline correct. Most questions are short, and each one carries a trap that real pipelines fall into.

  • Defining tables (DDL)
  • Loading and changing rows (DML)
  • How the answers are checked, and how to run them yourself

#How to use this bank

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.

After every answer, ask the three questions that separate an engineer's answer from an analyst's. Each answer ends with an Engineer check that answers them:

  1. What's the grain? What does one row of the output represent, and what guarantees it?
  2. What happens if it runs twice? Pipelines retry. A load that doubles its data on a retry is a bug.
  3. What does NULL do here? Most silent data loss in SQL comes from three-valued logic.

#How the answers are checked

Every question's sample data was loaded into a fresh DuckDB 2.0 database and its answer was run there. The sample tables, the expected outputs and the step-throughs are copied from those runs by a build script, not typed by hand. Answers marked as safe to re-run were run twice, and the build fails if the second run changes anything.

The answers are written in Snowflake-flavored SQL, because Snowflake is where I write SQL at work. Where Snowflake's syntax differs from what DuckDB runs (GENERATOR, AUTOINCREMENT, CLONE, IFF, DATEADD), the answer shows the Snowflake version first and the runnable version under it. Snowflake behaviour is taken from its documentation and linked; I haven't run these on a Snowflake account.

#Defining tables (DDL)

DDL decides what a table can hold and how it is stored. These questions cover declaring rules, changing a table that already has data, and the physical choices that decide what a query costs.

#Q1 · Create a typed table with constraints

Easy · DDL and constraints

Create a customers table for an e-commerce source system:

  • customer_sk: an integer surrogate key filled by the database.
  • customer_code: the business key, required and unique.
  • email: required.
  • country_code: exactly two characters.
  • credit_limit: money, defaulting to 0, never negative.
  • created_at: a timestamp that defaults to the time of the insert.

Then say which of these rules a cloud warehouse will actually stop a bad row for.

Hint

Every rule is a column constraint except the non-negative limit, which is a named CHECK. For the second part, look up which constraint types your warehouse enforces on standard tables.

Answer

In Snowflake:

sql
CREATE TABLE customers (
    customer_sk   NUMBER        AUTOINCREMENT PRIMARY KEY,   -- Postgres: INT GENERATED ALWAYS AS IDENTITY
    customer_code VARCHAR(50)   NOT NULL UNIQUE,
    email         VARCHAR(255)  NOT NULL,
    country_code  CHAR(2),
    credit_limit  NUMBER(12,2)  NOT NULL DEFAULT 0,
    created_at    TIMESTAMP_NTZ NOT NULL DEFAULT CURRENT_TIMESTAMP(),
    CONSTRAINT chk_credit_nonneg CHECK (credit_limit >= 0)
);

The same logic in portable SQL, as run on DuckDB to produce the output above:

sql
CREATE SEQUENCE customer_seq START 1;

CREATE TABLE customers (
    customer_sk   INTEGER       PRIMARY KEY DEFAULT nextval('customer_seq'),
    customer_code VARCHAR(50)   NOT NULL UNIQUE,
    email         VARCHAR(255)  NOT NULL,
    country_code  CHAR(2),
    credit_limit  DECIMAL(12,2) NOT NULL DEFAULT 0,
    created_at    TIMESTAMP     NOT NULL DEFAULT current_timestamp,
    CONSTRAINT chk_credit_nonneg CHECK (credit_limit >= 0)
);
  • customer_sk … DEFAULT nextval('customer_seq') makes the database fill the surrogate key. Snowflake spells it AUTOINCREMENT (or IDENTITY), Postgres GENERATED ALWAYS AS IDENTITY. A PRIMARY KEY implies NOT NULL and UNIQUE.
  • customer_code … NOT NULL UNIQUE declares the business key as an alternate key, which later loads use for lookups and dedup.
  • DECIMAL(12,2) stores money exactly. Never use FLOAT for currency: binary floating point can't represent 0.10 exactly, and sums drift.
  • DEFAULT 0 and DEFAULT current_timestamp fill columns an insert leaves out.
  • CONSTRAINT chk_credit_nonneg CHECK (…) names the rule, so the error message tells you which rule broke.

Which rules are enforced depends on the engine. DuckDB and Postgres enforce all of them, as the step-through shows. Snowflake does not enforce PRIMARY KEY, UNIQUE or FOREIGN KEY on standard tables: they are metadata for modeling and the optimizer (Snowflake docs). It does enforce NOT NULL, and since the April 2026 release it also enforces CHECK constraints (release notes). So on Snowflake a duplicate customer_code loads silently, and your pipeline has to test for it (Q14 and post 02's quality gate).

The table is created and two valid rows go in. The second omits credit_limit, so the default 0 fills it.
customers
customer_skcustomer_codeemailcountry_codecredit_limitcreated_at_filled
inserted1C-001ana@example.comUS500.00TRUE
inserted2C-002raj@example.comIN0.00TRUE
Step 1 of 4

Engineer check. Grain: one row per customer, guaranteed by the unique business key only where the engine enforces it. Run twice: CREATE TABLE fails the second time, so deployment scripts use CREATE TABLE IF NOT EXISTS or versioned migrations. NULL: NOT NULL is the one rule every engine enforces, so it's the cheapest quality check you have.

#Q2 · DROP, TRUNCATE or DELETE

Easy · DDL vs DML

Write one statement for each job and say whether it is DDL or DML:

  1. Empty the staging table stg_orders before a full reload, keeping the table.
  2. Remove only the 2025-03-26 rows from app_log.
  3. Remove the obsolete table deprecated_table completely.

Sample data

stg_orders

order_idamount
120.00
235.50
312.00

app_log

log_idlog_datemessage
12025-03-25start
22025-03-26retry
32025-03-26fail
42025-03-27ok
Sample data as SQL (to run it yourself)
sql
CREATE TABLE stg_orders (order_id INT, amount DECIMAL(10,2));
INSERT INTO stg_orders VALUES (1, 20.00), (2, 35.50), (3, 12.00);

CREATE TABLE app_log (log_id INT, log_date DATE, message VARCHAR);
INSERT INTO app_log VALUES
    (1, DATE '2025-03-25', 'start'),
    (2, DATE '2025-03-26', 'retry'),
    (3, DATE '2025-03-26', 'fail'),
    (4, DATE '2025-03-27', 'ok');

CREATE TABLE deprecated_table (id INT);

Expected state (rows_left for deprecated_table counts whether the table still exists)

table_namerows_left
stg_orders0
app_log2
deprecated_table0
Hint

Only one of the three jobs needs a WHERE clause, and only one removes the table's definition.

Answer
sql
TRUNCATE TABLE stg_orders;                               -- DDL: empty it, keep it

DELETE FROM app_log WHERE log_date = DATE '2025-03-26';  -- DML: filtered row removal

DROP TABLE deprecated_table;                             -- DDL: rows and definition gone
  • TRUNCATE TABLE removes every row in one operation and keeps the table, its columns and grants. It can't take a WHERE. It's classed as DDL because it works on the table's storage, not row by row.
  • DELETE … WHERE is DML: it removes only the rows that match, and it can be rolled back inside a transaction.
  • DROP TABLE removes the rows and the definition. Snowflake keeps dropped tables for its Time Travel period, so UNDROP TABLE can bring one back (docs); most engines can't.

The trap is reaching for DELETE FROM stg_orders (no WHERE) to empty a staging table. It works, but it is the slow, logged path, and on a large table TRUNCATE is the right tool.

Three tables before the statements run.
stg_orders
order_idamount
120.00
235.50
312.00
app_log
log_idlog_datemessage
12025-03-25start
22025-03-26retry
32025-03-26fail
42025-03-27ok
Step 1 of 5

Engineer check. Run twice: TRUNCATE and the DELETE are harmless the second time, but DROP TABLE errors, so scripts write DROP TABLE IF EXISTS. NULL: a DELETE … WHERE log_date = … never removes rows whose log_date is NULL, which is usually what you want, but worth knowing.

#Q3 · Evolve a schema without breaking a re-run

Easy · Schema evolution

An upstream team added a loyalty tier. Change customers so that:

  • a loyalty_tier VARCHAR(10) column exists, defaulting to 'BRONZE' for existing and new rows,
  • the column phone is renamed to phone_e164,
  • the obsolete fax column is gone.

Write it so the add and the drop can be run again safely by a deployment pipeline.

Sample data

customers

customer_skcustomer_codephonefax
1C-001+14155550101NULL
2C-002+919800000002+919800000099
Sample data as SQL (to run it yourself)
sql
CREATE TABLE customers (customer_sk INT, customer_code VARCHAR, phone VARCHAR, fax VARCHAR);
INSERT INTO customers VALUES
    (1, 'C-001', '+14155550101', NULL),
    (2, 'C-002', '+919800000002', '+919800000099');

Expected output

customer_skcustomer_codephone_e164loyalty_tier
1C-001+14155550101BRONZE
2C-002+919800000002BRONZE
Hint

Look for the IF NOT EXISTS and IF EXISTS forms of ADD COLUMN and DROP COLUMN.

Answer
sql
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(10) DEFAULT 'BRONZE';
ALTER TABLE customers RENAME COLUMN phone TO phone_e164;
ALTER TABLE customers DROP COLUMN IF EXISTS fax;
  • ADD COLUMN … DEFAULT 'BRONZE' adds the column, and existing rows read the default, as the output shows. On Postgres 11 and later, adding a column with a constant default doesn't rewrite the table (docs).
  • IF NOT EXISTS / IF EXISTS make the add and the drop safe to run again: a second run is a no-op instead of an error. Snowflake, Postgres and DuckDB all accept these forms.
  • RENAME COLUMN moves no data, but it breaks every downstream query that uses the old name, so it's a coordinated change, not a silent one.
The sample data before the answer runs.
customers
customer_skcustomer_codephonefax
1C-001+14155550101NULL
2C-002+919800000002+919800000099
Step 1 of 2

Engineer check. Run twice: the add and drop are idempotent thanks to the guards; the rename is not. NULL: rows that exist when the column is added get the default; a later insert that explicitly writes NULL gets NULL, not the default.

#Q4 · Type a raw table, and catch the values that don't parse

Easy · CTAS and safe casting

raw_orders arrived from CSV, so every column is text. In one statement, create a typed stg_orders with amount DECIMAL(12,2) and order_ts TIMESTAMP. A malformed value must not abort the load, and the bad rows must be easy to find afterwards.

Sample data

raw_orders

order_idcust_idamount_strts_str
1001C-00119.992025-03-01 10:00:00
1002C-0021,250.002025-03-01 11:30:00
1003C-001n/a2025-03-02 09:15:00
1004C-00342.502025-02-30 08:00:00
1005C-004NULL2025-03-03 14:45:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE raw_orders (order_id VARCHAR, cust_id VARCHAR, amount_str VARCHAR, ts_str VARCHAR);
INSERT INTO raw_orders VALUES
    ('1001', 'C-001', '19.99',    '2025-03-01 10:00:00'),
    ('1002', 'C-002', '1,250.00', '2025-03-01 11:30:00'),
    ('1003', 'C-001', 'n/a',      '2025-03-02 09:15:00'),
    ('1004', 'C-003', '42.50',    '2025-02-30 08:00:00'),
    ('1005', 'C-004', NULL,       '2025-03-03 14:45:00');

Expected output

order_idcust_idamountorder_tshas_parse_error
1001C-00119.992025-03-01 10:00FALSE
1002C-002NULL2025-03-01 11:30TRUE
1003C-001NULL2025-03-02 09:15TRUE
1004C-00342.50NULLTRUE
1005C-004NULL2025-03-03 14:45FALSE
Hint

CAST fails the whole statement on one bad value. Which variant returns NULL instead? Then: how do you tell a NULL that was in the source from a NULL the cast produced?

Answer
sql
CREATE TABLE stg_orders AS
SELECT
    CAST(order_id AS INT)                     AS order_id,
    cust_id,
    TRY_CAST(amount_str AS DECIMAL(12,2))     AS amount,
    TRY_CAST(ts_str     AS TIMESTAMP)         AS order_ts,
    (amount_str IS NOT NULL AND TRY_CAST(amount_str AS DECIMAL(12,2)) IS NULL)
      OR (ts_str IS NOT NULL AND TRY_CAST(ts_str AS TIMESTAMP) IS NULL)  AS has_parse_error
FROM raw_orders;
  • CREATE TABLE … AS SELECT (CTAS) creates and fills the table in one statement; its column types come from the query.
  • TRY_CAST(…) returns NULL instead of raising an error when a value doesn't parse (Snowflake docs). '1,250.00' (thousands separator), 'n/a' and the impossible date 2025-02-30 all become NULL.
  • has_parse_error is the part most answers forget. After TRY_CAST, a NULL amount could mean "the source had no amount" (order 1005, fine) or "the source had garbage" (orders 1002 and 1003, a problem). The flag is true only when the source had a value and the cast failed, so the bad rows can be counted, quarantined or alerted on.
Everything is text, including three values that won't parse.
raw_orders
order_idcust_idamount_strts_str
1001C-00119.992025-03-01 10:00:00
look here1002C-0021,250.002025-03-01 11:30:00
look here1003C-001n/a2025-03-02 09:15:00
look here1004C-00342.502025-02-30 08:00:00
1005C-004NULL2025-03-03 14:45:00
Step 1 of 2

Order 1002 is the dangerous one: 1,250.00 is a real amount that a stricter parser rejected. A silent TRY_CAST without the flag would drop $1,250 of revenue from every report.

Engineer check. Run twice: CTAS fails if the table exists; use CREATE OR REPLACE TABLE for a staging table that is rebuilt every run. NULL: distinguish a NULL from the source from a NULL produced by a failed cast, or you'll quietly lose data.

#Q5 · An empty load target, and a full copy for testing

Easy · CTAS, LIKE and CLONE

You need two copies of dim_product:

  1. dim_product_load: the same columns, no rows, to use as a load target.
  2. dim_product_dev: the same columns and data, for testing a risky change.

Show the portable way, then the Snowflake way, and say what each one does and doesn't copy.

Sample data

dim_product

product_skproduct_codenamelist_price
1SKU-001Wireless Mouse24.99
2SKU-002Keyboard89.00
3SKU-003USB Hub19.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_product (product_sk INT, product_code VARCHAR, name VARCHAR, list_price DECIMAL(10,2) DEFAULT 0);
INSERT INTO dim_product VALUES (1, 'SKU-001', 'Wireless Mouse', 24.99), (2, 'SKU-002', 'Keyboard', 89.00), (3, 'SKU-003', 'USB Hub', 19.00);

Expected output

table_namerow_count
dim_product_load0
dim_product_dev3
Hint

A WHERE clause that is always false gives you the columns without the rows. On Snowflake, one keyword copies only the structure and another copies the data without moving it.

Answer

In Snowflake:

sql
-- 1. empty target with the same column definitions, defaults and constraints
CREATE TABLE dim_product_load LIKE dim_product;

-- 2. zero-copy clone: instant, shares storage until either side changes
CREATE TABLE dim_product_dev CLONE dim_product;

The same logic in portable SQL, as run on DuckDB to produce the output above:

sql
-- 1. empty target: a query that returns no rows
CREATE TABLE dim_product_load AS SELECT * FROM dim_product WHERE FALSE;

-- 2. full copy for testing
CREATE TABLE dim_product_dev AS SELECT * FROM dim_product;
  • CREATE TABLE … AS SELECT … WHERE FALSE gives the right columns and types with zero rows. It does not copy defaults or constraints, because CTAS only knows the query's result shape.
  • Snowflake's CREATE TABLE … LIKE makes an empty table with the column definitions, defaults and constraints (docs). That's the better empty target.
  • CLONE copies the data without physically copying it: the clone shares the original's micro-partitions, and the two diverge only as either is changed (docs). It's the right tool for the dev copy, and the wrong one for an empty target, because it brings every row along.
The sample data before the answer runs.
dim_product
product_skproduct_codenamelist_price
1SKU-001Wireless Mouse24.99
2SKU-002Keyboard89.00
3SKU-003USB Hub19.00
Step 1 of 2

Engineer check. Run twice: both statements fail if the target exists, so use CREATE OR REPLACE for throwaway copies. Watch out: a clone of a production table in a dev schema is still production data, so the same access rules apply.

#Q6 · Make an insert survive a schema change

Easy · DML hygiene

This insert ran every night until someone added an is_active BOOLEAN DEFAULT TRUE column to dim_product:

sql
INSERT INTO dim_product VALUES (1004, 'SKU-004', 'Webcam', 'Video', 49.00);

Explain why it now fails, and rewrite it so it doesn't care about new columns.

Sample data

dim_product

product_skproduct_codenamecategorylist_priceis_active
1001SKU-001Wireless MouseAccessories24.99TRUE
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_product (product_sk INT, product_code VARCHAR, name VARCHAR, category VARCHAR, list_price DECIMAL(10,2));
INSERT INTO dim_product VALUES (1001, 'SKU-001', 'Wireless Mouse', 'Accessories', 24.99);
ALTER TABLE dim_product ADD COLUMN is_active BOOLEAN DEFAULT TRUE;

Expected output

product_skproduct_codenamecategorylist_priceis_active
1001SKU-001Wireless MouseAccessories24.99TRUE
1004SKU-004WebcamVideo49.00TRUE
Hint

A VALUES list without a column list has to supply every column, in table order.

Answer
sql
INSERT INTO dim_product (product_sk, product_code, name, category, list_price)
VALUES (1004, 'SKU-004', 'Webcam', 'Video', 49.00);
  • A positional insert must supply a value for every column, in the table's order. The table now has six columns and the insert gives five, so the engine rejects it, as the step-through shows.
  • Naming the columns makes the insert independent of column count and order. Columns you don't name, like is_active, get their default.
  • The failure is the good outcome. The bad one is when two columns of the same type are added or reordered: a positional insert then loads cleanly into the wrong columns, and nothing errors.
The positional insert supplies five values for a six-column table.
the engine says
Binder Error: table "dim_product" has 6 columns but 5 values were supplied
Step 1 of 2

Engineer check. Run twice: this insert adds a second SKU-004 row, because a plain INSERT is never idempotent. Q10 makes it safe with MERGE.

#Q7 · Surrogate keys from a sequence, and why they don't dedupe

Easy · Surrogate keys

Create dim_store whose store_sk is filled from a sequence, and insert two stores without supplying the key. Then answer: what happens if the load runs twice?

Expected output

store_skstore_codestore_name
1ST-01Downtown
2ST-02Airport
Hint

The sequence hands out a new number on every insert. Does it know anything about store_code?

Answer

In Snowflake:

sql
CREATE SEQUENCE store_seq START = 1 INCREMENT = 1;

CREATE TABLE dim_store (
    store_sk   NUMBER DEFAULT store_seq.NEXTVAL,   -- or: store_sk NUMBER AUTOINCREMENT
    store_code VARCHAR(20) NOT NULL,
    store_name VARCHAR(100)
);

INSERT INTO dim_store (store_code, store_name)
VALUES ('ST-01', 'Downtown'), ('ST-02', 'Airport');

The same logic in portable SQL, as run on DuckDB to produce the output above:

sql
CREATE SEQUENCE store_seq START 1;

CREATE TABLE dim_store (
    store_sk   INTEGER PRIMARY KEY DEFAULT nextval('store_seq'),
    store_code VARCHAR(20) NOT NULL,
    store_name VARCHAR(100)
);

INSERT INTO dim_store (store_code, store_name)
VALUES ('ST-01', 'Downtown'), ('ST-02', 'Airport');
  • CREATE SEQUENCE makes a standalone number generator, and DEFAULT nextval(…) pulls the next value whenever an insert leaves store_sk out.
  • Surrogate keys are meaningless integers on purpose: they insulate the warehouse from source keys that change format, get reused or collide across systems.
  • Sequences guarantee unique, increasing values, not gap-free ones: a rolled-back insert or a cached block skips numbers (Snowflake docs). Never use them as a row count.

The re-run is the real lesson. The sequence knows nothing about store_code, so running the load again inserts ST-01 and ST-02 a second time under new keys 3 and 4. Surrogate keys make rows unique, not entities. The natural key needs its own guard, and that's the job of MERGE (Q10).

Each store gets the next sequence value.
dim_store
store_skstore_codestore_name
inserted1ST-01Downtown
inserted2ST-02Airport
Step 1 of 2

Engineer check. Grain: unique per row, not per store, until a natural-key check exists. Run twice: duplicates. NULL: store_code NOT NULL is worth having, because a NULL business key can never be matched or deduplicated later.

#Q8 · Partition for pruning, and the filter that defeats it

Medium · Physical design

events is huge and almost every query filters on a date range. Give the BigQuery DDL that partitions it by event_date, and say what Snowflake does instead. Then fix this dashboard query, which scans the whole table even though the table is partitioned:

sql
SELECT count(*) FROM events WHERE YEAR(event_date) = 2025 AND MONTH(event_date) = 3;

Sample data

events

event_iduser_idevent_date
1u12025-02-27
2u22025-03-01
3u12025-03-15
4u32025-03-31
5u22025-04-01
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (event_id INT, user_id VARCHAR, event_date DATE);
INSERT INTO events VALUES
    (1, 'u1', DATE '2025-02-27'), (2, 'u2', DATE '2025-03-01'),
    (3, 'u1', DATE '2025-03-15'), (4, 'u3', DATE '2025-03-31'),
    (5, 'u2', DATE '2025-04-01');

Expected output

march_events
3
Hint

The engine decides which partitions to skip by comparing your filter with each partition's date range. Can it do that when the column is wrapped in a function?

Answer

In BigQuery and Snowflake:

sql
-- BigQuery: one partition per day of event_date
CREATE TABLE analytics.events (
    event_id   INT64,
    user_id    STRING,
    event_date DATE
)
PARTITION BY event_date;

-- Snowflake: no declared partitions; micro-partitions are automatic,
-- and a clustering key keeps rows with nearby dates together
CREATE TABLE events (event_id NUMBER, user_id STRING, event_date DATE)
CLUSTER BY (event_date);

-- The rewrite (both engines): a range on the bare column
SELECT count(*) FROM events
WHERE event_date >= DATE '2025-03-01' AND event_date < DATE '2025-04-01';

The same logic in portable SQL, as run on DuckDB to produce the output above:

sql
SELECT count(*) AS march_events
FROM events
WHERE event_date >= DATE '2025-03-01'
  AND event_date <  DATE '2025-04-01';
  • PARTITION BY event_date stores each day separately, so a filter on event_date reads only the matching days. That's partition pruning.
  • Snowflake has no declared partitions. It keeps min/max metadata for every micro-partition and skips the ones a filter rules out; CLUSTER BY keeps nearby dates in the same micro-partitions so that skipping works well on very large tables (docs).
  • YEAR(event_date) = 2025 asks the engine about a computed value, so it can't compare the filter with a partition's date range and reads everything. Google's guidance is the same: isolate the partition column on one side of the comparison (BigQuery docs).
  • A half-open range >= '2025-03-01' AND < '2025-04-01' returns the same rows, as the output shows, but prunes. It also handles month ends without knowing whether March has 31 days, and works for timestamps without a 23:59:59.999 hack.
The rows the filter should keep are the three in March.
events
event_iduser_idevent_date
1u12025-02-27
look here2u22025-03-01
look here3u12025-03-15
look here4u32025-03-31
5u22025-04-01
Step 1 of 2

Engineer check. Grain is unchanged; this is purely about cost and speed. Correctness: the half-open range is also the safe way to filter timestamps, because BETWEEN '2025-03-01' AND '2025-03-31' misses everything after midnight on the 31st.

#Q9 · View, snapshot or materialized view

Easy · Views

Create a view v_active_products over the active rows of dim_product. A teammate instead copies the active rows into a table each morning. Show what each returns after a product is deactivated and a new one is added, and say where a materialized view fits.

Sample data

dim_product

product_sknameis_active
1Wireless MouseTRUE
2KeyboardTRUE
3Fax MachineFALSE
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_product (product_sk INT, name VARCHAR, is_active BOOLEAN);
INSERT INTO dim_product VALUES (1, 'Wireless Mouse', TRUE), (2, 'Keyboard', TRUE), (3, 'Fax Machine', FALSE);

Expected output

product_skname
1Wireless Mouse
2Keyboard
Hint

A view stores a query, a table stores rows. Which one sees a change made after it was created?

Answer
sql
CREATE VIEW v_active_products AS
SELECT product_sk, name
FROM dim_product
WHERE is_active;
  • A view stores only the query, so every read runs it against the current table. It's always fresh, and you pay for the query on every read.
  • A table snapshot (the teammate's copy) stores rows. It's fast to read, but it's only as fresh as its last rebuild, as the step-through shows.
  • A materialized view stores rows and keeps them in sync. How it stays in sync is the difference that matters:
    • Snowflake maintains it automatically in the background and always returns current results; the cost is the background maintenance compute. It's limited to a single table, with no joins or window functions (docs).
    • Postgres materialized views are stale until you run REFRESH MATERIALIZED VIEW (docs).
    • For multi-table pipelines in Snowflake, dynamic tables fill the gap: you set a target lag and Snowflake refreshes them, incrementally when it can (docs).

Promote a view to something materialized when it is expensive and read far more often than its inputs change.

The view and the morning snapshot agree when they are created.
v_active_products
product_skname
1Wireless Mouse
2Keyboard
active_snapshot
product_skname
1Wireless Mouse
2Keyboard
Step 1 of 3

Engineer check. Freshness is the trade-off: a view is always current, a snapshot is current only as of its last build, and a materialized view's freshness depends on the engine.

#Loading and changing rows (DML)

DML is where pipelines go wrong quietly. These questions cover upserts, deduplication and the NULL behaviour that drops rows without an error.

#Q10 · An upsert that is safe to run twice

Easy · MERGE and upsert

src_currency holds today's currency list. Apply it to dim_currency so that existing codes are updated and new codes are inserted, and so that running the statement again changes nothing.

Sample data

dim_currency

currency_codecurrency_namesymbol
USDDollar$
GBPPound Sterling£

src_currency

currency_codecurrency_namesymbol
USDUS Dollar$
EUREuro€
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_currency (currency_code CHAR(3) PRIMARY KEY, currency_name VARCHAR, symbol VARCHAR);
INSERT INTO dim_currency VALUES ('USD', 'Dollar', '$'), ('GBP', 'Pound Sterling', '£');

CREATE TABLE src_currency (currency_code CHAR(3), currency_name VARCHAR, symbol VARCHAR);
INSERT INTO src_currency VALUES ('USD', 'US Dollar', '$'), ('EUR', 'Euro', '€');

Expected output

currency_codecurrency_namesymbol
EUREuro€
GBPPound Sterling£
USDUS Dollar$
Hint

You need one statement with two branches: one for codes that already exist, one for codes that don't.

Answer
sql
MERGE INTO dim_currency t
USING src_currency s
   ON t.currency_code = s.currency_code
WHEN MATCHED THEN UPDATE SET
    currency_name = s.currency_name,
    symbol        = s.symbol
WHEN NOT MATCHED THEN INSERT (currency_code, currency_name, symbol)
    VALUES (s.currency_code, s.currency_name, s.symbol);
  • MERGE INTO … USING … ON joins the target to the source on the key; each source row either matches a target row or doesn't.
  • WHEN MATCHED THEN UPDATE refreshes USD's name. WHEN NOT MATCHED THEN INSERT adds EUR. GBP isn't in the source, so it's left alone.
  • Running it again finds every source row matched and writes the same values, so the table doesn't change. The build ran it twice to check.

Postgres's older form does the same with INSERT … ON CONFLICT (currency_code) DO UPDATE SET currency_name = EXCLUDED.currency_name (docs); EXCLUDED is the row that would have been inserted.

The sample data before the answer runs.
dim_currency
currency_codecurrency_namesymbol
USDDollar$
GBPPound Sterling£
src_currency
currency_codecurrency_namesymbol
USDUS Dollar$
EUREuro€
Step 1 of 2

Engineer check. Grain: one row per currency code. Run twice: no change, which the build verified. NULL: a source row with a NULL currency_code never matches anything (NULL = NULL is not true), so it is inserted every run. Filter NULL keys out of the source.

#Q11 · Keep the latest row per key, and watch where NULLs sort

Easy · Deduplication

stg_customer holds several CDC pulls per customer. Return exactly one row per customer_code: the one with the latest updated_at, breaking ties by the greater email. One row has a NULL updated_at (a pull that lost its timestamp). It must not win.

Sample data

stg_customer

customer_codeemailupdated_at
C-001ana@old.com2025-01-01 09:00
C-001ana@new.com2025-02-01 09:00
C-001ana@lost.comNULL
C-002raj@a.com2025-03-01 12:00
C-002raj@b.com2025-03-01 12:00
C-003li@x.com2025-01-15 08:30
Sample data as SQL (to run it yourself)
sql
CREATE TABLE stg_customer (customer_code VARCHAR, email VARCHAR, updated_at TIMESTAMP);
INSERT INTO stg_customer VALUES
    ('C-001', 'ana@old.com',  TIMESTAMP '2025-01-01 09:00'),
    ('C-001', 'ana@new.com',  TIMESTAMP '2025-02-01 09:00'),
    ('C-001', 'ana@lost.com', NULL),
    ('C-002', 'raj@a.com',    TIMESTAMP '2025-03-01 12:00'),
    ('C-002', 'raj@b.com',    TIMESTAMP '2025-03-01 12:00'),
    ('C-003', 'li@x.com',     TIMESTAMP '2025-01-15 08:30');

Expected output

customer_codeemailupdated_at
C-001ana@new.com2025-02-01 09:00
C-002raj@b.com2025-03-01 12:00
C-003li@x.com2025-01-15 08:30
Hint

Number the rows within each customer, newest first, and keep number 1. Then check where your engine puts NULL in a descending sort.

Answer
sql
SELECT customer_code, email, updated_at
FROM stg_customer
QUALIFY ROW_NUMBER() OVER (
    PARTITION BY customer_code
    ORDER BY updated_at DESC NULLS LAST, email DESC
) = 1
ORDER BY customer_code;
  • ROW_NUMBER() OVER (PARTITION BY customer_code ORDER BY …) numbers the rows within each customer, starting at 1 for the winner.
  • QUALIFY … = 1 filters on that number in the same query. It's to window functions what HAVING is to aggregates. Postgres has no QUALIFY, so you wrap the query in a subquery and filter WHERE rn = 1 (Snowflake docs).
  • email DESC is the tiebreaker that makes C-002's winner deterministic. Without it, the two rows with the same timestamp could swap places between runs.
  • ROW_NUMBER, not RANK: RANK gives both tied rows rank 1 and you'd keep both.

NULLS LAST is the part most answers miss. Snowflake and Postgres sort NULL as larger than any value, so in a descending sort NULLs come first (Snowflake, Postgres). Without NULLS LAST, C-001's winner is the row with no timestamp, ana@lost.com. DuckDB happens to sort NULLs last by default (docs), which is exactly how this bug hides in testing and appears in production. Write the null ordering explicitly.

Each customer's rows numbered newest first, with NULLS LAST. Row 1 wins.
numbered
customer_codeemailupdated_atrn
keptC-001ana@new.com2025-02-01 09:001
removedC-001ana@old.com2025-01-01 09:002
removedC-001ana@lost.comNULL3
keptC-002raj@b.com2025-03-01 12:001
removedC-002raj@a.com2025-03-01 12:002
keptC-003li@x.com2025-01-15 08:301
Step 1 of 3

Engineer check. Grain: exactly one row per customer_code, guaranteed by ROW_NUMBER. Run twice: deterministic only because of the full tiebreak. NULL: always state NULLS LAST in a newest-first dedup.

#Q12 · Not equal, including the NULLs

Easy · NULL semantics

Return every order whose coupon_code is not 'PROMO10', including orders with no coupon at all. Explain why WHERE coupon_code <> 'PROMO10' gets this wrong.

Sample data

orders

order_idcoupon_codeamount
1PROMO1050.00
2SPRING20.00
3NULL35.00
4PROMO1015.00
5NULL80.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE orders (order_id INT, coupon_code VARCHAR, amount DECIMAL(10,2));
INSERT INTO orders VALUES
    (1, 'PROMO10', 50.00), (2, 'SPRING', 20.00), (3, NULL, 35.00),
    (4, 'PROMO10', 15.00), (5, NULL, 80.00);

Expected output

order_idcoupon_codeamount
2SPRING20.00
3NULL35.00
5NULL80.00
Hint

Work out what NULL <> 'PROMO10' evaluates to. It's not TRUE, and it's not FALSE.

Answer
sql
SELECT *
FROM orders
WHERE coupon_code IS DISTINCT FROM 'PROMO10'
ORDER BY order_id;
  • SQL logic has three values: TRUE, FALSE and UNKNOWN. Comparing NULL with anything gives UNKNOWN, and WHERE keeps only TRUE rows, so coupon_code <> 'PROMO10' silently drops orders 3 and 5. The step-through shows the UNKNOWN (NULL) result on those rows.
  • IS DISTINCT FROM treats NULL as an ordinary value: NULL is distinct from 'PROMO10', so those rows are kept.
  • The portable spelling is WHERE coupon_code <> 'PROMO10' OR coupon_code IS NULL.

Here the naive filter loses $115 of no-coupon revenue, and nothing errors.

What each predicate returns per row. The plain comparison is NULL (unknown) for the no-coupon orders.
predicates
order_idcoupon_code<> 'PROMO10'IS DISTINCT FROM
1PROMO10FALSEFALSE
2SPRINGTRUETRUE
look here3NULLNULLTRUE
4PROMO10FALSEFALSE
look here5NULLNULLTRUE
Step 1 of 3

Engineer check. NULL: any <>, NOT IN or NOT LIKE filter on a nullable column needs a decision about NULLs. Make it explicit.

#Q13 · Several totals in one pass

Easy · Conditional aggregation

In one pass over payments, return per method: the total amount, the captured amount, and the number of failed payments. Use no WHERE. A method with nothing captured must show 0.00, not NULL.

Sample data

payments

payment_idmethodstatusamount
1cardCAPTURED50.00
2cardFAILED20.00
3cardCAPTURED30.00
4walletFAILED15.00
5walletPENDING10.00
6bankCAPTUREDNULL
Sample data as SQL (to run it yourself)
sql
CREATE TABLE payments (payment_id INT, method VARCHAR, status VARCHAR, amount DECIMAL(10,2));
INSERT INTO payments VALUES
    (1, 'card',   'CAPTURED', 50.00), (2, 'card',   'FAILED',   20.00),
    (3, 'card',   'CAPTURED', 30.00), (4, 'wallet', 'FAILED',   15.00),
    (5, 'wallet', 'PENDING',  10.00), (6, 'bank',   'CAPTURED', NULL);

Expected output

methodtotal_amountcaptured_amountfailed_count
bank0.000.000
card100.0080.001
wallet25.000.001
Hint

Put a CASE inside each aggregate so that rows that don't belong contribute nothing. Then check what SUM returns when every value it sees is NULL.

Answer
sql
SELECT
    method,
    COALESCE(SUM(amount), 0)                                          AS total_amount,
    COALESCE(SUM(CASE WHEN status = 'CAPTURED' THEN amount END), 0)   AS captured_amount,
    COUNT(CASE WHEN status = 'FAILED' THEN 1 END)                     AS failed_count
FROM payments
GROUP BY method
ORDER BY method;
  • SUM(CASE WHEN status = 'CAPTURED' THEN amount END): rows that aren't captured become NULL, and SUM ignores NULLs, so one scan produces a filtered total.
  • COUNT(CASE WHEN status = 'FAILED' THEN 1 END): COUNT of an expression counts only non-NULL values, so only failures are counted.
  • COALESCE(…, 0): SUM over only NULLs returns NULL, not 0. That's the wallet's captured amount (nothing captured) and the bank's total (its one amount is NULL). COALESCE turns those into 0.00 so a dashboard shows a number.
  • One pass replaces three queries with three WHERE clauses joined back together.
What each row contributes to each total. Blank cells are NULL and are ignored.
contributions
payment_idmethodstatusamountto_capturedto_failed
6bankCAPTUREDNULLNULLNULL
1cardCAPTURED50.0050.00NULL
2cardFAILED20.00NULL1
3cardCAPTURED30.0030.00NULL
4walletFAILED15.00NULL1
5walletPENDING10.00NULLNULL
Step 1 of 3

Engineer check. Grain: one row per method. NULL: SUM of nothing is NULL and COUNT of nothing is 0, so wrap sums in COALESCE when a 0 is the honest answer.

#Q14 · Find orphan keys, and why NOT IN finds none

Easy · Referential integrity

Cloud warehouses don't enforce foreign keys. Find every customer_sk in fact_sales that has no row in dim_customer. A colleague tried WHERE customer_sk NOT IN (SELECT customer_sk FROM dim_customer) and got no rows. Explain why.

Sample data

dim_customer

customer_skcustomer_code
1C-001
2C-002
3C-003
NULLC-BAD

fact_sales

sale_idcustomer_skamount
101120.00
102235.00
103712.00
104940.00
10578.00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE dim_customer (customer_sk INT, customer_code VARCHAR);
INSERT INTO dim_customer VALUES (1, 'C-001'), (2, 'C-002'), (3, 'C-003'), (NULL, 'C-BAD');

CREATE TABLE fact_sales (sale_id INT, customer_sk INT, amount DECIMAL(10,2));
INSERT INTO fact_sales VALUES (101, 1, 20.00), (102, 2, 35.00), (103, 7, 12.00), (104, 9, 40.00), (105, 7, 8.00);

Expected output

customer_sk
7
9
Hint

x NOT IN (1, 2, 3, NULL) means x <> 1 AND x <> 2 AND x <> 3 AND x <> NULL. What is x <> NULL?

Answer
sql
SELECT DISTINCT f.customer_sk
FROM fact_sales f
WHERE NOT EXISTS (
    SELECT 1 FROM dim_customer d WHERE d.customer_sk = f.customer_sk
)
ORDER BY f.customer_sk;
  • NOT EXISTS (…) keeps a fact row when the subquery finds no dimension row with its key. Keys 7 and 9 have none, so they're the orphans.
  • NOT IN breaks on the dimension's bad row with a NULL key: 7 NOT IN (1, 2, 3, NULL) expands to … AND 7 <> NULL, which is UNKNOWN, so the whole condition is never TRUE and every row is filtered out. One NULL in the subquery and the check silently reports no problems.
  • LEFT JOIN dim_customer d ON … WHERE d.customer_sk IS NULL is the other safe form (an anti-join).

A referential check that can't fail is worse than no check, because it tells you the data is clean.

The dimension has a bad row with a NULL key; the fact references keys 7 and 9, which don't exist.
dim_customer
customer_skcustomer_code
1C-001
2C-002
3C-003
look hereNULLC-BAD
fact_sales
sale_idcustomer_skamount
101120.00
102235.00
look here103712.00
look here104940.00
look here10578.00
Step 1 of 3

Engineer check. NULL: never use NOT IN with a subquery that can return NULL. A fact row whose own key is NULL is also returned by NOT EXISTS, which is right: it's an orphan too.

#Q15 · A date spine, so empty days still show up

Easy · Generated data

daily_orders has no row for days with no orders. Build a calendar for 2025-03-01 to 2025-03-07 with date_key (YYYYMMDD as an integer), the day name and an is_weekend flag, and use it to report orders per day with 0 for the missing days.

Sample data

daily_orders

order_dateorders
2025-03-0112
2025-03-029
2025-03-0415
2025-03-074
Sample data as SQL (to run it yourself)
sql
CREATE TABLE daily_orders (order_date DATE, orders INT);
INSERT INTO daily_orders VALUES
    (DATE '2025-03-01', 12), (DATE '2025-03-02', 9),
    (DATE '2025-03-04', 15), (DATE '2025-03-07', 4);

Expected output

date_keyfull_dateday_nameis_weekendorders
202503012025-03-01SaturdayTRUE12
202503022025-03-02SundayTRUE9
202503032025-03-03MondayFALSE0
202503042025-03-04TuesdayFALSE15
202503052025-03-05WednesdayFALSE0
202503062025-03-06ThursdayFALSE0
202503072025-03-07FridayFALSE4
Hint

Generate one row per day first, independent of the data, then LEFT JOIN the facts onto it.

Answer

In Snowflake:

sql
WITH spine AS (
    SELECT DATEADD(day, ROW_NUMBER() OVER (ORDER BY SEQ4()) - 1, DATE '2025-03-01') AS full_date
    FROM TABLE(GENERATOR(ROWCOUNT => 7))
)
SELECT
    TO_NUMBER(TO_CHAR(s.full_date, 'YYYYMMDD')) AS date_key,
    s.full_date,
    DAYNAME(s.full_date)                        AS day_name,
    DAYOFWEEKISO(s.full_date) IN (6, 7)         AS is_weekend,
    COALESCE(o.orders, 0)                       AS orders
FROM spine s
LEFT JOIN daily_orders o ON o.order_date = s.full_date
ORDER BY s.full_date;

The same logic in portable SQL, as run on DuckDB to produce the output above:

sql
WITH spine AS (
    SELECT CAST(d AS DATE) AS full_date
    FROM generate_series(DATE '2025-03-01', DATE '2025-03-07', INTERVAL 1 DAY) AS t(d)
)
SELECT
    CAST(strftime(s.full_date, '%Y%m%d') AS INT) AS date_key,
    s.full_date,
    dayname(s.full_date)                         AS day_name,
    isodow(s.full_date) IN (6, 7)                AS is_weekend,
    COALESCE(o.orders, 0)                        AS orders
FROM spine s
LEFT JOIN daily_orders o ON o.order_date = s.full_date
ORDER BY s.full_date;
  • The spine is a gapless list of days that doesn't depend on the data: Snowflake builds it from GENERATOR, Postgres and DuckDB from generate_series.
  • LEFT JOIN daily_orders keeps every calendar day; days with no orders get NULL, and COALESCE(…, 0) reports them as 0. Grouping the orders directly would simply omit March 3, 5 and 6, and a chart would draw a straight line across the gap.
  • date_key as a YYYYMMDD integer is the classic warehouse date key: compact, sortable and readable.
  • isodow / DAYOFWEEKISO returns 6 and 7 for Saturday and Sunday under ISO rules, which avoids locale differences over which day is day 1.

Why ROW_NUMBER() and not SEQ4() alone in Snowflake. SEQ4() gives unique, increasing numbers but doesn't guarantee they're gap-free, especially at scale, so DATEADD(day, SEQ4(), …) can silently skip dates. Snowflake's own docs recommend ROW_NUMBER() OVER (ORDER BY SEQ4()) for a gap-free sequence (docs).

Seven generated days, with no reference to the data.
spine
full_date
2025-03-01
2025-03-02
2025-03-03
2025-03-04
2025-03-05
2025-03-06
2025-03-07
Step 1 of 3

Engineer check. Grain: one row per calendar day, guaranteed by the spine. NULL: missing days come back as NULL from the LEFT JOIN and must be turned into 0 explicitly.

#How to run these yourself

Everything in this series runs on a laptop for free.

  1. Install DuckDB. The CLI is a single binary, or pip install duckdb for Python (installation docs). Start it with duckdb practice.db.
  2. Load a question's data. Open "Sample data as SQL" under the question and paste it into the DuckDB prompt.
  3. Write your answer, run it, and compare with the expected output. Run it a second time to test idempotency.
  4. Postgres is the other free option, and it's closer to many OLTP sources. Most answers run there unchanged; replace QUALIFY with a subquery and MERGE needs version 15 or later (docs).
  5. Snowflake has a free trial if you want to run the Snowflake versions as written.

#Summary

The foundations come down to a few habits. Declare constraints, but know that a cloud warehouse enforces only some of them, so test the rest in SQL. Name your columns, cast defensively and flag what didn't parse. Make loads idempotent with MERGE, and deduplicate the source before merging. Be explicit about NULLs everywhere they can hide: in <> filters, NOT IN subqueries, descending sorts and sums over nothing. Generate a spine when missing rows matter.

#Concepts at a glance

ConceptIn one lineWhy it matters
Surrogate keyA meaningless integer the warehouse assignsInsulates you from source keys, but doesn't deduplicate entities
Enforced vs informational constraintsSnowflake enforces NOT NULL and CHECK, not PRIMARY KEY, UNIQUE or FOREIGN KEYDuplicates and orphans load silently unless you test for them
TRY_CAST plus a flagBad values become NULL, and a flag records that it happenedLoads don't abort, and data loss stays visible
LIKE vs CLONEStructure only vs zero-copy dataThe right empty target vs the right test copy
Partition pruningFilter the bare partition column with a rangeFunctions on the column make the engine read everything
MERGEUpdate matches, insert the restAn idempotent load, once the source has one row per key
QUALIFY ROW_NUMBER() = 1Keep one row per keyNeeds a full tiebreak and an explicit NULLS LAST
Three-valued logicNULL compared with anything is UNKNOWN<> and NOT IN silently drop rows
Date spineA generated, gapless list of datesDays with no data still appear, as zero

#Test yourself

Why can a newest-first dedup keep a row with no timestamp on Snowflake but not on DuckDB?

Snowflake and Postgres sort NULL as the largest value, so ORDER BY updated_at DESC puts NULLs first and the NULL row gets row number 1. DuckDB sorts NULLs last by default. Writing NULLS LAST makes the query behave the same everywhere.

A dimension table has one row with a NULL key. What does fact.key NOT IN (SELECT key FROM dim) return?

No rows at all. Every comparison with the NULL is unknown, so the NOT IN condition is never true. NOT EXISTS or a LEFT JOIN … IS NULL anti-join is the safe form.

You run a MERGE whose source has two rows for the same key. What happens on Snowflake?

By default it fails with a nondeterministic-merge error, because it can't tell which source row should win. Deduplicate the source to one row per key first.

Why does WHERE YEAR(event_date) = 2025 read the whole partitioned table?

The engine skips partitions by comparing the filter with each partition's range of event_date values, and it can't do that for a computed expression. A range on the bare column, event_date >= '2025-01-01' AND event_date < '2026-01-01', prunes.

A load assigns surrogate keys from a sequence. Is it safe to re-run?

No. The sequence knows nothing about the business key, so a re-run inserts the same entities again under new keys. Match on the business key with MERGE instead.

#Sources