Vineet Ganti
SQL for Data Engineers: Medallion Layers and CDC
All posts

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.

Data EngineeringSQLPracticeCDC

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)
sql
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_idaccount_idamountdirection_file_name_batch_id
T-1ACC-1120.00CREDITtxn_2025-03-01.csvB-2025-03-02
T-2ACC-145.50debittxn_2025-03-01.csvB-2025-03-02
T-2ACC-145.50debittxn_2025-03-02.csvB-2025-03-02
T-3ACC-2n/aDEBITtxn_2025-03-02.csvB-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:

sql
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:

sql
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 amount were DECIMAL, 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's filename = true, Snowflake's METADATA$FILENAME (docs)), _ingested_at and _batch_id let 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.
The sample data before the answer runs.
Step 1 of 2

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

opaccount_idbalancestatussource_tsseq_no_ingested_at
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:01
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:07
UACC-180.00OPEN2025-03-01 12:001022025-03-01 12:01
IACC-2500.00OPEN2025-03-01 10:002012025-03-01 10:02
Sample data as SQL (to run it yourself)
sql
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

opaccount_idbalancestatussource_tsseq_no_ingested_at
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:07
UACC-180.00OPEN2025-03-01 12:001022025-03-01 12:01
IACC-2500.00OPEN2025-03-01 10:002012025-03-01 10:02
Hint

Which columns identify one change, as opposed to one account?

Answer
sql
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.
Copies of the same change are numbered; only the first of each survives.
bronze_account_cdc
opaccount_idbalanceseq_no_ingested_atrn
keptIACC-1100.001012025-03-01 09:071
removedIACC-1100.001012025-03-01 09:012
keptUACC-180.001022025-03-01 12:011
keptIACC-2500.002012025-03-01 10:021
Step 1 of 2

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

opaccount_idbalancestatussource_tsseq_no_ingested_at
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:01
UACC-150.00OPEN2025-03-01 09:00:051022025-03-01 09:01
UACC-180.00OPEN2025-03-01 09:00:051032025-03-01 09:01
IACC-2500.00OPEN2025-03-01 10:002012025-03-01 10:02
Sample data as SQL (to run it yourself)
sql
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

opaccount_idbalancestatussource_tsseq_no_ingested_at
UACC-180.00OPEN2025-03-01 09:00:051032025-03-01 09:01
IACC-2500.00OPEN2025-03-01 10:002012025-03-01 10:02
Hint

Two rows have the same timestamp. Which column still tells you which one committed last?

Answer
sql
SELECT *
FROM cdc_batch
QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id
                           ORDER BY seq_no DESC) = 1
ORDER BY account_id;
  • seq_no is 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_ts can tie or skew. Two commits in the same second share a timestamp, and timestamps from different source nodes can disagree. Ordered by source_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 MERGE that 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).
Ordered by log position, the last change is 103: balance 80.
ordered by seq_no
opaccount_idbalancesource_tsseq_no
removedIACC-1100.002025-03-01 09:00101
removedUACC-150.002025-03-01 09:00:05102
keptUACC-180.002025-03-01 09:00:05103
keptIACC-2500.002025-03-01 10:00201
Step 1 of 2

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_idbalancestatusis_deleted_updated_at
ACC-1100.00OPENFALSE2025-02-28 09:00
ACC-3900.00OPENFALSE2025-02-28 09:00
ACC-5300.00OPENFALSE2025-02-28 09:00

changes

opaccount_idbalancestatussource_tsseq_no_ingested_at
UACC-180.00OPEN2025-03-01 09:00103NULL
IACC-2500.00OPEN2025-03-01 10:00201NULL
DACC-3NULLNULL2025-03-01 11:00301NULL
DACC-4NULLNULL2025-03-01 11:30401NULL
IACC-5300.00FROZEN2025-03-01 12:00501NULL
Sample data as SQL (to run it yourself)
sql
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_idbalancestatusis_deleted_updated_at
ACC-180.00OPENFALSE2025-03-01 09:00
ACC-2500.00OPENFALSE2025-03-01 10:00
ACC-3900.00OPENTRUE2025-03-01 11:00
ACC-5300.00FROZENFALSE2025-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
sql
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 filter WHERE 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 I for 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 D for an unknown key does nothing (ACC-4): no branch handles NOT MATCHED with op = 'D', so it falls through. Deleting what isn't there is a no-op.
  • is_deleted = FALSE on updates revives an account that's re-inserted after a delete.
