Lineage, Impact Analysis, and Dev vs Prod
Answer what breaks downstream when a source changes, separate dev builds from prod builds, then ship the level capstone unaided.
Lineage is the ref graph read in both directions
The previous lesson walked the graph forwards to decide what to build first. Read the same edges backwards from a changed source and you get impact analysis: the set of everything that would break. Same table, same recursive closure, opposite direction, and it is the question every schema-change email should already have answered.
Stage 1 of 4: Three sources. Ingestion owns these; the transformation layer only reads them.
The four models reachable from raw_payments are stg_payments, fct_payments, mart_finance_daily, and mart_exec_kpis. fct_customer_revenue sits in the same project and is not affected at all. That distinction is the entire value of lineage: it turns "we might break something" into a list.
Look closely at mart_finance_daily. It reads fct_payments, and it also reads stg_payments directly, because it reports how many payments were staged next to how many reached the fact. That makes it reachable from raw_payments by two paths of different lengths, two hops and three, and it is why an impact query has to report each model once at its shortest distance rather than once per path. A closure that lists the same mart twice is a closure nobody will read twice.
Lineage is also the fifth of the five observability pillars, alongside freshness, volume, schema, and distribution. Four of them tell you a table is wrong. Lineage tells you who else is now wrong because of it.
Dev and prod are two targets, not two codebases
The model SQL never changes between environments. What changes is the target: a connection profile that says which schema the compiled DDL writes into. On your branch the tool builds into a personal dev schema; on merge, the scheduled production job builds into the prod schema from the same files.
In this lesson that separation is simulated with a prod_ table prefix, and the discipline it encodes is worth stating plainly: you never fix data by editing a production table. You change the model, build it in dev, run the tests there, and promote by merging. A hotfix typed straight into prod is invisible to the next scheduled run, which will overwrite it with the same wrong answer.
model_runs records what was actually built where and whether it succeeded, which is how you answer two questions no dashboard shows you: which models have never built successfully in prod, and which models are ahead in dev and waiting to be promoted.
The capstone
The Practice below composes everything in Level 8: build order from the graph, the four data tests, per-row quarantine of the rows that fail them, and a script that survives the grader running it twice. It ships with no scaffold and no hints, only the goal state, because that is the shape of the take-home a junior data engineering loop actually assigns.
One design decision inside it is worth flagging before you start. A payment can pass all four data tests and still be absent from the fact, because its order was cancelled and never reached staging. A test answers "is this row valid". A join answers "does this row belong". Do not try to solve the second with the first, and note that the daily mart is the place that difference becomes visible: one day in the seed has a staged payment and no fact row at all.
Common mistake: quarantining into a table that is appended rather than rebuilt. The rejects table grows on every run, the same bad payment appears five times by Friday, and the "new failures today" number is meaningless. Rebuild the rejects table with the rest of the graph.
Interview nuance: "a source column is being renamed, what do you do" is answered lineage-first, in this order: enumerate the downstream closure from the graph, fix every model in it, rebuild in dev, run the tests, then promote. Candidates who start with "I would search the codebase for the column name" are describing the manual version of a query the manifest already answers.
On a real platform this differs. Here the graph is a
model_refstable you query yourself. In a real project the tool serves the same closure from its compiled manifest, anddbt build --select source:shop.raw_payments+builds exactly the models this lesson's Apply returns. A bareraw_payments+would match nothing, because the default selector method looks at models, seeds, snapshots, and tests, and a source has to be named with thesource:method. dbt Fusion extends the closure to column-level lineage, so it narrows to models that read the specific renamed column rather than the whole table, and an open lineage standard such as OpenLineage, or a catalog such as Microsoft Purview or DataHub, stitches graphs across tools. The idea is identical: an edge list, walked transitively.
CREATE TABLE model_runs (
model TEXT,
env TEXT, -- dev | prod
built_at TEXT,
status TEXT -- success | error
);
INSERT INTO model_runs (model, env, built_at, status) VALUES
('stg_orders', 'dev', '2026-06-14 09:00:00', 'success'),
('stg_orders', 'prod', '2026-06-14 02:00:00', 'success'),
('stg_customers', 'dev', '2026-06-13 09:05:00', 'success'),
('stg_customers', 'prod', '2026-06-14 02:05:00', 'success'),
('stg_payments', 'dev', '2026-06-14 09:10:00', 'success'),
('stg_payments', 'prod', '2026-06-14 02:10:00', 'success'),
('fct_customer_revenue', 'dev', '2026-06-14 09:20:00', 'success'),
('fct_customer_revenue', 'prod', '2026-06-14 02:20:00', 'error'),
('fct_payments', 'dev', '2026-06-13 09:25:00', 'success'),
('fct_payments', 'prod', '2026-06-14 02:25:00', 'success'),
('mart_finance_daily', 'dev', '2026-06-13 09:40:00', 'success'),
('mart_finance_daily', 'prod', '2026-06-14 02:40:00', 'success'),
('mart_exec_kpis', 'dev', '2026-06-14 09:45:00', 'success');-- The run-results log: one row per model build per environment.
-- The same model SQL produced both rows; only the target schema differed.
SELECT model, env, built_at, status
FROM model_runs
ORDER BY env, built_at;Apply
Your turn
The task this lesson builds to.
Write a query that returns every model downstream of raw_payments, as (model, depth), nearest first, over model_refs(model, ref_model).
A model that reads raw_payments directly is at depth 1, a model that reads that model is at depth 2, and so on. Report each model once, at its shortest distance from the source. Alias the columns exactly model and depth, and break ties within a depth by model ascending.
This is impact analysis: the exact list you would put in the email before renaming a column in raw_payments. Models that are not reachable from it must not appear.
4 hints and 1 automated check are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, plus 2 bonus drills.
Write a script that builds the production tables for the payments graph from raw_customers, raw_orders, and raw_payments, publishing only rows that pass the four data tests, quarantining the rest, and surviving a rerun. The grader runs your script twice.
Goal state, seven tables:
prod_stg_orders(order_id, customer_id, status, amount_usd, ordered_at): every non-cancelledraw_ordersrow.prod_stg_customers(customer_id, name, region): everyraw_customersrow.prod_payments_rejects(payment_id, order_id, reason): one row per offendingpayment_idand reason, withreasonexactly'duplicate_payment_id'when thepayment_idrepeats,'null_amount_usd'whenamount_usdis NULL,'invalid_method'whenmethodis outside('card', 'cash', 'transfer'), and'missing_order'when theorder_idis absent fromraw_orders.prod_stg_payments(payment_id, order_id, method, amount_usd, paid_at): everyraw_paymentsrow whosepayment_idis not in the rejects.prod_fct_payments(payment_id, order_id, customer_id, method, amount_usd, paid_at):prod_stg_paymentsjoined toprod_stg_orders, carrying the order'scustomer_id.prod_mart_finance_daily(paid_on, staged_payments, payments, revenue_usd): one row perpaid_atvalue inprod_stg_payments, wherestaged_paymentscounts the payments staged that day,paymentscounts how many of them reachedprod_fct_payments, andrevenue_usdisROUND(SUM(amount_usd), 2)over those fact rows. A day on which nothing reached the fact still gets a row, with0and0.0.prod_mart_exec_kpis(region, customers, payments, revenue_usd): one row per region that has at least one row inprod_fct_payments, joiningprod_fct_paymentstoprod_stg_customersfor the region, wherecustomersisCOUNT(DISTINCT prod_fct_payments.customer_id)(customers who actually paid, not customers in the region),paymentsis the number ofprod_fct_paymentsrows, andrevenue_usdisROUND(SUM(amount_usd), 2).
Build them in an order the dependencies allow.
10 automated checks are waiting in the workspace.