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.
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:
- What's the grain? What does one row of the output represent, and what guarantees it?
- What happens if it runs twice? Pipelines retry. A load that doubles its data on a retry is a bug.
- 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:
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:
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 itAUTOINCREMENT(orIDENTITY), PostgresGENERATED ALWAYS AS IDENTITY. APRIMARY KEYimpliesNOT NULLandUNIQUE.customer_code … NOT NULL UNIQUEdeclares the business key as an alternate key, which later loads use for lookups and dedup.DECIMAL(12,2)stores money exactly. Never useFLOATfor currency: binary floating point can't represent 0.10 exactly, and sums drift.DEFAULT 0andDEFAULT current_timestampfill 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).
| customer_sk | customer_code | country_code | credit_limit | created_at_filled | ||
|---|---|---|---|---|---|---|
| inserted | 1 | C-001 | ana@example.com | US | 500.00 | TRUE |
| inserted | 2 | C-002 | raj@example.com | IN | 0.00 | TRUE |
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:
- Empty the staging table
stg_ordersbefore a full reload, keeping the table. - Remove only the 2025-03-26 rows from
app_log. - Remove the obsolete table
deprecated_tablecompletely.
Sample data
stg_orders
order_id | amount |
|---|---|
| 1 | 20.00 |
| 2 | 35.50 |
| 3 | 12.00 |
app_log
log_id | log_date | message |
|---|---|---|
| 1 | 2025-03-25 | start |
| 2 | 2025-03-26 | retry |
| 3 | 2025-03-26 | fail |
| 4 | 2025-03-27 | ok |
Sample data as SQL (to run it yourself)
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_name | rows_left |
|---|---|
| stg_orders | 0 |
| app_log | 2 |
| deprecated_table | 0 |
Hint
Only one of the three jobs needs a WHERE clause, and only one removes the table's definition.
Answer
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 TABLEremoves every row in one operation and keeps the table, its columns and grants. It can't take aWHERE. It's classed as DDL because it works on the table's storage, not row by row.DELETE … WHEREis DML: it removes only the rows that match, and it can be rolled back inside a transaction.DROP TABLEremoves the rows and the definition. Snowflake keeps dropped tables for its Time Travel period, soUNDROP TABLEcan 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.
| order_id | amount |
|---|---|
| 1 | 20.00 |
| 2 | 35.50 |
| 3 | 12.00 |
| log_id | log_date | message |
|---|---|---|
| 1 | 2025-03-25 | start |
| 2 | 2025-03-26 | retry |
| 3 | 2025-03-26 | fail |
| 4 | 2025-03-27 | ok |
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
phoneis renamed tophone_e164, - the obsolete
faxcolumn is gone.
Write it so the add and the drop can be run again safely by a deployment pipeline.
Sample data
customers
customer_sk | customer_code | phone | fax |
|---|---|---|---|
| 1 | C-001 | +14155550101 | NULL |
| 2 | C-002 | +919800000002 | +919800000099 |
Sample data as SQL (to run it yourself)
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_sk | customer_code | phone_e164 | loyalty_tier |
|---|---|---|---|
| 1 | C-001 | +14155550101 | BRONZE |
| 2 | C-002 | +919800000002 | BRONZE |
Hint
Look for the IF NOT EXISTS and IF EXISTS forms of ADD COLUMN and DROP COLUMN.
Answer
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 EXISTSmake 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 COLUMNmoves no data, but it breaks every downstream query that uses the old name, so it's a coordinated change, not a silent one.
| customer_sk | customer_code | phone | fax |
|---|---|---|---|
| 1 | C-001 | +14155550101 | NULL |
| 2 | C-002 | +919800000002 | +919800000099 |
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_id | cust_id | amount_str | ts_str |
|---|---|---|---|
| 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 |
Sample data as SQL (to run it yourself)
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_id | cust_id | amount | order_ts | has_parse_error |
|---|---|---|---|---|
| 1001 | C-001 | 19.99 | 2025-03-01 10:00 | FALSE |
| 1002 | C-002 | NULL | 2025-03-01 11:30 | TRUE |
| 1003 | C-001 | NULL | 2025-03-02 09:15 | TRUE |
| 1004 | C-003 | 42.50 | NULL | TRUE |
| 1005 | C-004 | NULL | 2025-03-03 14:45 | FALSE |
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
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 date2025-02-30all become NULL.has_parse_erroris the part most answers forget. AfterTRY_CAST, a NULLamountcould 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.
| order_id | cust_id | amount_str | ts_str | |
|---|---|---|---|---|
| 1001 | C-001 | 19.99 | 2025-03-01 10:00:00 | |
| look here | 1002 | C-002 | 1,250.00 | 2025-03-01 11:30:00 |
| look here | 1003 | C-001 | n/a | 2025-03-02 09:15:00 |
| look here | 1004 | C-003 | 42.50 | 2025-02-30 08:00:00 |
| 1005 | C-004 | NULL | 2025-03-03 14:45:00 |
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:
dim_product_load: the same columns, no rows, to use as a load target.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_sk | product_code | name | list_price |
|---|---|---|---|
| 1 | SKU-001 | Wireless Mouse | 24.99 |
| 2 | SKU-002 | Keyboard | 89.00 |
| 3 | SKU-003 | USB Hub | 19.00 |
Sample data as SQL (to run it yourself)
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_name | row_count |
|---|---|
| dim_product_load | 0 |
| dim_product_dev | 3 |
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:
-- 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:
-- 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 FALSEgives 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 … LIKEmakes an empty table with the column definitions, defaults and constraints (docs). That's the better empty target. CLONEcopies 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.
| product_sk | product_code | name | list_price |
|---|---|---|---|
| 1 | SKU-001 | Wireless Mouse | 24.99 |
| 2 | SKU-002 | Keyboard | 89.00 |
| 3 | SKU-003 | USB Hub | 19.00 |
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:
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_sk | product_code | name | category | list_price | is_active |
|---|---|---|---|---|---|
| 1001 | SKU-001 | Wireless Mouse | Accessories | 24.99 | TRUE |
Sample data as SQL (to run it yourself)
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_sk | product_code | name | category | list_price | is_active |
|---|---|---|---|---|---|
| 1001 | SKU-001 | Wireless Mouse | Accessories | 24.99 | TRUE |
| 1004 | SKU-004 | Webcam | Video | 49.00 | TRUE |
Hint
A VALUES list without a column list has to supply every column, in table order.
Answer
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.
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_sk | store_code | store_name |
|---|---|---|
| 1 | ST-01 | Downtown |
| 2 | ST-02 | Airport |
Hint
The sequence hands out a new number on every insert. Does it know anything about store_code?
Answer
In Snowflake:
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:
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 SEQUENCEmakes a standalone number generator, andDEFAULT nextval(…)pulls the next value whenever an insert leavesstore_skout.- 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).
| store_sk | store_code | store_name | |
|---|---|---|---|
| inserted | 1 | ST-01 | Downtown |
| inserted | 2 | ST-02 | Airport |
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:
SELECT count(*) FROM events WHERE YEAR(event_date) = 2025 AND MONTH(event_date) = 3;
Sample data
events
event_id | user_id | event_date |
|---|---|---|
| 1 | u1 | 2025-02-27 |
| 2 | u2 | 2025-03-01 |
| 3 | u1 | 2025-03-15 |
| 4 | u3 | 2025-03-31 |
| 5 | u2 | 2025-04-01 |
Sample data as SQL (to run it yourself)
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:
-- 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:
SELECT count(*) AS march_events
FROM events
WHERE event_date >= DATE '2025-03-01'
AND event_date < DATE '2025-04-01';
PARTITION BY event_datestores each day separately, so a filter onevent_datereads 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 BYkeeps nearby dates in the same micro-partitions so that skipping works well on very large tables (docs). YEAR(event_date) = 2025asks 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 a23:59:59.999hack.
| event_id | user_id | event_date | |
|---|---|---|---|
| 1 | u1 | 2025-02-27 | |
| look here | 2 | u2 | 2025-03-01 |
| look here | 3 | u1 | 2025-03-15 |
| look here | 4 | u3 | 2025-03-31 |
| 5 | u2 | 2025-04-01 |
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_sk | name | is_active |
|---|---|---|
| 1 | Wireless Mouse | TRUE |
| 2 | Keyboard | TRUE |
| 3 | Fax Machine | FALSE |
Sample data as SQL (to run it yourself)
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_sk | name |
|---|---|
| 1 | Wireless Mouse |
| 2 | Keyboard |
Hint
A view stores a query, a table stores rows. Which one sees a change made after it was created?
Answer
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.
| product_sk | name |
|---|---|
| 1 | Wireless Mouse |
| 2 | Keyboard |
| product_sk | name |
|---|---|
| 1 | Wireless Mouse |
| 2 | Keyboard |
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_code | currency_name | symbol |
|---|---|---|
| USD | Dollar | $ |
| GBP | Pound Sterling | £ |
src_currency
currency_code | currency_name | symbol |
|---|---|---|
| USD | US Dollar | $ |
| EUR | Euro | € |
Sample data as SQL (to run it yourself)
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_code | currency_name | symbol |
|---|---|---|
| EUR | Euro | € |
| GBP | Pound Sterling | £ |
| USD | US Dollar | $ |
Hint
You need one statement with two branches: one for codes that already exist, one for codes that don't.
Answer
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 … ONjoins the target to the source on the key; each source row either matches a target row or doesn't.WHEN MATCHED THEN UPDATErefreshes USD's name.WHEN NOT MATCHED THEN INSERTadds 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.
| currency_code | currency_name | symbol |
|---|---|---|
| USD | Dollar | $ |
| GBP | Pound Sterling | £ |
| currency_code | currency_name | symbol |
|---|---|---|
| USD | US Dollar | $ |
| EUR | Euro | € |
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_code | email | updated_at |
|---|---|---|
| C-001 | ana@old.com | 2025-01-01 09:00 |
| C-001 | ana@new.com | 2025-02-01 09:00 |
| C-001 | ana@lost.com | NULL |
| C-002 | raj@a.com | 2025-03-01 12:00 |
| C-002 | raj@b.com | 2025-03-01 12:00 |
| C-003 | li@x.com | 2025-01-15 08:30 |
Sample data as SQL (to run it yourself)
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_code | email | updated_at |
|---|---|---|
| C-001 | ana@new.com | 2025-02-01 09:00 |
| C-002 | raj@b.com | 2025-03-01 12:00 |
| C-003 | li@x.com | 2025-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
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 … = 1filters on that number in the same query. It's to window functions whatHAVINGis to aggregates. Postgres has noQUALIFY, so you wrap the query in a subquery and filterWHERE rn = 1(Snowflake docs).email DESCis 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, notRANK:RANKgives 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.
| customer_code | updated_at | rn | ||
|---|---|---|---|---|
| kept | C-001 | ana@new.com | 2025-02-01 09:00 | 1 |
| removed | C-001 | ana@old.com | 2025-01-01 09:00 | 2 |
| removed | C-001 | ana@lost.com | NULL | 3 |
| kept | C-002 | raj@b.com | 2025-03-01 12:00 | 1 |
| removed | C-002 | raj@a.com | 2025-03-01 12:00 | 2 |
| kept | C-003 | li@x.com | 2025-01-15 08:30 | 1 |
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_id | coupon_code | amount |
|---|---|---|
| 1 | PROMO10 | 50.00 |
| 2 | SPRING | 20.00 |
| 3 | NULL | 35.00 |
| 4 | PROMO10 | 15.00 |
| 5 | NULL | 80.00 |
Sample data as SQL (to run it yourself)
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_id | coupon_code | amount |
|---|---|---|
| 2 | SPRING | 20.00 |
| 3 | NULL | 35.00 |
| 5 | NULL | 80.00 |
Hint
Work out what NULL <> 'PROMO10' evaluates to. It's not TRUE, and it's not FALSE.
Answer
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
WHEREkeeps only TRUE rows, socoupon_code <> 'PROMO10'silently drops orders 3 and 5. The step-through shows the UNKNOWN (NULL) result on those rows. IS DISTINCT FROMtreats 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.
| order_id | coupon_code | <> 'PROMO10' | IS DISTINCT FROM | |
|---|---|---|---|---|
| 1 | PROMO10 | FALSE | FALSE | |
| 2 | SPRING | TRUE | TRUE | |
| look here | 3 | NULL | NULL | TRUE |
| 4 | PROMO10 | FALSE | FALSE | |
| look here | 5 | NULL | NULL | TRUE |
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_id | method | status | amount |
|---|---|---|---|
| 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 |
Sample data as SQL (to run it yourself)
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
method | total_amount | captured_amount | failed_count |
|---|---|---|---|
| bank | 0.00 | 0.00 | 0 |
| card | 100.00 | 80.00 | 1 |
| wallet | 25.00 | 0.00 | 1 |
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
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, andSUMignores NULLs, so one scan produces a filtered total.COUNT(CASE WHEN status = 'FAILED' THEN 1 END):COUNTof an expression counts only non-NULL values, so only failures are counted.COALESCE(…, 0):SUMover 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).COALESCEturns those into 0.00 so a dashboard shows a number.- One pass replaces three queries with three
WHEREclauses joined back together.
| payment_id | method | status | amount | to_captured | to_failed |
|---|---|---|---|---|---|
| 6 | bank | CAPTURED | NULL | NULL | NULL |
| 1 | card | CAPTURED | 50.00 | 50.00 | NULL |
| 2 | card | FAILED | 20.00 | NULL | 1 |
| 3 | card | CAPTURED | 30.00 | 30.00 | NULL |
| 4 | wallet | FAILED | 15.00 | NULL | 1 |
| 5 | wallet | PENDING | 10.00 | NULL | NULL |
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_sk | customer_code |
|---|---|
| 1 | C-001 |
| 2 | C-002 |
| 3 | C-003 |
| NULL | C-BAD |
fact_sales
sale_id | customer_sk | amount |
|---|---|---|
| 101 | 1 | 20.00 |
| 102 | 2 | 35.00 |
| 103 | 7 | 12.00 |
| 104 | 9 | 40.00 |
| 105 | 7 | 8.00 |
Sample data as SQL (to run it yourself)
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
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 INbreaks 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 NULLis 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.
| customer_sk | customer_code | |
|---|---|---|
| 1 | C-001 | |
| 2 | C-002 | |
| 3 | C-003 | |
| look here | NULL | C-BAD |
| sale_id | customer_sk | amount | |
|---|---|---|---|
| 101 | 1 | 20.00 | |
| 102 | 2 | 35.00 | |
| look here | 103 | 7 | 12.00 |
| look here | 104 | 9 | 40.00 |
| look here | 105 | 7 | 8.00 |
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_date | orders |
|---|---|
| 2025-03-01 | 12 |
| 2025-03-02 | 9 |
| 2025-03-04 | 15 |
| 2025-03-07 | 4 |
Sample data as SQL (to run it yourself)
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_key | full_date | day_name | is_weekend | orders |
|---|---|---|---|---|
| 20250301 | 2025-03-01 | Saturday | TRUE | 12 |
| 20250302 | 2025-03-02 | Sunday | TRUE | 9 |
| 20250303 | 2025-03-03 | Monday | FALSE | 0 |
| 20250304 | 2025-03-04 | Tuesday | FALSE | 15 |
| 20250305 | 2025-03-05 | Wednesday | FALSE | 0 |
| 20250306 | 2025-03-06 | Thursday | FALSE | 0 |
| 20250307 | 2025-03-07 | Friday | FALSE | 4 |
Hint
Generate one row per day first, independent of the data, then LEFT JOIN the facts onto it.
Answer
In Snowflake:
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:
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 fromgenerate_series. LEFT JOIN daily_orderskeeps every calendar day; days with no orders get NULL, andCOALESCE(…, 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_keyas aYYYYMMDDinteger is the classic warehouse date key: compact, sortable and readable.isodow/DAYOFWEEKISOreturns 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).
| full_date |
|---|
| 2025-03-01 |
| 2025-03-02 |
| 2025-03-03 |
| 2025-03-04 |
| 2025-03-05 |
| 2025-03-06 |
| 2025-03-07 |
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.
- Install DuckDB. The CLI is a single binary, or
pip install duckdbfor Python (installation docs). Start it withduckdb practice.db. - Load a question's data. Open "Sample data as SQL" under the question and paste it into the DuckDB prompt.
- Write your answer, run it, and compare with the expected output. Run it a second time to test idempotency.
- Postgres is the other free option, and it's closer to many OLTP sources. Most answers run there unchanged; replace
QUALIFYwith a subquery andMERGEneeds version 15 or later (docs). - 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
| Concept | In one line | Why it matters |
|---|---|---|
| Surrogate key | A meaningless integer the warehouse assigns | Insulates you from source keys, but doesn't deduplicate entities |
| Enforced vs informational constraints | Snowflake enforces NOT NULL and CHECK, not PRIMARY KEY, UNIQUE or FOREIGN KEY | Duplicates and orphans load silently unless you test for them |
TRY_CAST plus a flag | Bad values become NULL, and a flag records that it happened | Loads don't abort, and data loss stays visible |
LIKE vs CLONE | Structure only vs zero-copy data | The right empty target vs the right test copy |
| Partition pruning | Filter the bare partition column with a range | Functions on the column make the engine read everything |
MERGE | Update matches, insert the rest | An idempotent load, once the source has one row per key |
QUALIFY ROW_NUMBER() = 1 | Keep one row per key | Needs a full tiebreak and an explicit NULLS LAST |
| Three-valued logic | NULL compared with anything is UNKNOWN | <> and NOT IN silently drop rows |
| Date spine | A generated, gapless list of dates | Days 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
- Snowflake: constraints and join elimination, CHECK constraint release notes, CREATE TABLE, CLONE, MERGE, QUALIFY, ORDER BY and NULL ordering, SEQ4 and gaps, CREATE SEQUENCE, conversion functions and TRY_CAST, micro-partitions and clustering, materialized views, views, materialized views and dynamic tables, UNDROP TABLE
- PostgreSQL: ALTER TABLE, INSERT … ON CONFLICT, MERGE, sorting and NULLs, materialized views
- BigQuery: querying partitioned tables
- DuckDB: installation, ORDER BY and NULL ordering, MERGE INTO