The sample data before the answer runs.
silver_account
account_idbalancestatusis_deleted_updated_at
ACC-1100.00OPENFALSE2025-02-28 09:00
ACC-3900.00OPENFALSE2025-02-28 09:00
ACC-5300.00OPENFALSE2025-02-28 09:00
changes
opaccount_idbalancestatussource_tsseq_no_ingested_at
UACC-180.00OPEN2025-03-01 09:00103NULL
IACC-2500.00OPEN2025-03-01 10:00201NULL
DACC-3NULLNULL2025-03-01 11:00301NULL
DACC-4NULLNULL2025-03-01 11:30401NULL
IACC-5300.00FROZEN2025-03-01 12:00501NULL
Step 1 of 2

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

opaccount_idbalancestatussource_tsseq_no_ingested_at
IACC-1100.00OPEN2025-03-01 09:00101NULL
UACC-1100.00FROZEN2025-03-01 12:00102NULL
UACC-180.00OPEN2025-03-02 08:00103NULL
IACC-2500.00OPEN2025-03-01 10:00201NULL
DACC-2NULLNULL2025-03-03 09:00202NULL
Sample data as SQL (to run it yourself)
sql
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_idbalancestatuseffective_fromeffective_tois_currentversion
ACC-1100.00OPEN2025-03-01 09:002025-03-01 12:00FALSE1
ACC-1100.00FROZEN2025-03-01 12:002025-03-02 08:00FALSE2
ACC-180.00OPEN2025-03-02 08:009999-12-31 00:00TRUE3
ACC-2500.00OPEN2025-03-01 10:002025-03-03 09:00FALSE1
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
sql
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 by seq_no (Q40) keeps it correct even when timestamps tie.
  • The D row is used, then dropped. It's still in ordered when LEAD runs, so ACC-2's last version ends at the delete time. Then WHERE op IN ('I', 'U') removes the D row 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.

Each change gets the next change's time as its end, including the delete that ends ACC-2's version.
ordered changes
opaccount_idstatusbalancesource_tsnext_ts
keptIACC-1OPEN100.002025-03-01 09:002025-03-01 12:00
keptUACC-1FROZEN100.002025-03-01 12:002025-03-02 08:00
keptUACC-1OPEN80.002025-03-02 08:00NULL
keptIACC-2OPEN500.002025-03-01 10:002025-03-03 09:00
removedDACC-2NULLNULL2025-03-03 09:00NULL
Step 1 of 3

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_idaccount_idtxn_tsamount_ingested_at
T-1ACC-12025-03-01 09:00120.002025-03-01 09:05
T-2ACC-12025-03-04 18:0040.002025-03-04 18:01
T-7ACC-12025-03-02 11:0015.002025-03-05 07:00
T-8ACC-22025-03-05 06:3060.002025-03-05 07:00
T-9ACC-12025-03-05 06:4510.002025-03-05 07:00

etl_control

table_namewatermark_ts
silver_txn2025-03-04 23:59
Sample data as SQL (to run it yourself)
sql
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_idrecompute_fromdays_touched
ACC-12025-03-022025-03-02, 2025-03-05
ACC-22025-03-052025-03-05
Hint

Filter the batch by when it arrived, then group it by when the transactions happened.

Answer
sql
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_from matters 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 uses recompute_from.
The sample data before the answer runs.
bronze_txn
txn_idaccount_idtxn_tsamount_ingested_at
T-1ACC-12025-03-01 09:00120.002025-03-01 09:05
T-2ACC-12025-03-04 18:0040.002025-03-04 18:01
T-7ACC-12025-03-02 11:0015.002025-03-05 07:00
T-8ACC-22025-03-05 06:3060.002025-03-05 07:00
T-9ACC-12025-03-05 06:4510.002025-03-05 07:00
etl_control
table_namewatermark_ts
silver_txn2025-03-04 23:59
Step 1 of 2

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_idaccount_idamountmerchant_ingested_at
T-1ACC-1120.00Payroll2025-03-01 09:05
T-2ACC-145.50Cafe2025-03-01 12:31
T-2ACC-145.50Cafe2025-03-02 08:00
T-3ACC-260.00Grocer2025-03-02 08:16
T-3ACC-266.00Grocer2025-03-02 09:40
Sample data as SQL (to run it yourself)
sql
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_idaccount_idamountmerchant_ingested_athas_conflict
T-1ACC-1120.00Payroll2025-03-01 09:05FALSE
T-2ACC-145.50Cafe2025-03-01 12:31FALSE
T-3ACC-260.00Grocer2025-03-02 08:16TRUE
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
sql
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.
Every delivery with its fingerprint. T-3's two fingerprints differ.
bronze_txn
txn_idamount_ingested_atfingerprint
T-1120.002025-03-01 09:0558650d7b
T-245.502025-03-01 12:31817933e7
T-245.502025-03-02 08:00817933e7
look hereT-360.002025-03-02 08:16265943a4
look hereT-366.002025-03-02 09:4074c24e20
Step 1 of 2

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_dateaccount_idclosing_balancetxn_count
2025-03-01ACC-11120.001
2025-03-02ACC-11120.000
2025-03-03ACC-11120.000
2025-03-04ACC-11080.001

