Data Engineering/Data Engineering SQL · Part 3 of 6
·53 min read
SQL for Data Engineers: Medallion Layers and CDC
Part 3 of a data engineering SQL practice bank: a banking medallion pipeline, from Bronze landing to Silver CDC apply and history to Gold recomputes, covering redelivery, log ordering, soft deletes, late data, window-frame traps, data contracts, reconciliation and a refresh that is safe to retry. Every answer is run on real sample data.
NovaBank's core banking system publishes a change data capture (CDC) stream: every insert, update and delete on an account, with an operation flag and a log position. Transactions arrive as an append-only feed of files. This post builds the medallion pipeline on top: Bronze keeps exactly what arrived, Silver cleans, deduplicates and applies the changes, and Gold serves aggregates. It's the post about correctness under retries, redelivery and late data.
The post covers four areas:
- Bronze: landing raw data without losing anything
- Silver: deduplication, ordering, applying changes and keeping history
- Gold: recomputing only what late data changed, and serving without fan-out
- Quality: contracts, reconciliation, lineage, and a refresh that's safe to retry
#How to use this post
Each question has a difficulty, the pattern it drills, a small sample of data and the expected output. Try it before you open the dropdowns: Hint nudges you, and Answer has the SQL, a line-by-line explanation and a step-through of the data. The step-through shows the real tables at each stage, with changed rows tinted and labelled: inserted or kept rows in green, removed rows in red, changed rows in amber. Every answer ends with an Engineer check: the grain, what happens if it runs twice, and what NULL does. The first post explains how the answers were run and checked.
#Bronze: land everything
Bronze has one job: keep exactly what arrived, and record where it came from.
#Q38 · Land raw files in Bronze without losing a row
Medium · Bronze ingestion
Two CSV files of transactions arrived. One has an amount that isn't a number, and one row was delivered in both files. Create bronze_txn and load both files so that nothing is rejected, every row records which file it came from, and the batch is identifiable.
Sample data as SQL (to run it yourself)
COPY (SELECT * FROM (VALUES
('T-1', 'ACC-1', '2025-03-01 09:00', '120.00', 'CREDIT', 'Payroll'),
('T-2', 'ACC-1', '2025-03-01 12:30', '45.50', 'debit', 'Cafe')
) t(txn_id, account_id, txn_ts, amount, direction, merchant)) TO 'txn_2025-03-01.csv' (HEADER);
COPY (SELECT * FROM (VALUES
('T-3', 'ACC-2', '2025-03-02 08:15', 'n/a', 'DEBIT', 'Grocer'),
('T-2', 'ACC-1', '2025-03-01 12:30', '45.50', 'debit', 'Cafe')
) t(txn_id, account_id, txn_ts, amount, direction, merchant)) TO 'txn_2025-03-02.csv' (HEADER);
Expected output
txn_id | account_id | amount | direction | _file_name | _batch_id |
|---|---|---|---|---|---|
| T-1 | ACC-1 | 120.00 | CREDIT | txn_2025-03-01.csv | B-2025-03-02 |
| T-2 | ACC-1 | 45.50 | debit | txn_2025-03-01.csv | B-2025-03-02 |
| T-2 | ACC-1 | 45.50 | debit | txn_2025-03-02.csv | B-2025-03-02 |
| T-3 | ACC-2 | n/a | DEBIT | txn_2025-03-02.csv | B-2025-03-02 |
Hint
Bronze is the archive you rebuild everything else from. What would you lose if a column's type rejected a row here?
Answer
In Snowflake:
CREATE FILE FORMAT ff_csv TYPE = CSV SKIP_HEADER = 1 FIELD_OPTIONALLY_ENCLOSED_BY = '"';
CREATE TABLE bronze_txn (
txn_id STRING, account_id STRING, txn_ts STRING, amount STRING, direction STRING, merchant STRING,
_file_name STRING, _ingested_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(), _batch_id STRING
);
COPY INTO bronze_txn (txn_id, account_id, txn_ts, amount, direction, merchant, _file_name, _batch_id)
FROM (SELECT $1, $2, $3, $4, $5, $6, METADATA$FILENAME, 'B-2025-03-02' FROM @raw_stage/txn/)
FILE_FORMAT = (FORMAT_NAME = ff_csv);
The same logic in portable SQL, as run on DuckDB to produce the output above:
CREATE TABLE bronze_txn (
txn_id VARCHAR,
account_id VARCHAR,
txn_ts VARCHAR, -- everything lands as text: typing happens in Silver
amount VARCHAR,
direction VARCHAR,
merchant VARCHAR,
_file_name VARCHAR, -- lineage: which file
_ingested_at TIMESTAMP, -- lineage: when it landed
_batch_id VARCHAR -- lineage: which run
);
INSERT INTO bronze_txn
SELECT txn_id, account_id, txn_ts, amount, direction, merchant,
filename, current_timestamp, 'B-2025-03-02'
FROM read_csv('txn_*.csv', header = true, all_varchar = true, filename = true);
- Everything lands as text. If
amountwereDECIMAL, the load would fail on'n/a'(or, with error-skipping turned on, drop the row). Bronze's job is to keep exactly what arrived, so typing and validation happen in Silver, where a bad row can be quarantined with a reason (Q50). - Lineage columns:
_file_name(DuckDB'sfilename = true, Snowflake'sMETADATA$FILENAME(docs)),_ingested_atand_batch_idlet you trace any row to its file and run, and replay or delete one bad file's rows. - Duplicates stay. T-2 is in both files, and Bronze keeps both copies. Deduplication is a Silver decision (Q39, Q44); dropping rows at the door would make the archive incomplete.
- Bronze is append-only. Never update or delete it. Because it is complete and immutable, Silver and Gold can be rebuilt whenever their logic changes.
Snowflake's COPY INTO also remembers which files it has already loaded and skips them on a re-run, which makes the load itself idempotent for unchanged files (docs). DuckDB's read_csv has no such memory, so a re-run there loads the files again.
Engineer check. Grain: one row per row delivered, duplicates included. Run twice: on Snowflake, COPY INTO's load history skips files it has seen; elsewhere, track loaded files yourself. NULL: an empty CSV field arrives as NULL or empty string depending on the file format, so decide which before Silver relies on it.
#Silver: apply changes correctly
Turning an at-least-once change stream into a correct current state and a correct history.
#Q39 · Drop redelivered change events
Medium · Deduplication
The CDC tool delivers each change at least once, so the same change can appear twice in bronze_account_cdc. A change is identified by (account_id, seq_no), where seq_no is the source log position. Reduce Bronze to one row per change, keeping the most recent delivery.
Sample data
bronze_account_cdc
op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:01 |
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:07 |
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 12:00 | 102 | 2025-03-01 12:01 |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | 2025-03-01 10:02 |
Sample data as SQL (to run it yourself)
CREATE TABLE bronze_account_cdc (op VARCHAR, account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR, source_ts TIMESTAMP, seq_no INT, _ingested_at TIMESTAMP);
INSERT INTO bronze_account_cdc VALUES
('I', 'ACC-1', 100.00, 'OPEN', TIMESTAMP '2025-03-01 09:00', 101, TIMESTAMP '2025-03-01 09:01'),
('I', 'ACC-1', 100.00, 'OPEN', TIMESTAMP '2025-03-01 09:00', 101, TIMESTAMP '2025-03-01 09:07'),
('U', 'ACC-1', 80.00, 'OPEN', TIMESTAMP '2025-03-01 12:00', 102, TIMESTAMP '2025-03-01 12:01'),
('I', 'ACC-2', 500.00, 'OPEN', TIMESTAMP '2025-03-01 10:00', 201, TIMESTAMP '2025-03-01 10:02');
Expected output
op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:07 |
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 12:00 | 102 | 2025-03-01 12:01 |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | 2025-03-01 10:02 |
Hint
Which columns identify one change, as opposed to one account?
Answer
SELECT op, account_id, balance, status, source_ts, seq_no, _ingested_at
FROM bronze_account_cdc
QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id, seq_no
ORDER BY _ingested_at DESC) = 1
ORDER BY account_id, seq_no;
PARTITION BY account_id, seq_no: one change is one log position for one key. The two deliveries of change 101 collapse to one.- Not
PARTITION BY account_id: that would also collapse ACC-1's two different changes (101 and 102) into one. Removing redeliveries and reducing many changes to the latest are two separate steps, and confusing them loses history (Q40, Q42). ORDER BY _ingested_at DESC: the copies are identical, so either is fine; taking the latest delivery is a deterministic rule.
| op | account_id | balance | seq_no | _ingested_at | rn | |
|---|---|---|---|---|---|---|
| kept | I | ACC-1 | 100.00 | 101 | 2025-03-01 09:07 | 1 |
| removed | I | ACC-1 | 100.00 | 101 | 2025-03-01 09:01 | 2 |
| kept | U | ACC-1 | 80.00 | 102 | 2025-03-01 12:01 | 1 |
| kept | I | ACC-2 | 500.00 | 201 | 2025-03-01 10:02 | 1 |
Engineer check. Grain: one row per change (account_id, seq_no). Run twice: read-only and deterministic. NULL: a change with a NULL seq_no can't be ordered or deduplicated reliably; reject it at Silver.
#Q40 · Collapse a batch to the last change per account
Hard · CDC ordering
Before applying a batch to the current-state table, reduce it to the last change per account. Two of ACC-1's updates share the same source_ts (they committed in the same second). Pick the order key that gets the right final balance, and explain why.
Sample data
cdc_batch
op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:01 |
| U | ACC-1 | 50.00 | OPEN | 2025-03-01 09:00:05 | 102 | 2025-03-01 09:01 |
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 09:00:05 | 103 | 2025-03-01 09:01 |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | 2025-03-01 10:02 |
Sample data as SQL (to run it yourself)
CREATE TABLE cdc_batch (op VARCHAR, account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR, source_ts TIMESTAMP, seq_no INT, _ingested_at TIMESTAMP);
INSERT INTO cdc_batch VALUES
('I', 'ACC-1', 100.00, 'OPEN', TIMESTAMP '2025-03-01 09:00:00', 101, TIMESTAMP '2025-03-01 09:01'),
('U', 'ACC-1', 50.00, 'OPEN', TIMESTAMP '2025-03-01 09:00:05', 102, TIMESTAMP '2025-03-01 09:01'),
('U', 'ACC-1', 80.00, 'OPEN', TIMESTAMP '2025-03-01 09:00:05', 103, TIMESTAMP '2025-03-01 09:01'),
('I', 'ACC-2', 500.00, 'OPEN', TIMESTAMP '2025-03-01 10:00:00', 201, TIMESTAMP '2025-03-01 10:02');
Expected output
op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 09:00:05 | 103 | 2025-03-01 09:01 |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | 2025-03-01 10:02 |
Hint
Two rows have the same timestamp. Which column still tells you which one committed last?
Answer
SELECT *
FROM cdc_batch
QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id
ORDER BY seq_no DESC) = 1
ORDER BY account_id;
seq_nois the commit order. It's the source database's log position, assigned in exactly the order changes were committed. ACC-1 went 100 → 50 → 80, so the final balance is 80.source_tscan tie or skew. Two commits in the same second share a timestamp, and timestamps from different source nodes can disagree. Ordered bysource_ts, changes 102 and 103 tie, and the engine may pick 50: a balance the account no longer has (step 2).- Why collapse at all: the
MERGEthat applies the batch (Q41) must see at most one source row per key, or it's nondeterministic and Snowflake rejects it (docs). - Collapse only for the current-state table. A history table needs every change (Q42).
| op | account_id | balance | source_ts | seq_no | |
|---|---|---|---|---|---|
| removed | I | ACC-1 | 100.00 | 2025-03-01 09:00 | 101 |
| removed | U | ACC-1 | 50.00 | 2025-03-01 09:00:05 | 102 |
| kept | U | ACC-1 | 80.00 | 2025-03-01 09:00:05 | 103 |
| kept | I | ACC-2 | 500.00 | 2025-03-01 10:00 | 201 |
Engineer check. Grain: one row per account. Run twice: deterministic, because seq_no is unique per account. NULL: never order changes by a nullable or non-unique column.
#Q41 · Apply inserts, updates and deletes with one MERGE
Hard · CDC apply
silver_account is the current state of every account. Apply the collapsed change set: insert on I, update on U, and soft-delete on D (set is_deleted). It must be safe to re-apply, including an I for an account that already exists and a D for one that never did.
Sample data
silver_account
account_id | balance | status | is_deleted | _updated_at |
|---|---|---|---|---|
| ACC-1 | 100.00 | OPEN | FALSE | 2025-02-28 09:00 |
| ACC-3 | 900.00 | OPEN | FALSE | 2025-02-28 09:00 |
| ACC-5 | 300.00 | OPEN | FALSE | 2025-02-28 09:00 |
changes
op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 09:00 | 103 | NULL |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | NULL |
| D | ACC-3 | NULL | NULL | 2025-03-01 11:00 | 301 | NULL |
| D | ACC-4 | NULL | NULL | 2025-03-01 11:30 | 401 | NULL |
| I | ACC-5 | 300.00 | FROZEN | 2025-03-01 12:00 | 501 | NULL |
Sample data as SQL (to run it yourself)
CREATE TABLE silver_account (account_id VARCHAR PRIMARY KEY, balance DECIMAL(12,2), status VARCHAR, is_deleted BOOLEAN, _updated_at TIMESTAMP);
INSERT INTO silver_account VALUES
('ACC-1', 100.00, 'OPEN', FALSE, TIMESTAMP '2025-02-28 09:00'),
('ACC-3', 900.00, 'OPEN', FALSE, TIMESTAMP '2025-02-28 09:00'),
('ACC-5', 300.00, 'OPEN', FALSE, TIMESTAMP '2025-02-28 09:00');
CREATE TABLE changes (op VARCHAR, account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR, source_ts TIMESTAMP, seq_no INT, _ingested_at TIMESTAMP);
INSERT INTO changes VALUES
('U', 'ACC-1', 80.00, 'OPEN', TIMESTAMP '2025-03-01 09:00', 103, NULL),
('I', 'ACC-2', 500.00, 'OPEN', TIMESTAMP '2025-03-01 10:00', 201, NULL),
('D', 'ACC-3', NULL, NULL, TIMESTAMP '2025-03-01 11:00', 301, NULL),
('D', 'ACC-4', NULL, NULL, TIMESTAMP '2025-03-01 11:30', 401, NULL),
('I', 'ACC-5', 300.00, 'FROZEN', TIMESTAMP '2025-03-01 12:00', 501, NULL);
Expected output
account_id | balance | status | is_deleted | _updated_at |
|---|---|---|---|---|
| ACC-1 | 80.00 | OPEN | FALSE | 2025-03-01 09:00 |
| ACC-2 | 500.00 | OPEN | FALSE | 2025-03-01 10:00 |
| ACC-3 | 900.00 | OPEN | TRUE | 2025-03-01 11:00 |
| ACC-5 | 300.00 | FROZEN | FALSE | 2025-03-01 12:00 |
Hint
MERGE can have several WHEN MATCHED branches, each with its own extra condition. What should happen to a D that matches nothing?
Answer
MERGE INTO silver_account t
USING changes s
ON t.account_id = s.account_id
WHEN MATCHED AND s.op = 'D' THEN UPDATE SET
is_deleted = TRUE, _updated_at = s.source_ts
WHEN MATCHED AND s.op IN ('I', 'U') THEN UPDATE SET
balance = s.balance, status = s.status, is_deleted = FALSE, _updated_at = s.source_ts
WHEN NOT MATCHED AND s.op IN ('I', 'U') THEN INSERT
VALUES (s.account_id, s.balance, s.status, FALSE, s.source_ts);
- Conditional branches (
WHEN MATCHED AND s.op = 'D') let one statement handle all three operations. Branches are checked in order and the first that applies wins. - Soft delete: ACC-3 stays, with
is_deleted = TRUE. Readers filterWHERE NOT is_deleted. Physically deleting in Silver would lose the fact that the account existed and was closed, which history (Q42), downstream incremental loads and audits all need, and a replay from Bronze couldn't tell "deleted" from "never loaded". - An
Ifor an existing key is treated as an update (ACC-5). After a replay or an at-least-once redelivery, an insert can arrive for a row that's already there; treating it as an upsert keeps the statement idempotent instead of failing on the primary key. - A
Dfor an unknown key does nothing (ACC-4): no branch handlesNOT MATCHEDwithop = 'D', so it falls through. Deleting what isn't there is a no-op. is_deleted = FALSEon updates revives an account that's re-inserted after a delete.
| account_id | balance | status | is_deleted | _updated_at |
|---|---|---|---|---|
| ACC-1 | 100.00 | OPEN | FALSE | 2025-02-28 09:00 |
| ACC-3 | 900.00 | OPEN | FALSE | 2025-02-28 09:00 |
| ACC-5 | 300.00 | OPEN | FALSE | 2025-02-28 09:00 |
| op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 09:00 | 103 | NULL |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | NULL |
| D | ACC-3 | NULL | NULL | 2025-03-01 11:00 | 301 | NULL |
| D | ACC-4 | NULL | NULL | 2025-03-01 11:30 | 401 | NULL |
| I | ACC-5 | 300.00 | FROZEN | 2025-03-01 12:00 | 501 | NULL |
Engineer check. Grain: one row per account, current state. Run twice: verified unchanged. NULL: the delete events carry NULL balances, which is fine because the delete branch doesn't copy them.
#Q42 · SCD2 history from a change stream, keeping every change
Hard · SCD2 from CDC
Build silver_account_history from the full, deduplicated change set: one version per I or U, valid from its source_ts until the next change, with a D closing the account's last version without opening a new one. ACC-1 was frozen for a few hours on 1 March; that version must appear in the history.
Sample data
changes
op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | NULL |
| U | ACC-1 | 100.00 | FROZEN | 2025-03-01 12:00 | 102 | NULL |
| U | ACC-1 | 80.00 | OPEN | 2025-03-02 08:00 | 103 | NULL |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | NULL |
| D | ACC-2 | NULL | NULL | 2025-03-03 09:00 | 202 | NULL |
Sample data as SQL (to run it yourself)
CREATE TABLE changes (op VARCHAR, account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR, source_ts TIMESTAMP, seq_no INT, _ingested_at TIMESTAMP);
INSERT INTO changes VALUES
('I', 'ACC-1', 100.00, 'OPEN', TIMESTAMP '2025-03-01 09:00', 101, NULL),
('U', 'ACC-1', 100.00, 'FROZEN', TIMESTAMP '2025-03-01 12:00', 102, NULL),
('U', 'ACC-1', 80.00, 'OPEN', TIMESTAMP '2025-03-02 08:00', 103, NULL),
('I', 'ACC-2', 500.00, 'OPEN', TIMESTAMP '2025-03-01 10:00', 201, NULL),
('D', 'ACC-2', NULL, NULL, TIMESTAMP '2025-03-03 09:00', 202, NULL);
Expected output
account_id | balance | status | effective_from | effective_to | is_current | version |
|---|---|---|---|---|---|---|
| ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 2025-03-01 12:00 | FALSE | 1 |
| ACC-1 | 100.00 | FROZEN | 2025-03-01 12:00 | 2025-03-02 08:00 | FALSE | 2 |
| ACC-1 | 80.00 | OPEN | 2025-03-02 08:00 | 9999-12-31 00:00 | TRUE | 3 |
| ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 2025-03-03 09:00 | FALSE | 1 |
Hint
Don't collapse to the latest change here. Each change's window ends where the account's next change (of any kind) begins.
Answer
CREATE OR REPLACE TABLE silver_account_history AS
WITH ordered AS (
SELECT *,
LEAD(source_ts) OVER (PARTITION BY account_id ORDER BY seq_no) AS next_ts
FROM changes
)
SELECT
account_id, balance, status,
source_ts AS effective_from,
COALESCE(next_ts, TIMESTAMP '9999-12-31 00:00') AS effective_to,
next_ts IS NULL AS is_current,
ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY seq_no) AS version
FROM ordered
WHERE op IN ('I', 'U')
ORDER BY account_id, version;
- Use every change, not the collapsed batch. Collapsing to the last change per account (Q40) is right for the current-state table and wrong for history: ACC-1's FROZEN version would vanish, because a later change in the same batch replaced it (step 2). The original version of this exercise made exactly that mistake.
LEAD(source_ts) OVER (PARTITION BY account_id ORDER BY seq_no)is the start of the next change, which is where this version's window ends. Ordering byseq_no(Q40) keeps it correct even when timestamps tie.- The
Drow is used, then dropped. It's still inorderedwhenLEADruns, so ACC-2's last version ends at the delete time. ThenWHERE op IN ('I', 'U')removes theDrow itself, so no version is opened for it. ACC-2 has no current version: correctly, it no longer exists. is_current = next_ts IS NULL: only the last version of a live account is open.
To maintain this incrementally, process each new batch the same way, and close the account's currently open version at the timestamp of its first new change.
| op | account_id | status | balance | source_ts | next_ts | |
|---|---|---|---|---|---|---|
| kept | I | ACC-1 | OPEN | 100.00 | 2025-03-01 09:00 | 2025-03-01 12:00 |
| kept | U | ACC-1 | FROZEN | 100.00 | 2025-03-01 12:00 | 2025-03-02 08:00 |
| kept | U | ACC-1 | OPEN | 80.00 | 2025-03-02 08:00 | NULL |
| kept | I | ACC-2 | OPEN | 500.00 | 2025-03-01 10:00 | 2025-03-03 09:00 |
| removed | D | ACC-2 | NULL | NULL | 2025-03-03 09:00 | NULL |
Engineer check. Grain: one row per account version. Run twice: CREATE OR REPLACE rebuilds the same table. NULL: the open version's end is a sentinel, not NULL, so as-of queries (post 06) can use a plain range predicate.
#Q43 · Which days did this batch touch?
Medium · Late-arriving facts
Gold has daily aggregates that already ran up to 4 March. Today's batch (everything in Bronze ingested after the watermark) contains transactions dated in the past. For each account, return the earliest day the batch touched and the list of days, so the pipeline knows which Gold days to recompute.
Sample data
bronze_txn
txn_id | account_id | txn_ts | amount | _ingested_at |
|---|---|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 | 120.00 | 2025-03-01 09:05 |
| T-2 | ACC-1 | 2025-03-04 18:00 | 40.00 | 2025-03-04 18:01 |
| T-7 | ACC-1 | 2025-03-02 11:00 | 15.00 | 2025-03-05 07:00 |
| T-8 | ACC-2 | 2025-03-05 06:30 | 60.00 | 2025-03-05 07:00 |
| T-9 | ACC-1 | 2025-03-05 06:45 | 10.00 | 2025-03-05 07:00 |
etl_control
table_name | watermark_ts |
|---|---|
| silver_txn | 2025-03-04 23:59 |
Sample data as SQL (to run it yourself)
CREATE TABLE bronze_txn (txn_id VARCHAR, account_id VARCHAR, txn_ts TIMESTAMP, amount DECIMAL(10,2), _ingested_at TIMESTAMP);
INSERT INTO bronze_txn VALUES
('T-1', 'ACC-1', TIMESTAMP '2025-03-01 09:00', 120.00, TIMESTAMP '2025-03-01 09:05'),
('T-2', 'ACC-1', TIMESTAMP '2025-03-04 18:00', 40.00, TIMESTAMP '2025-03-04 18:01'),
('T-7', 'ACC-1', TIMESTAMP '2025-03-02 11:00', 15.00, TIMESTAMP '2025-03-05 07:00'),
('T-8', 'ACC-2', TIMESTAMP '2025-03-05 06:30', 60.00, TIMESTAMP '2025-03-05 07:00'),
('T-9', 'ACC-1', TIMESTAMP '2025-03-05 06:45', 10.00, TIMESTAMP '2025-03-05 07:00');
CREATE TABLE etl_control (table_name VARCHAR, watermark_ts TIMESTAMP);
INSERT INTO etl_control VALUES ('silver_txn', TIMESTAMP '2025-03-04 23:59');
Expected output
account_id | recompute_from | days_touched |
|---|---|---|
| ACC-1 | 2025-03-02 | 2025-03-02, 2025-03-05 |
| ACC-2 | 2025-03-05 | 2025-03-05 |
Hint
Filter the batch by when it arrived, then group it by when the transactions happened.
Answer
SELECT account_id,
MIN(CAST(txn_ts AS DATE)) AS recompute_from,
string_agg(DISTINCT CAST(CAST(txn_ts AS DATE) AS VARCHAR), ', ' ORDER BY CAST(CAST(txn_ts AS DATE) AS VARCHAR)) AS days_touched
FROM bronze_txn
WHERE _ingested_at > (SELECT watermark_ts FROM etl_control WHERE table_name = 'silver_txn')
GROUP BY account_id
ORDER BY account_id;
- Select the batch by arrival (
_ingested_at > watermark), then describe it by event time (txn_ts). Mixing the two up is the root of most late-data bugs (Q27). - T-7 is late: it happened on 2 March and arrived on 5 March, after 2 March's Gold aggregate had already run. Without this step, 2 March stays wrong forever.
recompute_frommatters for cumulative measures like a closing balance: a late row on 2 March changes 2 March and every day after it, so Q45 recomputes from this day forward, not just the late day itself.string_agg(DISTINCT …)lists the days for a log message; the pipeline usesrecompute_from.
| txn_id | account_id | txn_ts | amount | _ingested_at |
|---|---|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 | 120.00 | 2025-03-01 09:05 |
| T-2 | ACC-1 | 2025-03-04 18:00 | 40.00 | 2025-03-04 18:01 |
| T-7 | ACC-1 | 2025-03-02 11:00 | 15.00 | 2025-03-05 07:00 |
| T-8 | ACC-2 | 2025-03-05 06:30 | 60.00 | 2025-03-05 07:00 |
| T-9 | ACC-1 | 2025-03-05 06:45 | 10.00 | 2025-03-05 07:00 |
| table_name | watermark_ts |
|---|---|
| silver_txn | 2025-03-04 23:59 |
Engineer check. Grain: one row per account with new data. Run twice: read-only. NULL: a NULL txn_ts can't be placed on a day; quarantine it before this step.
#Q44 · Keep the first delivery, and flag the ones that disagree
Medium · Deduplication
Transactions are immutable events, but the feed redelivers some. Load one row per txn_id into Silver, keeping the first delivery. One redelivery came back with a different amount; that's not a duplicate but a conflict, and it must be flagged rather than silently resolved.
Sample data
bronze_txn
txn_id | account_id | amount | merchant | _ingested_at |
|---|---|---|---|---|
| T-1 | ACC-1 | 120.00 | Payroll | 2025-03-01 09:05 |
| T-2 | ACC-1 | 45.50 | Cafe | 2025-03-01 12:31 |
| T-2 | ACC-1 | 45.50 | Cafe | 2025-03-02 08:00 |
| T-3 | ACC-2 | 60.00 | Grocer | 2025-03-02 08:16 |
| T-3 | ACC-2 | 66.00 | Grocer | 2025-03-02 09:40 |
Sample data as SQL (to run it yourself)
CREATE TABLE bronze_txn (txn_id VARCHAR, account_id VARCHAR, amount DECIMAL(10,2), merchant VARCHAR, _ingested_at TIMESTAMP);
INSERT INTO bronze_txn VALUES
('T-1', 'ACC-1', 120.00, 'Payroll', TIMESTAMP '2025-03-01 09:05'),
('T-2', 'ACC-1', 45.50, 'Cafe', TIMESTAMP '2025-03-01 12:31'),
('T-2', 'ACC-1', 45.50, 'Cafe', TIMESTAMP '2025-03-02 08:00'),
('T-3', 'ACC-2', 60.00, 'Grocer', TIMESTAMP '2025-03-02 08:16'),
('T-3', 'ACC-2', 66.00, 'Grocer', TIMESTAMP '2025-03-02 09:40');
Expected output
txn_id | account_id | amount | merchant | _ingested_at | has_conflict |
|---|---|---|---|---|---|
| T-1 | ACC-1 | 120.00 | Payroll | 2025-03-01 09:05 | FALSE |
| T-2 | ACC-1 | 45.50 | Cafe | 2025-03-01 12:31 | FALSE |
| T-3 | ACC-2 | 60.00 | Grocer | 2025-03-02 08:16 | TRUE |
Hint
Keep-first is ORDER BY _ingested_at ascending. To spot a conflict, compare a fingerprint of the payload across all deliveries of the same id.
Answer
SELECT
txn_id, account_id, amount, merchant, _ingested_at,
MIN(md5(concat_ws('|', account_id, amount, merchant))) OVER w
<> MAX(md5(concat_ws('|', account_id, amount, merchant))) OVER w AS has_conflict
FROM bronze_txn
WINDOW w AS (PARTITION BY txn_id)
QUALIFY ROW_NUMBER() OVER (PARTITION BY txn_id ORDER BY _ingested_at) = 1
ORDER BY txn_id;
- Keep first for events, keep latest for records. A transaction happened once, so its first record is the truth and later copies are redeliveries. A customer record genuinely changes, so there the latest version wins (Q11). Choosing the wrong rule either resurrects stale values or silently overwrites real ones.
- T-2's copies are identical, so dropping the second is safe.
- T-3's copies disagree (60.00 then 66.00). Keep-first would silently keep 60, keep-latest would silently keep 66, and either way nobody learns that the source sent two different stories. The window compares the smallest and largest payload fingerprint per
txn_id; if they differ, the deliveries conflict. Route those rows to review (Q50) instead of letting a rule decide. - In Postgres, which has no
QUALIFY, wrap the query and filter on the row number.
| txn_id | amount | _ingested_at | fingerprint | |
|---|---|---|---|---|
| T-1 | 120.00 | 2025-03-01 09:05 | 58650d7b | |
| T-2 | 45.50 | 2025-03-01 12:31 | 817933e7 | |
| T-2 | 45.50 | 2025-03-02 08:00 | 817933e7 | |
| look here | T-3 | 60.00 | 2025-03-02 08:16 | 265943a4 |
| look here | T-3 | 66.00 | 2025-03-02 09:40 | 74c24e20 |
Engineer check. Grain: one row per txn_id. NULL: concat_ws skips NULLs on DuckDB and Postgres, so for a real fingerprint use Q19's NULL-safe recipe.
#Gold: aggregates and serving
Recomputing what late data changes, and the window and join traps in serving tables.
#Q45 · Recompute a cumulative Gold table after late data
Hard · Idempotent Gold
gold_daily_balance has one row per account per day from 1 to 4 March: the closing balance (opening balance plus all transactions to date) and the day's transaction count, including days with no transactions. A late transaction for 2 March just arrived (affected comes from Q43). Recompute only what it changed, idempotently.
Sample data
gold_daily_balance
balance_date | account_id | closing_balance | txn_count |
|---|---|---|---|
| 2025-03-01 | ACC-1 | 1120.00 | 1 |
| 2025-03-02 | ACC-1 | 1120.00 | 0 |
| 2025-03-03 | ACC-1 | 1120.00 | 0 |
| 2025-03-04 | ACC-1 | 1080.00 | 1 |
silver_txn
txn_id | account_id | txn_ts | signed_amount |
|---|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 | 120.00 |
| T-2 | ACC-1 | 2025-03-04 18:00 | -40.00 |
| T-7 | ACC-1 | 2025-03-02 11:00 | -15.00 |
affected
account_id | recompute_from |
|---|---|
| ACC-1 | 2025-03-02 |
Sample data as SQL (to run it yourself)
CREATE TABLE account_opening (account_id VARCHAR, opening_balance DECIMAL(12,2));
INSERT INTO account_opening VALUES ('ACC-1', 1000.00);
CREATE TABLE silver_txn (txn_id VARCHAR, account_id VARCHAR, txn_ts TIMESTAMP, signed_amount DECIMAL(12,2));
INSERT INTO silver_txn VALUES
('T-1', 'ACC-1', TIMESTAMP '2025-03-01 09:00', 120.00),
('T-2', 'ACC-1', TIMESTAMP '2025-03-04 18:00', -40.00);
CREATE TABLE gold_daily_balance AS
WITH days AS (SELECT CAST(d AS DATE) AS d FROM generate_series(DATE '2025-03-01', DATE '2025-03-04', INTERVAL 1 DAY) t(d)),
daily AS (
SELECT a.account_id, days.d,
COALESCE(SUM(t.signed_amount), 0) AS net_change, COUNT(t.txn_id) AS txn_count
FROM account_opening a CROSS JOIN days
LEFT JOIN silver_txn t ON t.account_id = a.account_id AND CAST(t.txn_ts AS DATE) = days.d
GROUP BY ALL
)
SELECT d AS balance_date, daily.account_id,
a.opening_balance + SUM(net_change) OVER (PARTITION BY daily.account_id ORDER BY d) AS closing_balance,
txn_count
FROM daily JOIN account_opening a USING (account_id);
INSERT INTO silver_txn VALUES ('T-7', 'ACC-1', TIMESTAMP '2025-03-02 11:00', -15.00); -- the late arrival
CREATE TABLE affected AS SELECT 'ACC-1' AS account_id, DATE '2025-03-02' AS recompute_from;
Expected output
balance_date | account_id | closing_balance | txn_count |
|---|---|---|---|
| 2025-03-01 | ACC-1 | 1120.00 | 1 |
| 2025-03-02 | ACC-1 | 1105.00 | 1 |
| 2025-03-03 | ACC-1 | 1105.00 | 0 |
| 2025-03-04 | ACC-1 | 1065.00 | 1 |
Hint
A late row on 2 March changes the closing balance of 2 March and of every day after it. And days with no transactions still need a row.
Answer
DELETE FROM gold_daily_balance g
USING affected a
WHERE g.account_id = a.account_id
AND g.balance_date >= a.recompute_from;
INSERT INTO gold_daily_balance
WITH days AS (
SELECT CAST(d AS DATE) AS d
FROM generate_series(DATE '2025-03-01', DATE '2025-03-04', INTERVAL 1 DAY) t(d)
),
daily AS ( -- every account x every day, empty days included
SELECT o.account_id, days.d,
COALESCE(SUM(t.signed_amount), 0) AS net_change,
COUNT(t.txn_id) AS txn_count
FROM account_opening o
CROSS JOIN days
LEFT JOIN silver_txn t
ON t.account_id = o.account_id AND CAST(t.txn_ts AS DATE) = days.d
GROUP BY ALL
),
running AS ( -- cumulative over the FULL history
SELECT daily.*,
o.opening_balance + SUM(net_change) OVER (PARTITION BY daily.account_id ORDER BY d) AS closing_balance
FROM daily JOIN account_opening o USING (account_id)
)
SELECT r.d, r.account_id, r.closing_balance, r.txn_count
FROM running r
JOIN affected a ON a.account_id = r.account_id AND r.d >= a.recompute_from;
- Recompute from the earliest affected day forward. The closing balance is cumulative, so T-7 lowers 2, 3 and 4 March by $15. Recomputing only 2 March would leave 3 and 4 March contradicting it.
- The running sum runs over the full history, then the final
JOIN affectedkeeps only the rows to replace. Computing the running sum over only the affected days would restart it from zero. - The opening balance is the starting point of every account's running sum. Without it, the "closing balance" is just the sum of transactions, which is wrong for any account that existed before the warehouse.
- The day spine gives 3 March a row (no transactions, balance carried forward). Grouping the transactions alone would skip it, and a balance chart would have a hole.
- Delete-then-insert over the affected range makes the step idempotent: run it twice and the same rows are replaced with the same values. The build verified it. A
MERGEon(balance_date, account_id)would also work here, but delete-then-insert is simpler when whole ranges are recomputed.
| balance_date | account_id | closing_balance | txn_count |
|---|---|---|---|
| 2025-03-01 | ACC-1 | 1120.00 | 1 |
| 2025-03-02 | ACC-1 | 1120.00 | 0 |
| 2025-03-03 | ACC-1 | 1120.00 | 0 |
| 2025-03-04 | ACC-1 | 1080.00 | 1 |
| txn_id | account_id | txn_ts | signed_amount |
|---|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 | 120.00 |
| T-2 | ACC-1 | 2025-03-04 18:00 | -40.00 |
| T-7 | ACC-1 | 2025-03-02 11:00 | -15.00 |
| account_id | recompute_from |
|---|---|
| ACC-1 | 2025-03-02 |
Engineer check. Grain: one row per account per day, every day. Run twice: verified unchanged. NULL: COALESCE(SUM(...), 0) turns an empty day's NULL into a zero change, so the running sum carries the balance forward.
#Q46 · A running balance that doesn't jump on ties
Hard · Window frames
Show the balance after each transaction for ACC-1, starting from an opening balance of 1,000. Two transactions share the same timestamp. Each row must show the balance after that row alone, in a repeatable order.
Sample data
silver_txn
txn_id | account_id | txn_ts | signed_amount |
|---|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 | 120.00 |
| T-2 | ACC-1 | 2025-03-01 12:30 | -45.50 |
| T-3 | ACC-1 | 2025-03-01 12:30 | -20.00 |
| T-4 | ACC-1 | 2025-03-02 08:00 | -10.00 |
Sample data as SQL (to run it yourself)
CREATE TABLE silver_txn (txn_id VARCHAR, account_id VARCHAR, txn_ts TIMESTAMP, signed_amount DECIMAL(12,2));
INSERT INTO silver_txn VALUES
('T-1', 'ACC-1', TIMESTAMP '2025-03-01 09:00', 120.00),
('T-2', 'ACC-1', TIMESTAMP '2025-03-01 12:30', -45.50),
('T-3', 'ACC-1', TIMESTAMP '2025-03-01 12:30', -20.00),
('T-4', 'ACC-1', TIMESTAMP '2025-03-02 08:00', -10.00);
Expected output
txn_id | txn_ts | signed_amount | running_balance |
|---|---|---|---|
| T-1 | 2025-03-01 09:00 | 120.00 | 1120.00 |
| T-2 | 2025-03-01 12:30 | -45.50 | 1074.50 |
| T-3 | 2025-03-01 12:30 | -20.00 | 1054.50 |
| T-4 | 2025-03-02 08:00 | -10.00 | 1044.50 |
Hint
With only ORDER BY txn_ts, the default window frame treats rows with the same timestamp as one group.
Answer
SELECT txn_id, txn_ts, signed_amount,
1000.00 + SUM(signed_amount) OVER (
PARTITION BY account_id
ORDER BY txn_ts, txn_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_balance
FROM silver_txn
ORDER BY txn_ts, txn_id;
ORDER BY txn_ts, txn_idadds a tiebreaker, so the order of T-2 and T-3 is the same on every run.ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWaccumulates row by row.- The trap: when a window has
ORDER BYand no explicit frame, the SQL default isRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, andRANGEincludes every peer (row with the sameORDER BYvalue) in the current row's frame (Postgres docs). T-2 and T-3 then both show 1,054.50, the balance after both of them, and the intermediate balance of 1,074.50 never appears (step 1). The end-of-day total is right, which is why this survives testing, but a statement or an overdraft check that reads it is wrong.
| txn_id | txn_ts | signed_amount | running_balance | |
|---|---|---|---|---|
| T-1 | 2025-03-01 09:00 | 120.00 | 1120.00 | |
| wrong | T-2 | 2025-03-01 12:30 | -45.50 | 1054.50 |
| T-3 | 2025-03-01 12:30 | -20.00 | 1054.50 | |
| T-4 | 2025-03-02 08:00 | -10.00 | 1044.50 |
Engineer check. Grain: one row per transaction. Run twice: repeatable only because of the txn_id tiebreak. NULL: a NULL amount is skipped by SUM, so the balance carries over that row unchanged; decide if that's acceptable or reject such rows in Silver.
#Q47 · Debits and credits side by side
Medium · Pivot
Per account and month, return two columns, total_debit and total_credit, from silver_txn.direction. An account with only credits in a month must show a debit of 0.
Sample data
silver_txn
txn_id | account_id | txn_ts | amount | direction |
|---|---|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 | 120.00 | CREDIT |
| T-2 | ACC-1 | 2025-03-01 12:30 | 45.50 | DEBIT |
| T-3 | ACC-1 | 2025-03-20 08:00 | 20.00 | DEBIT |
| T-4 | ACC-2 | 2025-03-05 10:00 | 500.00 | CREDIT |
Sample data as SQL (to run it yourself)
CREATE TABLE silver_txn (txn_id VARCHAR, account_id VARCHAR, txn_ts TIMESTAMP, amount DECIMAL(10,2), direction VARCHAR);
INSERT INTO silver_txn VALUES
('T-1', 'ACC-1', TIMESTAMP '2025-03-01 09:00', 120.00, 'CREDIT'),
('T-2', 'ACC-1', TIMESTAMP '2025-03-01 12:30', 45.50, 'DEBIT'),
('T-3', 'ACC-1', TIMESTAMP '2025-03-20 08:00', 20.00, 'DEBIT'),
('T-4', 'ACC-2', TIMESTAMP '2025-03-05 10:00', 500.00, 'CREDIT');
Expected output
account_id | month | total_debit | total_credit |
|---|---|---|---|
| ACC-1 | 2025-03-01 | 65.50 | 120.00 |
| ACC-2 | 2025-03-01 | 0.00 | 500.00 |
Hint
One SUM(CASE …) per output column.
Answer
In Snowflake:
SELECT *
FROM (SELECT account_id, DATE_TRUNC('month', txn_ts) AS month, direction, amount FROM silver_txn)
PIVOT (SUM(amount) FOR direction IN ('DEBIT', 'CREDIT'))
AS p (account_id, month, total_debit, total_credit);
-- PIVOT leaves a NULL where a month has no rows of one kind: wrap with COALESCE if 0 is wanted.
The same logic in portable SQL, as run on DuckDB to produce the output above:
SELECT account_id,
CAST(date_trunc('month', txn_ts) AS DATE) AS month,
SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE 0 END) AS total_debit,
SUM(CASE WHEN direction = 'CREDIT' THEN amount ELSE 0 END) AS total_credit
FROM silver_txn
GROUP BY ALL
ORDER BY account_id, month;
- Conditional aggregation turns values of
directioninto columns: eachSUM(CASE …)adds only its kind of row.ELSE 0makes a missing kind 0 rather than NULL. date_trunc('month', …)buckets timestamps by month.- Snowflake's
PIVOTis shorter but needs the value list written out, and it returns NULL for a missing kind (docs). TheCASEform works on every engine and handles derived buckets, which is why it's the usual choice in pipelines.
| txn_id | account_id | txn_ts | amount | direction |
|---|---|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 | 120.00 | CREDIT |
| T-2 | ACC-1 | 2025-03-01 12:30 | 45.50 | DEBIT |
| T-3 | ACC-1 | 2025-03-20 08:00 | 20.00 | DEBIT |
| T-4 | ACC-2 | 2025-03-05 10:00 | 500.00 | CREDIT |
Engineer check. Grain: one row per account and month. NULL: a NULL direction falls into neither column, so those rows disappear from both totals. Quarantine them first (Q50).
#Q48 · Find dormant stretches, including at the edges
Hard · Gaps and islands
Find every stretch of 5 or more days with no transactions, per account, between the day it opened and the reporting date 2025-03-31. Include the stretch before the first transaction and the one after the last.
Sample data
accounts
account_id | opened_date |
|---|---|
| ACC-1 | 2025-03-01 |
| ACC-2 | 2025-03-01 |
silver_txn
txn_id | account_id | txn_ts |
|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 |
| T-2 | ACC-1 | 2025-03-03 10:00 |
| T-3 | ACC-1 | 2025-03-12 11:00 |
| T-4 | ACC-1 | 2025-03-29 12:00 |
| T-5 | ACC-2 | 2025-03-10 08:00 |
Sample data as SQL (to run it yourself)
CREATE TABLE accounts (account_id VARCHAR, opened_date DATE);
INSERT INTO accounts VALUES ('ACC-1', DATE '2025-03-01'), ('ACC-2', DATE '2025-03-01');
CREATE TABLE silver_txn (txn_id VARCHAR, account_id VARCHAR, txn_ts TIMESTAMP);
INSERT INTO silver_txn VALUES
('T-1', 'ACC-1', TIMESTAMP '2025-03-01 09:00'), ('T-2', 'ACC-1', TIMESTAMP '2025-03-03 10:00'),
('T-3', 'ACC-1', TIMESTAMP '2025-03-12 11:00'), ('T-4', 'ACC-1', TIMESTAMP '2025-03-29 12:00'),
('T-5', 'ACC-2', TIMESTAMP '2025-03-10 08:00');
Expected output
account_id | gap_start | gap_end | gap_days |
|---|---|---|---|
| ACC-1 | 2025-03-04 | 2025-03-11 | 8 |
| ACC-1 | 2025-03-13 | 2025-03-28 | 16 |
| ACC-2 | 2025-03-01 | 2025-03-09 | 9 |
| ACC-2 | 2025-03-11 | 2025-03-31 | 21 |
Hint
LAG over the active days finds gaps between transactions. Add two fake marker days so the edges become gaps between markers too.
Answer
WITH marks AS ( -- active days, plus a marker before the start and after the end
SELECT DISTINCT account_id, CAST(txn_ts AS DATE) AS d FROM silver_txn
UNION
SELECT account_id, opened_date - 1 FROM accounts
UNION
SELECT account_id, DATE '2025-03-31' + 1 FROM accounts
),
gaps AS (
SELECT account_id, d,
LAG(d) OVER (PARTITION BY account_id ORDER BY d) AS prev_d
FROM marks
)
SELECT account_id,
prev_d + 1 AS gap_start,
d - 1 AS gap_end,
d - prev_d - 1 AS gap_days
FROM gaps
WHERE d - prev_d - 1 >= 5
ORDER BY account_id, gap_start;
LAG(d)pairs each active day with the previous one; the days strictly between them (d − prev_d − 1) are dormant.- Edge markers: the obvious query only measures gaps between transactions, so it misses ACC-2's nine dormant days after opening and its 21 quiet days at the end of the period. Adding a marker the day before opening and the day after the reporting date turns those edges into ordinary gaps.
UNION, notUNION ALL, so a transaction on the marker day doesn't create a zero-length duplicate.- Dates subtract to whole days in DuckDB, Postgres and Snowflake; use
DATEDIFF('day', …)if your engine needs it.
| account_id | d | kind | days_since_prev | |
|---|---|---|---|---|
| ACC-1 | 2025-02-28 | start marker | NULL | |
| ACC-1 | 2025-03-01 | active | 0 | |
| ACC-1 | 2025-03-03 | active | 1 | |
| look here | ACC-1 | 2025-03-12 | active | 8 |
| look here | ACC-1 | 2025-03-29 | active | 16 |
| ACC-1 | 2025-04-01 | end marker | 2 | |
| ACC-2 | 2025-02-28 | start marker | NULL | |
| look here | ACC-2 | 2025-03-10 | active | 9 |
| look here | ACC-2 | 2025-04-01 | end marker | 21 |
Engineer check. Grain: one row per dormant stretch. NULL: LAG is NULL on each account's first row, the comparison is unknown, and the row is filtered out, which is right because the start marker is always first.
#Q49 · Three transactions inside ten minutes
Hard · Sliding windows
Flag every moment an account has 3 or more transactions within a 10-minute window (a simple velocity check for fraud). For each flagged transaction, return the account, the transaction that completes the burst, and how many transactions fell in the 10 minutes ending at it.
Sample data
silver_txn
txn_id | account_id | txn_ts |
|---|---|---|
| T-1 | ACC-1 | 2025-03-01 10:00 |
| T-2 | ACC-1 | 2025-03-01 10:04 |
| T-3 | ACC-1 | 2025-03-01 10:09 |
| T-4 | ACC-1 | 2025-03-01 10:15 |
| T-5 | ACC-2 | 2025-03-01 10:00 |
| T-6 | ACC-2 | 2025-03-01 10:11 |
| T-7 | ACC-2 | 2025-03-01 10:20 |
Sample data as SQL (to run it yourself)
CREATE TABLE silver_txn (txn_id VARCHAR, account_id VARCHAR, txn_ts TIMESTAMP);
INSERT INTO silver_txn VALUES
('T-1', 'ACC-1', TIMESTAMP '2025-03-01 10:00'), ('T-2', 'ACC-1', TIMESTAMP '2025-03-01 10:04'),
('T-3', 'ACC-1', TIMESTAMP '2025-03-01 10:09'), ('T-4', 'ACC-1', TIMESTAMP '2025-03-01 10:15'),
('T-5', 'ACC-2', TIMESTAMP '2025-03-01 10:00'), ('T-6', 'ACC-2', TIMESTAMP '2025-03-01 10:11'),
('T-7', 'ACC-2', TIMESTAMP '2025-03-01 10:20');
Expected output
account_id | txn_id | txn_ts | txns_in_window |
|---|---|---|---|
| ACC-1 | T-3 | 2025-03-01 10:09 | 3 |
Hint
For each transaction, count the transactions in the 10 minutes ending at it.
Answer
SELECT a.account_id, a.txn_id, a.txn_ts, COUNT(*) AS txns_in_window
FROM silver_txn a
JOIN silver_txn b
ON b.account_id = a.account_id
AND b.txn_ts > a.txn_ts - INTERVAL 10 MINUTE
AND b.txn_ts <= a.txn_ts
GROUP BY a.account_id, a.txn_id, a.txn_ts
HAVING COUNT(*) >= 3
ORDER BY a.account_id, a.txn_ts;
- Each transaction is the end of a window
(t − 10 min, t]. The self-join collects every transaction of the same account in that window, including itself, andHAVING COUNT(*) >= 3keeps the bursts. T-3 at 10:09 sees T-1, T-2 and itself. - The half-open window (
>on the left) means a transaction exactly 10 minutes earlier doesn't count. Choose the boundary deliberately; it's a business rule. - The trade-off: a self-join compares every pair of an account's transactions, which is slow for a busy account. A window function does it in one sorted pass:
COUNT(*) OVER (PARTITION BY account_id ORDER BY txn_ts RANGE BETWEEN INTERVAL 10 MINUTE PRECEDING AND CURRENT ROW). DuckDB and Postgres support interval offsets inRANGEframes; check your engine's window-frame documentation before relying on it, because support varies. - ACC-2's transactions are 11 and 9 minutes apart, so no 10-minute window ever holds three.
| txn_id | account_id | txn_ts |
|---|---|---|
| T-1 | ACC-1 | 2025-03-01 10:00 |
| T-2 | ACC-1 | 2025-03-01 10:04 |
| T-3 | ACC-1 | 2025-03-01 10:09 |
| T-4 | ACC-1 | 2025-03-01 10:15 |
| T-5 | ACC-2 | 2025-03-01 10:00 |
| T-6 | ACC-2 | 2025-03-01 10:11 |
| T-7 | ACC-2 | 2025-03-01 10:20 |
Engineer check. Grain: one row per transaction that completes a burst. NULL: a NULL txn_ts matches no window; it can't be placed in time, so reject it in Silver.
#Quality, serving and operations
Checks that can't silently pass, a serving table that can't multiply money, and a refresh that can't silently double.
#Q50 · Quarantine bad rows, and make sure none fall through
Medium · Data contracts
Silver transactions must have amount <> 0 and direction in (DEBIT, CREDIT). Load valid rows into silver_txn and everything else into reject_txn with a reason. Every Bronze row must land in exactly one of the two tables.
Sample data
bronze_txn
txn_id | amount | direction |
|---|---|---|
| T-1 | 120.00 | CREDIT |
| T-2 | 0.00 | DEBIT |
| T-3 | 45.50 | REFUND |
| T-4 | NULL | DEBIT |
| T-5 | 30.00 | NULL |
| T-6 | 12.00 | DEBIT |
Sample data as SQL (to run it yourself)
CREATE TABLE bronze_txn (txn_id VARCHAR, amount DECIMAL(10,2), direction VARCHAR);
INSERT INTO bronze_txn VALUES
('T-1', 120.00, 'CREDIT'), ('T-2', 0.00, 'DEBIT'), ('T-3', 45.50, 'REFUND'),
('T-4', NULL, 'DEBIT'), ('T-5', 30.00, NULL), ('T-6', 12.00, 'DEBIT');
CREATE TABLE silver_txn (txn_id VARCHAR, amount DECIMAL(10,2), direction VARCHAR);
CREATE TABLE reject_txn (txn_id VARCHAR, amount DECIMAL(10,2), direction VARCHAR, reject_reason VARCHAR);
Expected output
target | txn_id | reject_reason |
|---|---|---|
| silver_txn | T-1 | NULL |
| reject_txn | T-2 | ZERO_AMOUNT |
| reject_txn | T-3 | BAD_DIRECTION |
| reject_txn | T-4 | MISSING_AMOUNT |
| reject_txn | T-5 | MISSING_DIRECTION |
| silver_txn | T-6 | NULL |
Hint
Write the rule once, as a column that can only be TRUE or FALSE, then route on it. What does NULL <> 0 AND … evaluate to?
Answer
CREATE OR REPLACE TEMP TABLE checked AS
SELECT *,
COALESCE(amount <> 0 AND direction IN ('DEBIT', 'CREDIT'), FALSE) AS is_valid
FROM bronze_txn;
INSERT INTO silver_txn
SELECT txn_id, amount, direction FROM checked WHERE is_valid;
INSERT INTO reject_txn
SELECT txn_id, amount, direction,
CASE WHEN amount IS NULL THEN 'MISSING_AMOUNT'
WHEN amount = 0 THEN 'ZERO_AMOUNT'
WHEN direction IS NULL THEN 'MISSING_DIRECTION'
ELSE 'BAD_DIRECTION' END
FROM checked
WHERE NOT is_valid;
- The contract becomes one boolean column,
is_valid, and both inserts route on it, so the two sides can't disagree. COALESCE(…, FALSE)is the critical part. For T-4 (NULL amount) and T-5 (NULL direction) the rule evaluates to UNKNOWN, not FALSE. With the obvious pair of filters,WHERE rulefor Silver andWHERE NOT (rule)for rejects, both evaluate to UNKNOWN for those rows, so they land in neither table and silently disappear (step 2). Forcing UNKNOWN to FALSE sends them to quarantine.- The reasons are checked in order, NULLs first, so each rejected row gets the most specific reason.
- Quarantine, don't drop: rejected rows can be investigated, fixed upstream and replayed. Snowflake now enforces
CHECKconstraints too (post 01, Q1), but a failed check aborts the whole insert; routing rows keeps the good ones flowing. - Prove it: count Bronze rows, Silver rows and rejected rows, and fail the run unless they add up.
| txn_id | amount | direction | rule | |
|---|---|---|---|---|
| kept | T-1 | 120.00 | CREDIT | TRUE |
| removed | T-2 | 0.00 | DEBIT | FALSE |
| removed | T-3 | 45.50 | REFUND | FALSE |
| wrong | T-4 | NULL | DEBIT | NULL |
| wrong | T-5 | 30.00 | NULL | NULL |
| kept | T-6 | 12.00 | DEBIT | TRUE |
Engineer check. Grain: every Bronze row in exactly one target. Run twice: these inserts append again, so in a pipeline they sit behind a watermark (Q27) or replace the batch. NULL: the whole point. A rule that can be UNKNOWN routes nowhere unless you collapse it to TRUE or FALSE.
#Q51 · Reconcile two layers with checksums
Hard · Reconciliation
Check whether silver_account (live accounts only) matches a fresh src_account_snapshot. First compare a cheap aggregate checksum of each side; only if they differ, list the accounts that differ and how.
Sample data
silver_account
account_id | balance | status | is_deleted |
|---|---|---|---|
| ACC-1 | 80.00 | OPEN | FALSE |
| ACC-2 | 500.00 | OPEN | FALSE |
| ACC-3 | 900.00 | OPEN | TRUE |
| ACC-5 | 300.00 | FROZEN | FALSE |
| ACC-7 | 60.00 | OPEN | FALSE |
src_account_snapshot
account_id | balance | status |
|---|---|---|
| ACC-1 | 80.00 | OPEN |
| ACC-2 | 450.00 | OPEN |
| ACC-5 | 300.00 | FROZEN |
| ACC-6 | 75.00 | OPEN |
Sample data as SQL (to run it yourself)
CREATE TABLE silver_account (account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR, is_deleted BOOLEAN);
INSERT INTO silver_account VALUES
('ACC-1', 80.00, 'OPEN', FALSE), ('ACC-2', 500.00, 'OPEN', FALSE),
('ACC-3', 900.00, 'OPEN', TRUE), ('ACC-5', 300.00, 'FROZEN', FALSE),
('ACC-7', 60.00, 'OPEN', FALSE);
CREATE TABLE src_account_snapshot (account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR);
INSERT INTO src_account_snapshot VALUES
('ACC-1', 80.00, 'OPEN'), ('ACC-2', 450.00, 'OPEN'), ('ACC-5', 300.00, 'FROZEN'), ('ACC-6', 75.00, 'OPEN');
Expected output (the row-level diff)
account_id | problem |
|---|---|
| ACC-2 | values differ |
| ACC-6 | missing in silver |
| ACC-7 | missing in source |
Hint
SUM doesn't care about row order, so a sum of row hashes fingerprints a whole table in one scan.
Answer
-- 1. One pass per side, no join
SELECT 'silver' AS side, COUNT(*) AS row_count, SUM(hash(account_id, balance, status)) AS checksum
FROM silver_account WHERE NOT is_deleted
UNION ALL
SELECT 'source', COUNT(*), SUM(hash(account_id, balance, status))
FROM src_account_snapshot;
-- 2. Only when they differ: the row-level diff
SELECT COALESCE(s.account_id, x.account_id) AS account_id,
CASE WHEN s.account_id IS NULL THEN 'missing in silver'
WHEN x.account_id IS NULL THEN 'missing in source'
ELSE 'values differ' END AS problem
FROM (SELECT * FROM silver_account WHERE NOT is_deleted) s
FULL OUTER JOIN src_account_snapshot x ON x.account_id = s.account_id
WHERE s.account_id IS NULL OR x.account_id IS NULL
OR hash(s.balance, s.status) <> hash(x.balance, x.status)
ORDER BY account_id;
SUM(hash(…))is an order-independent fingerprint of a table. Together with the row count, matching sums mean the tables almost certainly match, and it costs one scan per side with no join. On huge tables that's the check you can afford to run every time.- Step 2 runs only when step 1 disagrees. The
FULL OUTER JOINfinds keys missing on either side (ACC-6 is only in the source, ACC-7 only in Silver), and comparing row hashes finds keys whose values differ (ACC-2's balance). - Why the count isn't enough: both sides have four live rows, so a row-count check passes even though three accounts are wrong.
- Compare like with like: soft-deleted ACC-3 is excluded from Silver because the source snapshot only has live accounts.
hash()is engine-specific (DuckDB and Snowflake both have one, with different outputs), so compute both sides on the same engine. To compare across engines, usemd5of a NULL-safe string (Q19).
| side | row_count | checksum |
|---|---|---|
| silver | 4 | 32916981022189210000 |
| source | 4 | 37514689815987460000 |
Engineer check. Grain: one row per mismatched account. Run twice: read-only. NULL: hash(NULL) is a fixed value on DuckDB, but a NULL inside a hashed string can behave differently elsewhere; use the NULL-safe recipe for cross-engine checks.
#Q52 · A customer 360 that doesn't multiply balances
Hard · Serving and fan-out
Build gold_customer_360, one row per customer: number of live accounts, total balance of live accounts, last transaction time and number of transactions. A customer whose accounts have no transactions must still appear. Show the bug in joining accounts to transactions and then summing balances.
Sample data
silver_account
account_id | customer_id | balance | is_deleted |
|---|---|---|---|
| ACC-1 | CUST-1 | 1000.00 | FALSE |
| ACC-2 | CUST-1 | 500.00 | FALSE |
| ACC-3 | CUST-2 | 250.00 | FALSE |
| ACC-4 | CUST-2 | 900.00 | TRUE |
silver_txn
txn_id | account_id | txn_ts |
|---|---|---|
| T-1 | ACC-1 | 2025-03-01 09:00 |
| T-2 | ACC-1 | 2025-03-02 10:00 |
| T-3 | ACC-1 | 2025-03-03 11:00 |
| T-4 | ACC-2 | 2025-03-02 12:00 |
Sample data as SQL (to run it yourself)
CREATE TABLE silver_account (account_id VARCHAR, customer_id VARCHAR, balance DECIMAL(12,2), is_deleted BOOLEAN);
INSERT INTO silver_account VALUES
('ACC-1', 'CUST-1', 1000.00, FALSE), ('ACC-2', 'CUST-1', 500.00, FALSE),
('ACC-3', 'CUST-2', 250.00, FALSE), ('ACC-4', 'CUST-2', 900.00, TRUE);
CREATE TABLE silver_txn (txn_id VARCHAR, account_id VARCHAR, txn_ts TIMESTAMP);
INSERT INTO silver_txn VALUES
('T-1', 'ACC-1', TIMESTAMP '2025-03-01 09:00'), ('T-2', 'ACC-1', TIMESTAMP '2025-03-02 10:00'),
('T-3', 'ACC-1', TIMESTAMP '2025-03-03 11:00'), ('T-4', 'ACC-2', TIMESTAMP '2025-03-02 12:00');
Expected output
customer_id | live_accounts | total_balance | last_txn_ts | txn_count |
|---|---|---|---|---|
| CUST-1 | 2 | 1500.00 | 2025-03-03 11:00 | 4 |
| CUST-2 | 1 | 250.00 | NULL | 0 |
Hint
Each account's balance appears once per transaction after the join. Aggregate each side to the customer before joining them.
Answer
WITH acct AS ( -- one row per customer, from accounts alone
SELECT customer_id,
COUNT(*) AS live_accounts,
SUM(balance) AS total_balance
FROM silver_account
WHERE NOT is_deleted
GROUP BY customer_id
),
txn AS ( -- one row per customer, from transactions alone
SELECT a.customer_id, MAX(t.txn_ts) AS last_txn_ts, COUNT(*) AS txn_count
FROM silver_txn t
JOIN silver_account a ON a.account_id = t.account_id
WHERE NOT a.is_deleted
GROUP BY a.customer_id
)
SELECT acct.customer_id, live_accounts, total_balance, last_txn_ts,
COALESCE(txn_count, 0) AS txn_count
FROM acct
LEFT JOIN txn ON txn.customer_id = acct.customer_id
ORDER BY acct.customer_id;
- The fan-out: joining accounts to transactions repeats ACC-1's row three times (it has three transactions).
SUM(balance)over the joined rows counts its $1,000 three times, so CUST-1's balance comes out as $3,500 instead of $1,500 (step 1).COUNT(DISTINCT account_id)hides the problem for the account count, which makes the wrong balance easy to miss. - Aggregate each side to the output grain first (one row per customer), then join one-to-one. The same rule as drill-across in post 02 (Q34).
LEFT JOINkeeps CUST-2, whose live account has no transactions; an inner join would drop the customer entirely.COALESCE(txn_count, 0)reports zero rather than NULL.- Deleted accounts are excluded on both sides, so ACC-4's $900 doesn't count.
| customer_id | account_id | balance | txn_id | |
|---|---|---|---|---|
| wrong | CUST-1 | ACC-1 | 1000.00 | T-1 |
| wrong | CUST-1 | ACC-1 | 1000.00 | T-2 |
| wrong | CUST-1 | ACC-1 | 1000.00 | T-3 |
| wrong | CUST-1 | ACC-2 | 500.00 | T-4 |
| customer_id | live_accounts | total_balance | |
|---|---|---|---|
| wrong | CUST-1 | 2 | 3500.00 |
| CUST-2 | 1 | 250.00 |
Engineer check. Grain: one row per customer with a live account. NULL: customers with no transactions get NULL last_txn_ts, which is honest, and a txn_count of 0.
#Q53 · Which batch wrote this row, and is it stale?
Medium · Lineage
silver_account carries lineage columns: _batch_id (the run that last wrote the row), _source_system and _record_hash (a fingerprint of the business columns at write time). For each account, report the batch that last wrote it and whether it is stale: the current source data no longer matches what was written.
Sample data
silver_account
account_id | balance | status | _batch_id | _source_system | _record_hash |
|---|---|---|---|---|---|
| ACC-1 | 80.00 | OPEN | B-0305 | core-banking | 8b6aae79bd5a879f82b7a53738277cb8 |
| ACC-2 | 500.00 | OPEN | B-0301 | core-banking | 5c9bbe499ca4de34f59af467d819b7cf |
| ACC-5 | 300.00 | FROZEN | B-0304 | core-banking | cc6bbfa2b6475fa9a9e7d27cbd6a0d39 |
src_account_snapshot
account_id | balance | status |
|---|---|---|
| ACC-1 | 80.00 | OPEN |
| ACC-2 | 450.00 | OPEN |
| ACC-5 | 300.00 | FROZEN |
Sample data as SQL (to run it yourself)
CREATE TABLE silver_account (account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR,
_batch_id VARCHAR, _source_system VARCHAR, _record_hash VARCHAR);
INSERT INTO silver_account VALUES
('ACC-1', 80.00, 'OPEN', 'B-0305', 'core-banking', md5('80.00|OPEN')),
('ACC-2', 500.00, 'OPEN', 'B-0301', 'core-banking', md5('500.00|OPEN')),
('ACC-5', 300.00, 'FROZEN', 'B-0304', 'core-banking', md5('300.00|FROZEN'));
CREATE TABLE src_account_snapshot (account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR);
INSERT INTO src_account_snapshot VALUES ('ACC-1', 80.00, 'OPEN'), ('ACC-2', 450.00, 'OPEN'), ('ACC-5', 300.00, 'FROZEN');
Expected output
account_id | last_written_by | _source_system | is_stale |
|---|---|---|---|
| ACC-1 | B-0305 | core-banking | FALSE |
| ACC-2 | B-0301 | core-banking | TRUE |
| ACC-5 | B-0304 | core-banking | FALSE |
Hint
Recompute the fingerprint from the source with exactly the same recipe, and compare.
Answer
SELECT s.account_id,
s._batch_id AS last_written_by,
s._source_system,
s._record_hash <> md5(concat_ws('|', CAST(x.balance AS VARCHAR), x.status)) AS is_stale
FROM silver_account s
JOIN src_account_snapshot x USING (account_id)
ORDER BY s.account_id;
_batch_idanswers "which run wrote this?", which is the first question in any incident: you can find every row a bad run touched, and replay or roll back just those._record_hashis a fingerprint of the business columns stored at write time. Recomputing it from the source with the same recipe and comparing gives a one-column staleness check: ACC-2's source balance moved to 450, so its Silver row is stale and was last written by batch B-0301.- The recipe must match byte for byte: the same column order, separator, casts and NULL handling (Q19). A different cast (
450.0vs450.00) makes every row look stale.
| account_id | balance | status | _batch_id | _source_system | _record_hash |
|---|---|---|---|---|---|
| ACC-1 | 80.00 | OPEN | B-0305 | core-banking | 8b6aae79bd5a879f82b7a53738277cb8 |
| ACC-2 | 500.00 | OPEN | B-0301 | core-banking | 5c9bbe499ca4de34f59af467d819b7cf |
| ACC-5 | 300.00 | FROZEN | B-0304 | core-banking | cc6bbfa2b6475fa9a9e7d27cbd6a0d39 |
| account_id | balance | status |
|---|---|---|
| ACC-1 | 80.00 | OPEN |
| ACC-2 | 450.00 | OPEN |
| ACC-5 | 300.00 | FROZEN |
Engineer check. Grain: one row per account. Run twice: read-only. NULL: a NULL in a hashed column needs the NULL-safe recipe, or two different rows can share a fingerprint.
#Q54 · A Silver refresh that is safe to run twice
Expert · Idempotent pipeline
Write the complete, re-runnable refresh of silver_account from bronze_account_cdc, as it would run in a scheduled task: read the watermark and fix the batch's upper bound, deduplicate redeliveries, collapse to the last change per account, apply inserts, updates and soft deletes, and advance the watermark. Running it twice in a row must change nothing the second time.
Sample data
bronze_account_cdc
op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:01 |
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:07 |
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 12:00 | 102 | 2025-03-01 12:01 |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | 2025-03-01 10:02 |
| D | ACC-2 | NULL | NULL | 2025-03-01 15:00 | 202 | 2025-03-01 15:01 |
Sample data as SQL (to run it yourself)
CREATE TABLE bronze_account_cdc (op VARCHAR, account_id VARCHAR, balance DECIMAL(12,2), status VARCHAR, source_ts TIMESTAMP, seq_no INT, _ingested_at TIMESTAMP);
INSERT INTO bronze_account_cdc VALUES
('I', 'ACC-1', 100.00, 'OPEN', TIMESTAMP '2025-03-01 09:00', 101, TIMESTAMP '2025-03-01 09:01'),
('I', 'ACC-1', 100.00, 'OPEN', TIMESTAMP '2025-03-01 09:00', 101, TIMESTAMP '2025-03-01 09:07'),
('U', 'ACC-1', 80.00, 'OPEN', TIMESTAMP '2025-03-01 12:00', 102, TIMESTAMP '2025-03-01 12:01'),
('I', 'ACC-2', 500.00, 'OPEN', TIMESTAMP '2025-03-01 10:00', 201, TIMESTAMP '2025-03-01 10:02'),
('D', 'ACC-2', NULL, NULL, TIMESTAMP '2025-03-01 15:00', 202, TIMESTAMP '2025-03-01 15:01');
CREATE TABLE silver_account (account_id VARCHAR PRIMARY KEY, balance DECIMAL(12,2), status VARCHAR, is_deleted BOOLEAN, _updated_at TIMESTAMP);
CREATE TABLE etl_control (table_name VARCHAR, watermark_ts TIMESTAMP);
INSERT INTO etl_control VALUES ('silver_account', TIMESTAMP '1900-01-01');
Expected output
account_id | balance | status | is_deleted | _updated_at | watermark_ts |
|---|---|---|---|---|---|
| ACC-1 | 80.00 | OPEN | FALSE | 2025-03-01 12:00 | 2025-03-01 15:01 |
Hint
List what could double-apply a change on a retry: redelivered events, several changes per key, a blind insert, a watermark that moves too far or not at all. Each step needs a guard against one of them.
Answer
-- 1. Freeze this run's window: (watermark, upper]
CREATE OR REPLACE TEMP TABLE run_window AS
SELECT (SELECT watermark_ts FROM etl_control WHERE table_name = 'silver_account') AS lower_ts,
MAX(_ingested_at) AS upper_ts
FROM bronze_account_cdc
WHERE _ingested_at > (SELECT watermark_ts FROM etl_control WHERE table_name = 'silver_account');
-- 2. The change set: drop redeliveries, then keep the last change per account
CREATE OR REPLACE TEMP TABLE chg AS
WITH new_rows AS (
SELECT c.* FROM bronze_account_cdc c, run_window w
WHERE c._ingested_at > w.lower_ts AND c._ingested_at <= w.upper_ts
),
deduped AS (
SELECT * FROM new_rows
QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id, seq_no ORDER BY _ingested_at DESC) = 1
)
SELECT * FROM deduped
QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY seq_no DESC) = 1;
-- 3. Apply I/U/D (Q41)
MERGE INTO silver_account t
USING chg s ON t.account_id = s.account_id
WHEN MATCHED AND s.op = 'D' THEN UPDATE SET is_deleted = TRUE, _updated_at = s.source_ts
WHEN MATCHED AND s.op IN ('I', 'U') THEN UPDATE SET
balance = s.balance, status = s.status, is_deleted = FALSE, _updated_at = s.source_ts
WHEN NOT MATCHED AND s.op IN ('I', 'U') THEN INSERT
VALUES (s.account_id, s.balance, s.status, FALSE, s.source_ts);
-- 4. Advance the watermark to the bound this run used; an empty run leaves it alone
UPDATE etl_control
SET watermark_ts = COALESCE((SELECT upper_ts FROM run_window), watermark_ts)
WHERE table_name = 'silver_account';
Each step carries a guard, and together they make the job safe to retry:
| Step | Guard | What it prevents |
|---|---|---|
| 1 | The window (watermark, upper] is frozen before reading | Skipping rows that land mid-run (Q27) |
| 2 | Dedup on (account_id, seq_no) | Applying a redelivered change twice (Q39) |
| 2 | Last change per account by seq_no | A nondeterministic MERGE or a stale final state (Q40) |
| 3 | MERGE instead of INSERT | Duplicate rows; re-applying a change is a no-op (Q41) |
| 4 | COALESCE(upper_ts, watermark_ts) | A NULL watermark after an empty run (Q27) |
The second run finds no rows above the new watermark, so chg is empty, the MERGE does nothing and the watermark stays put. The build ran it twice and checked.
ACC-2 never appears. It was opened and closed inside this batch, so the collapsed change is a D for an account Silver has never seen, and nothing is inserted. That is right for a current-state table: the account doesn't exist now. The history table (Q42), built from every change, still records that it existed.
Where the guarantee ends: steps 3 and 4 are separate statements. If the job dies between them, the next run re-applies the same window, which is harmless because the MERGE is idempotent. If your engine supports it, wrap steps 3 and 4 in one transaction so the watermark can never move without the data.
| op | account_id | balance | status | source_ts | seq_no | _ingested_at |
|---|---|---|---|---|---|---|
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:01 |
| I | ACC-1 | 100.00 | OPEN | 2025-03-01 09:00 | 101 | 2025-03-01 09:07 |
| U | ACC-1 | 80.00 | OPEN | 2025-03-01 12:00 | 102 | 2025-03-01 12:01 |
| I | ACC-2 | 500.00 | OPEN | 2025-03-01 10:00 | 201 | 2025-03-01 10:02 |
| D | ACC-2 | NULL | NULL | 2025-03-01 15:00 | 202 | 2025-03-01 15:01 |
Engineer check. Grain: one current row per account. Run twice: verified unchanged. NULL: delete events carry NULL attributes that the delete branch ignores; an empty window produces a NULL upper_ts that step 4 absorbs.
#Summary
A medallion pipeline is correct when every layer can be rebuilt and every step can be retried. Bronze makes rebuilding possible: it keeps every delivery as text, with lineage, and never changes. Silver makes retries safe: it removes redeliveries by change identity, orders changes by log position rather than timestamps, applies them with a MERGE that treats a repeated insert as an update, keeps deletes as tombstones, and builds history from every change rather than the collapsed batch. Gold stays correct under late data by recomputing from the earliest affected day, over the full history, with a spine for empty days. And quality checks need the same care as the data: a contract that can evaluate to UNKNOWN loses rows, a count can match while values don't, and a join before a sum multiplies money.
#Concepts at a glance
| Concept | In one line | Why it matters |
|---|---|---|
| Bronze | Append-only, everything as text, with lineage | You can always rebuild the layers above |
| Change identity | (key, log position) | Dedups redeliveries without losing real changes |
| Log order | Order changes by seq_no, not timestamps | Timestamps tie and skew |
| CDC apply | One MERGE: I/U upsert, D soft delete | Idempotent, and deletes stay visible |
| History from CDC | LEAD over every change; deletes close the last version | Intermediate states survive |
| Touched days | Select by arrival, group by event time | Late data reaches the days it belongs to |
| Cumulative recompute | From the earliest affected day, over full history | Running totals stay consistent |
| ROWS vs RANGE | RANGE folds ties into one value | Running balances jump over tied rows |
| Contract routing | COALESCE(rule, FALSE) | Rows with NULLs can't fall between valid and rejected |
| Checksum reconciliation | COUNT plus SUM(hash) per side, then a diff | Cheap global check, targeted detail |
| Pre-aggregate before joining | Bring each side to the output grain | No multiplied balances |
#Test yourself
Why does a Bronze table store amount as text?
So nothing is ever rejected at the door. A typed column would fail or drop a row with a malformed value, and Bronze must keep exactly what arrived. Typing and validation happen in Silver, where bad rows can be quarantined with a reason.
Two updates for the same account share a source_ts. How do you know which came last?
By the log position, seq_no, which the source assigns in commit order. Timestamps can tie or come from clocks that disagree.
Why can't SCD2 history be built from the batch collapsed to the last change per account?
Collapsing throws away intermediate changes, such as an account that was frozen and unfrozen in the same batch. History needs every change; only the current-state table uses the collapsed batch.
A routing rule is amount <> 0 AND direction IN ('DEBIT', 'CREDIT'). Where does a row with a NULL amount go if you load WHERE rule and reject WHERE NOT rule?
Nowhere. The rule is UNKNOWN for that row, and both filters drop UNKNOWN. Route on COALESCE(rule, FALSE) instead.
A late transaction arrives for 2 March. Which days of a closing-balance table need recomputing?
2 March and every day after it, because a closing balance is cumulative.
#Sources
- Snowflake: COPY INTO, metadata columns such as METADATA$FILENAME, MERGE, PIVOT, GREATEST
- PostgreSQL: window function frames
- DuckDB: reading CSV files, MERGE INTO, window functions
- Databricks: the medallion architecture