silver_txn

txn_idaccount_idtxn_tssigned_amount
T-1ACC-12025-03-01 09:00120.00
T-2ACC-12025-03-04 18:00-40.00
T-7ACC-12025-03-02 11:00-15.00

affected

account_idrecompute_from
ACC-12025-03-02
Sample data as SQL (to run it yourself)
sql
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_dateaccount_idclosing_balancetxn_count
2025-03-01ACC-11120.001
2025-03-02ACC-11105.001
2025-03-03ACC-11105.000
2025-03-04ACC-11065.001
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
sql
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 affected keeps 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 MERGE on (balance_date, account_id) would also work here, but delete-then-insert is simpler when whole ranges are recomputed.
The sample data before the answer runs.
gold_daily_balance
balance_dateaccount_idclosing_balancetxn_count
2025-03-01ACC-11120.001
2025-03-02ACC-11120.000
2025-03-03ACC-11120.000
2025-03-04ACC-11080.001
silver_txn
txn_idaccount_idtxn_tssigned_amount
T-1ACC-12025-03-01 09:00120.00
T-2ACC-12025-03-04 18:00-40.00
T-7ACC-12025-03-02 11:00-15.00
affected
account_idrecompute_from
ACC-12025-03-02
Step 1 of 2

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_idaccount_idtxn_tssigned_amount
T-1ACC-12025-03-01 09:00120.00
T-2ACC-12025-03-01 12:30-45.50
T-3ACC-12025-03-01 12:30-20.00
T-4ACC-12025-03-02 08:00-10.00
Sample data as SQL (to run it yourself)
sql
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_idtxn_tssigned_amountrunning_balance
T-12025-03-01 09:00120.001120.00
T-22025-03-01 12:30-45.501074.50
T-32025-03-01 12:30-20.001054.50
T-42025-03-02 08:00-10.001044.50
Hint

With only ORDER BY txn_ts, the default window frame treats rows with the same timestamp as one group.

Answer
sql
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_id adds a tiebreaker, so the order of T-2 and T-3 is the same on every run.
  • ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW accumulates row by row.
  • The trap: when a window has ORDER BY and no explicit frame, the SQL default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE includes every peer (row with the same ORDER BY value) 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.
ORDER BY txn_ts only: the tied rows share one value, the balance after both.
default (RANGE) frame
txn_idtxn_tssigned_amountrunning_balance
T-12025-03-01 09:00120.001120.00
wrongT-22025-03-01 12:30-45.501054.50
T-32025-03-01 12:30-20.001054.50
T-42025-03-02 08:00-10.001044.50
Step 1 of 2

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_idaccount_idtxn_tsamountdirection
T-1ACC-12025-03-01 09:00120.00CREDIT
T-2ACC-12025-03-01 12:3045.50DEBIT
T-3ACC-12025-03-20 08:0020.00DEBIT
T-4ACC-22025-03-05 10:00500.00CREDIT
Sample data as SQL (to run it yourself)
sql
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_idmonthtotal_debittotal_credit
ACC-12025-03-0165.50120.00
ACC-22025-03-010.00500.00
Hint

One SUM(CASE …) per output column.

Answer

In Snowflake:

sql
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:

sql
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 direction into columns: each SUM(CASE …) adds only its kind of row. ELSE 0 makes a missing kind 0 rather than NULL.
  • date_trunc('month', …) buckets timestamps by month.
  • Snowflake's PIVOT is shorter but needs the value list written out, and it returns NULL for a missing kind (docs). The CASE form works on every engine and handles derived buckets, which is why it's the usual choice in pipelines.
The sample data before the answer runs.
silver_txn
txn_idaccount_idtxn_tsamountdirection
T-1ACC-12025-03-01 09:00120.00CREDIT
T-2ACC-12025-03-01 12:3045.50DEBIT
T-3ACC-12025-03-20 08:0020.00DEBIT
T-4ACC-22025-03-05 10:00500.00CREDIT
Step 1 of 2

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_idopened_date
ACC-12025-03-01
ACC-22025-03-01

silver_txn

txn_idaccount_idtxn_ts
T-1ACC-12025-03-01 09:00
T-2ACC-12025-03-03 10:00
T-3ACC-12025-03-12 11:00
T-4ACC-12025-03-29 12:00
T-5ACC-22025-03-10 08:00
Sample data as SQL (to run it yourself)
sql
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_idgap_startgap_endgap_days
ACC-12025-03-042025-03-118
ACC-12025-03-132025-03-2816
ACC-22025-03-012025-03-099
ACC-22025-03-112025-03-3121
Hint

LAG over the active days finds gaps between transactions. Add two fake marker days so the edges become gaps between markers too.

Answer
sql
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, not UNION 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.
Active days plus the two edge markers, with the gap since the previous one.
marks
account_iddkinddays_since_prev
ACC-12025-02-28start markerNULL
ACC-12025-03-01active0
ACC-12025-03-03active1
look hereACC-12025-03-12active8
look hereACC-12025-03-29active16
ACC-12025-04-01end marker2
ACC-22025-02-28start markerNULL
look hereACC-22025-03-10active9
look hereACC-22025-04-01end marker21
Step 1 of 2

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_idaccount_idtxn_ts
T-1ACC-12025-03-01 10:00
T-2ACC-12025-03-01 10:04
T-3ACC-12025-03-01 10:09
T-4ACC-12025-03-01 10:15
T-5ACC-22025-03-01 10:00
T-6ACC-22025-03-01 10:11
T-7ACC-22025-03-01 10:20
Sample data as SQL (to run it yourself)
sql
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_idtxn_idtxn_tstxns_in_window
ACC-1T-32025-03-01 10:093
Hint

For each transaction, count the transactions in the 10 minutes ending at it.

Answer
sql
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, and HAVING COUNT(*) >= 3 keeps 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 in RANGE frames; 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.
The sample data before the answer runs.
silver_txn
txn_idaccount_idtxn_ts
T-1ACC-12025-03-01 10:00
T-2ACC-12025-03-01 10:04
T-3ACC-12025-03-01 10:09
T-4ACC-12025-03-01 10:15
T-5ACC-22025-03-01 10:00
T-6ACC-22025-03-01 10:11
T-7ACC-22025-03-01 10:20
Step 1 of 2

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_idamountdirection
T-1120.00CREDIT
T-20.00DEBIT
T-345.50REFUND
T-4NULLDEBIT
T-530.00NULL
T-612.00DEBIT
Sample data as SQL (to run it yourself)
sql
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

targettxn_idreject_reason
silver_txnT-1NULL
reject_txnT-2ZERO_AMOUNT
reject_txnT-3BAD_DIRECTION
reject_txnT-4MISSING_AMOUNT
reject_txnT-5MISSING_DIRECTION
silver_txnT-6NULL
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
sql
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 rule for Silver and WHERE 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 CHECK constraints 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.
With WHERE rule and WHERE NOT rule, the rows with NULLs evaluate to unknown on both sides.
rule per row
txn_idamountdirectionrule
keptT-1120.00CREDITTRUE
removedT-20.00DEBITFALSE
removedT-345.50REFUNDFALSE
wrongT-4NULLDEBITNULL
wrongT-530.00NULLNULL
keptT-612.00DEBITTRUE
Step 1 of 3

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_idbalancestatusis_deleted
ACC-180.00OPENFALSE
ACC-2500.00OPENFALSE
ACC-3900.00OPENTRUE
ACC-5300.00FROZENFALSE
ACC-760.00OPENFALSE

src_account_snapshot

account_idbalancestatus
ACC-180.00OPEN
ACC-2450.00OPEN
ACC-5300.00FROZEN
ACC-675.00OPEN
Sample data as SQL (to run it yourself)
sql
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_idproblem
ACC-2values differ
ACC-6missing in silver
ACC-7missing in source
Hint

SUM doesn't care about row order, so a sum of row hashes fingerprints a whole table in one scan.

Answer
sql
-- 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 JOIN finds 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, use md5 of a NULL-safe string (Q19).
Both sides have four rows, so a count check passes, but the checksums differ.
aggregate check
siderow_countchecksum
silver432916981022189210000
source437514689815987460000
Step 1 of 2

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_idcustomer_idbalanceis_deleted
ACC-1CUST-11000.00FALSE
ACC-2CUST-1500.00FALSE
ACC-3CUST-2250.00FALSE
ACC-4CUST-2900.00TRUE

silver_txn

txn_idaccount_idtxn_ts
T-1ACC-12025-03-01 09:00
T-2ACC-12025-03-02 10:00
T-3ACC-12025-03-03 11:00
T-4ACC-22025-03-02 12:00
Sample data as SQL (to run it yourself)
sql
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_idlive_accountstotal_balancelast_txn_tstxn_count
CUST-121500.002025-03-03 11:004
CUST-21250.00NULL0
Hint

Each account's balance appears once per transaction after the join. Aggregate each side to the customer before joining them.

Answer
sql
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 JOIN keeps 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.
Join first, then sum: each balance is repeated once per transaction.
joined rows for CUST-1
customer_idaccount_idbalancetxn_id
wrongCUST-1ACC-11000.00T-1
wrongCUST-1ACC-11000.00T-2
wrongCUST-1ACC-11000.00T-3
wrongCUST-1ACC-2500.00T-4
summed after the join
customer_idlive_accountstotal_balance
wrongCUST-123500.00
CUST-21250.00
Step 1 of 2

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_idbalancestatus_batch_id_source_system_record_hash
ACC-180.00OPENB-0305core-banking8b6aae79bd5a879f82b7a53738277cb8
ACC-2500.00OPENB-0301core-banking5c9bbe499ca4de34f59af467d819b7cf
ACC-5300.00FROZENB-0304core-bankingcc6bbfa2b6475fa9a9e7d27cbd6a0d39

src_account_snapshot

account_idbalancestatus
ACC-180.00OPEN
ACC-2450.00OPEN
ACC-5300.00FROZEN
Sample data as SQL (to run it yourself)
sql
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_idlast_written_by_source_systemis_stale
ACC-1B-0305core-bankingFALSE
ACC-2B-0301core-bankingTRUE
ACC-5B-0304core-bankingFALSE
Hint

Recompute the fingerprint from the source with exactly the same recipe, and compare.

Answer
sql
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_id answers "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_hash is 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.0 vs 450.00) makes every row look stale.
The sample data before the answer runs.
silver_account
account_idbalancestatus_batch_id_source_system_record_hash
ACC-180.00OPENB-0305core-banking8b6aae79bd5a879f82b7a53738277cb8
ACC-2500.00OPENB-0301core-banking5c9bbe499ca4de34f59af467d819b7cf
ACC-5300.00FROZENB-0304core-bankingcc6bbfa2b6475fa9a9e7d27cbd6a0d39
src_account_snapshot
account_idbalancestatus
ACC-180.00OPEN
ACC-2450.00OPEN
ACC-5300.00FROZEN
Step 1 of 2

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

opaccount_idbalancestatussource_tsseq_no_ingested_at
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:01
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:07
UACC-180.00OPEN2025-03-01 12:001022025-03-01 12:01
IACC-2500.00OPEN2025-03-01 10:002012025-03-01 10:02
DACC-2NULLNULL2025-03-01 15:002022025-03-01 15:01
Sample data as SQL (to run it yourself)
sql
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_idbalancestatusis_deleted_updated_atwatermark_ts
ACC-180.00OPENFALSE2025-03-01 12:002025-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
sql
-- 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:

StepGuardWhat it prevents
1The window (watermark, upper] is frozen before readingSkipping rows that land mid-run (Q27)
2Dedup on (account_id, seq_no)Applying a redelivered change twice (Q39)
2Last change per account by seq_noA nondeterministic MERGE or a stale final state (Q40)
3MERGE instead of INSERTDuplicate rows; re-applying a change is a no-op (Q41)
4COALESCE(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.

The sample data before the answer runs.
bronze_account_cdc
opaccount_idbalancestatussource_tsseq_no_ingested_at
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:01
IACC-1100.00OPEN2025-03-01 09:001012025-03-01 09:07
UACC-180.00OPEN2025-03-01 12:001022025-03-01 12:01
IACC-2500.00OPEN2025-03-01 10:002012025-03-01 10:02
DACC-2NULLNULL2025-03-01 15:002022025-03-01 15:01
Step 1 of 2

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

ConceptIn one lineWhy it matters
BronzeAppend-only, everything as text, with lineageYou can always rebuild the layers above
Change identity(key, log position)Dedups redeliveries without losing real changes
Log orderOrder changes by seq_no, not timestampsTimestamps tie and skew
CDC applyOne MERGE: I/U upsert, D soft deleteIdempotent, and deletes stay visible
History from CDCLEAD over every change; deletes close the last versionIntermediate states survive
Touched daysSelect by arrival, group by event timeLate data reaches the days it belongs to
Cumulative recomputeFrom the earliest affected day, over full historyRunning totals stay consistent
ROWS vs RANGERANGE folds ties into one valueRunning balances jump over tied rows
Contract routingCOALESCE(rule, FALSE)Rows with NULLs can't fall between valid and rejected
Checksum reconciliationCOUNT plus SUM(hash) per side, then a diffCheap global check, targeted detail
Pre-aggregate before joiningBring each side to the output grainNo 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