The layer that makes every other layer readable
Two conventions, both boring, both load-bearing. Sources mean a raw table name appears in exactly one file in the whole project, so the thing you do not control is quarantined to one place. Staging means one model per source table, doing four narrow jobs and no joins, so the mapping from what the loader gave you to what your project calls it is one to one and obvious.
Neither convention is a dbt feature. dbt would happily let you write select * from raw.orders in a mart, join four raw tables in a staging model, and call a column dt. The tool does not care. The convention exists because the alternative has been tried at scale, repeatedly, and it produces estates where nobody can safely rename anything. Today you build the version this project actually ships: 8 sources, 8 staging models, and the tests that fail fast at the boundary.
Sources: one place a raw table name appears 7 min live
A source declaration is two things at once: a rename-proof indirection, and a place to hang tests on data you did not produce. Both matter, and the second one is the half people skip.
LiveWhat one yml file actually buys you4 min▶
The declaration is short. _bazaar__sources.yml names one source called bazaar, points it at schema: raw, and lists the eight tables. From then on, every reference in the project is {{ source('bazaar', 'orders') }} and never raw.orders. Three consequences, in ascending order of how much they matter:
- A loader rename is a one-file edit. Fivetran restructures a schema, someone renames
transactionstopayment_attempts, the warehouse team movesrawtolanding. You change the yml. Every model keeps compiling, because no model ever knew the physical name. - Sources become nodes in the graph. Once declared, a source appears in lineage, so "what depends on this raw table" is answerable by walking edges. That is why b4's graph has 32 nodes rather than 23 - sources are in it, and everything is downstream of one.
- You get somewhere to put tests and documentation about data you do not own. This is the real prize, and it is the subject of the next card.
The naming convention for the file itself is worth copying: _bazaar__sources.yml, leading underscore so it sorts to the top of the folder, source name in the middle, double underscore as the separator. Its sibling _bazaar__models.yml holds the staging models' descriptions and tests. Two files, one obvious place to look for each.
LiveTests at the boundary, and why they run first4 min▶
Source tests are ordinary dbt tests attached to a source column instead of a model column, and dbt build runs them before anything downstream is built. Three of them do most of the work in this project:
uniqueonusers.user_id,merchants.merchant_id,products.product_id,carts.cart_id,transactions.txn_id, plususers.emailandtransactions.psp_ref. A duplicated primary key in a source is the single most destructive input a warehouse can receive, because it silently multiplies rows in every join above it.not_nullon those same keys, with one deliberate exception:transactions.psp_refis declareduniqueonly, because the payment provider does not always return a reference on a failed attempt. A null key does not multiply rows, it loses them - which is worse than a duplicate, because a missing row does not look like an error, it looks like a slow week.relationshipsfromproducts.merchant_idtomerchants.merchant_id, and fromorders.user_idtousers.user_id. This is referential integrity the source system may or may not be enforcing, checked on the data you actually received rather than on the ERD somebody drew.
Why at the boundary rather than on the staging models? Because the point of a test is to stop work as early as possible. A duplicate txn_id caught on the source costs one failing test and a skipped subtree. The same duplicate caught in a mart costs a rebuilt star schema, a wrong revenue number, and a conversation with somebody who already presented it. This project runs 100 data tests in total, and the ones at the boundary are the cheapest of them.
One deliberate asymmetry: cart_items and order_items have no source-level column tests. Their grain is a composite - cart plus product, order plus line - and there is no single column to declare unique. That check lives one layer up as a unique_combination test on the staging model, which is the right place for it. Session b7 takes the whole test suite apart.
Self-studySource freshness: the check that answers "is the loader even running"3 min read▶
Every test above answers "is this data correct". Freshness answers a completely different and often more urgent question: "is this data there". A source can be perfectly valid and eleven days stale, and no unique or not_null test will ever notice.
You declare it on the source with a timestamp column dbt can look at:
loaded_at_field- the column that tells dbt when a row arrived. This is the requirement people trip on: freshness needs a load timestamp, and an OLTPcreated_tsis not the same thing. If your loader does not stamp arrival time, freshness cannot be computed and the honest fix is to ask the loader to add the column.warn_afteranderror_after- thresholds in hours or days. Warn when it is late, error when it is broken, and the two numbers are a business decision rather than a technical one.dbt source freshness- the command that checks them. Note that it is its own command:dbt builddoes not run freshness for you.
This project does not enable freshness, and the reason is worth being straight about: the raw data is a fixed 90-day extract loaded by python/load_raw.py, so a freshness check would either always pass trivially or always fail. Teaching it against fake staleness would be theatre. What is real is the shape of the decision, which is why it is named here and why the threshold conversation belongs on your own project.
Freshness is also the seam where this course stops and two others start. Whether the loader ran is monitoring, which is learn-dataops. Why the loader is built the way it is, and how raw got populated at all, is learn-data-engineering. dbt transforms what is already there and has no way to fetch a missing batch.
Staging: four jobs, and no joins 8 min live
One model per source table. Same grain in, same grain out. Four jobs, and the fourth one is a prohibition. Every staging model in this project fits on one screen, and that is a design goal rather than a coincidence.
| The job | What it means | From this project |
|---|---|---|
| 1 · Rename | give every column a name that carries meaning outside its source table, so a column read in a mart needs no context | status becomes order_status; category becomes merchant_category in merchants and product_category in products - because two different things were both called category |
| 2 · Recast | fix types once, at the edge, so no downstream model casts anything and no two models cast it differently | cast(order_ts as timestamp) as ordered_at; cast(joined_date as date) as joined_on; cast(is_guest as boolean) as is_guest - the loader shipped that last one as an integer |
| 3 · Light cleaning | nulls that would silently drop a group, whitespace, casing. Light means reversible and uncontroversial - not business logic | coalesce(decline_reason, 'none') turns 994 nulls out of 1,060 payment attempts into a groupable value; coalesce(city, 'unknown') is defensive and currently changes nothing |
| 4 · No joins | the prohibition. Same grain in, same grain out, every time. If you need another table, you need an intermediate model | stg_bazaar__order_items keeps the merchant_id the source captured at time of sale rather than looking it up from products - which is also the correct answer for a fact grain |
LiveNaming conventions that carry meaning4 min▶
Consistency is the low bar. The useful version is naming that tells you something you would otherwise have to look up:
stg_<source>__<entity>with a double underscore:stg_bazaar__order_items. The double underscore separates source from entity, which matters the day a second source system also has anorderstable.stg_bazaar__ordersandstg_shopify__orderscoexist without argument.*_ator*_tsfor timestamps,*_dateor*_onfor dates. Here:ordered_at,signed_up_at,added_at,attempted_atare timestamps;joined_onandlaunched_onare dates. Reading a mart, you know whether a column has a time component without checking the schema - which is exactly the mistake that produces off-by-one-day reports.is_*for booleans, and only for booleans.is_settled,is_guest,is_approved. Neveris_status, never anis_*column holding'Y'.- Meaning-carrying names over source-faithful ones.
joined_onbeatsjoined_datebecause it reads as a fact about a merchant.order_statusbeatsstatusbecausestatusin a mart is a question rather than a column. - One primary key name per entity, everywhere.
order_idisorder_idin every model that touches it. The moment it becomesorderidin one place, every join in the project needs a human to check it.
Staging is also the only layer where you get this for free. Rename in staging and the whole project above it inherits the good name. Rename in a mart and you have two names for one thing, forever, plus a conversation about which one the dashboard uses.
LiveThe two things staging genuinely earns4 min▶
"Rename, recast, clean, no joins" would make staging pure plumbing. Two additions earn their place, and both are about defining something once instead of everywhere.
- A boolean defined once.
order_status in ('paid', 'refunded') as is_settled. Which statuses count as money is a business rule, and the alternative to writing it here is writing it in every mart, every ad hoc query and every dashboard filter - where it will eventually be written differently. In this dataset that is 920 paid plus 37 refunded, so 957 settled orders out of 985, and every number in this course that says 957 traces back to this one line. A refunded order still represents a sale that happened, which is a decision made in learn-data-modeling and honoured here. - Pre-split additive measures. In
stg_bazaar__transactions:case when status = 'approved' then amount else 0 end as approved_amount, and the same for declined. Plus the flag versions,is_approvedandis_declined, as 1 and 0. It looks redundant. It means every rate downstream has an additive numerator and an additive denominator, so a decline rate can be summed up from hour to day to month and stay correct.
That second point is the one that saves a project. Never store a ratio in a mart. Store the parts and divide at read time, because a ratio cannot be aggregated: the average of daily decline rates is not the monthly decline rate unless every day had identical volume, and no day does. Pre-splitting at staging means no downstream model is ever tempted, because the additive columns are already sitting there. The 6.2% decline rate in this course is computed from 66 declines over 1,060 attempts every single time it appears.
accepted_values is the guard for any column built from a status. This project puts it on txn_status with approved, declined and refunded, and on price_tier and cart_status. Add the same test to order_status and the day the source invents chargeback, the build fails and somebody has to decide whether it is settled - instead of it quietly falling outside the boolean and reducing revenue for reasons nobody can find.
Self-studyModeled attributes: columns that do not exist in the source3 min read▶
price_tier is in stg_bazaar__products and nowhere in raw.products. The source has list_price, a number. The business thinks in bands: budget, mid, premium. Somebody has to convert one into the other, and the only real question is where.
- In the dashboard. Fastest to write, worst outcome. Three dashboards, three sets of thresholds, and a meeting where two people disagree about how many premium products exist. Nobody can find the definition because it is in a filter expression.
- In each mart that needs it. Better, still wrong. The rule is now in two or three models and will be updated in some of them.
- In staging, once. A three-branch
caseexpression, one place, with anaccepted_valuestest asserting the output is exactly budget, mid or premium. Every model above it inherits the definition, and changing the bands is one diff a reviewer can see.
The principle generalises: define a derived attribute as low in the stack as it can correctly live. Low enough that everything above inherits it, and no lower than the layer that has all the information needed to compute it. price_tier needs only list_price, so staging can do it. Something that needs an order and a payment cannot be computed in staging at all, because staging is not allowed to join - so it belongs in intermediate, which is session b5.
Where staging must stop: anything that needs another table, anything that changes the grain, and anything genuinely contested. If two departments disagree about what "premium" means, no case statement fixes that, and putting one in staging just hides the disagreement under a column name. That is a governance conversation, and pretending otherwise is how a warehouse loses trust.
Raw against staged, side by side ★ 14 min · everyone builds
These editors run real DuckDB in your browser with this project already built, so raw and main_staging are both queryable and you can see exactly what the layer did. The first Run downloads the engine (about 8 MB, once), then everything is local.
Diff the columns. Seven in, eight out. Read which names changed, which type changed, and which column is new.
Then diff the rows. One order per status, raw values beside staged values, with the boolean that did not exist upstream.
Watch the cleaning happen. 994 nulls at the boundary, zero after staging, and a value you can group by.
Finally, run the source tests by hand. These are the same checks dbt build runs before any model exists - written as plain SQL so you can see there is no magic.
SELECT 'raw.orders' AS relation, ordinal_position AS pos, column_name, data_type FROM information_schema.columns WHERE table_schema = 'raw' AND table_name = 'orders' UNION ALL SELECT 'main_staging.stg_bazaar__orders', ordinal_position, column_name, data_type FROM information_schema.columns WHERE table_schema = 'main_staging' AND table_name = 'stg_bazaar__orders' ORDER BY relation DESC, pos;
SELECT r.order_id,
r.status AS raw_status,
r.order_ts AS raw_order_ts,
s.order_status AS staged_order_status,
s.ordered_at AS staged_ordered_at,
s.is_settled
FROM raw.orders r
JOIN main_staging.stg_bazaar__orders s USING (order_id)
WHERE r.order_id IN (SELECT min(order_id) FROM raw.orders GROUP BY status)
ORDER BY s.is_settled DESC, r.order_id;
SELECT 'raw.transactions.decline_reason' AS column_at_the_boundary,
count(*) FILTER (WHERE decline_reason IS NULL) AS null_rows,
count(*) AS rows_total
FROM raw.transactions
UNION ALL
SELECT 'stg_bazaar__transactions.decline_reason',
count(*) FILTER (WHERE decline_reason IS NULL),
count(*)
FROM main_staging.stg_bazaar__transactions;
SELECT 'unique · raw.transactions.txn_id' AS test_at_the_boundary,
(SELECT count(*) FROM (SELECT txn_id FROM raw.transactions
GROUP BY txn_id HAVING count(*) > 1)) AS failing_rows
UNION ALL
SELECT 'unique · raw.users.email',
(SELECT count(*) FROM (SELECT email FROM raw.users
GROUP BY email HAVING count(*) > 1))
UNION ALL
SELECT 'not_null · raw.orders.user_id',
(SELECT count(*) FROM raw.orders WHERE user_id IS NULL)
UNION ALL
SELECT 'relationships · raw.products.merchant_id to raw.merchants',
(SELECT count(*) FROM raw.products p
LEFT JOIN raw.merchants m USING (merchant_id)
WHERE m.merchant_id IS NULL);
The last box is the honest demystification of dbt testing. A generic test is a query that must return zero rows, and dbt's contribution is not the SQL - it is that the query is declared next to the column in four words of yml, runs on every build, and stops the graph when it fails. Which is also why "we have tests" and "our tests run" are completely different claims: this project's 100 tests are one line each in a yml file, and the reason they mean anything is that PASS=121 ERROR=0 is the line CI reads. A test suite nobody runs is documentation of good intentions.
Your turn: the two things staging earned ★ 8 min · build your own
Two queries against the staging layer, then one question about your own source system that no query can answer for you.
LiveQ1 · Pre-split measures, and the rate you never store4 min▶
SELECT count(*) AS attempts,
sum(is_approved) AS approvals,
sum(is_declined) AS declines,
round(100.0 * sum(is_declined) / count(*), 1) AS decline_rate_pct,
round(sum(approved_amount), 2) AS approved_amount,
round(sum(declined_amount), 2) AS declined_amount
FROM main_staging.stg_bazaar__transactions;
Every column after the first two is additive, which is the whole point. Add a GROUP BY date_trunc('day', attempted_at) and the daily rates are correct; sum those days back up and the monthly rate is still correct, because you are re-dividing sums rather than averaging averages. Then notice the last two numbers: approved payments total 111,906.48, which is exactly the settled net revenue the order lines produce. Two independent paths through the warehouse agreeing to the cent is what a reconciliation test looks like, and this project has one as a singular test.
LiveQ2 · A column the source does not have4 min▶
SELECT s.price_tier,
count(*) AS products,
round(min(s.list_price), 2) AS cheapest,
round(max(s.list_price), 2) AS dearest,
(SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'raw' AND table_name = 'products'
AND column_name = 'price_tier') AS price_tier_columns_in_the_source
FROM main_staging.stg_bazaar__products s
GROUP BY 1
ORDER BY cheapest;
Now edit the thresholds in your head: move the premium boundary from 80 to 120 and nine products become mid. In this project that is one case expression in one model and every mart follows. In a project where the bands live in three dashboards, it is three tickets and one of them gets missed.
Self-studyQ3 · Audit your own boundary3 min▶
No SQL. Five questions about the source system you actually have. The answers are usually uncomfortable, which is what makes them worth writing down before b4.
- How many places in your estate is a raw table name written? If the answer is more than one file, a loader rename is currently an archaeology project.
- Which source columns would you bet money are unique and not null? Those are your first source tests. The ones you would not bet on are more interesting, because your models are probably already assuming them.
- Does your loader stamp an arrival time? If not, freshness is not available to you, and the fix is a conversation with whoever owns extraction rather than anything you can write in dbt.
- Is there a business rule written in more than one of your models? A settled flag, an active-customer definition, a tier band. That rule wants to be one column in one staging model.
- Is any ratio stored in a table anywhere? A conversion rate, an approval rate, a margin percentage. Find its numerator and denominator and store those instead, before somebody averages it.
Try it yourself - this week ◐ 20-30 min total
- Break a source on purpose: change
schema: rawtoschema: landingin_bazaar__sources.ymland rundbt build. Read how it fails, then fix it in that one line and confirm no model needed touching. - Add a source test that should fail: put
uniqueonorders.user_id, which is a foreign key rather than a key. Watch the build stop at the boundary and read which models get SKIPped. - Write the ninth staging model. Pick any table in your own warehouse, do exactly the four jobs, and stop yourself the moment you want to join something. That urge is the intermediate layer asking to exist, and it is session b5.
- Grep your own project for a business rule written twice - a settled flag, an active-user definition, a tier band. Move it into one staging model and count how many models get shorter.
- List your sources and, for each, whether it has a load timestamp. Bring that list to b7, where the same discipline gets applied to the whole 100-test suite.
Sources covered
This page covers:
source(), declaring raw tables oncePart 1 · this project's 8 tables in one yml, read line by lineunique, not_null, relationships, accepted_values)Part 1 + Demo 1 · each one also written out as plain SQLloaded_at_field, warn_after and error_afterNamed with the requirement stated · not enabled here, because a fixed 90-day extract would make it theatreraw at allOut of scope by design · monitoring is learn-dataops, extraction is learn-data-engineeringThree questions before you go 🎯 ◐ 90 seconds
1 · Why declare all eight raw tables in one sources yml instead of referencing them where they are needed?
dbt would happily let you write raw.orders in a mart. The declaration quarantines the thing you do not control to one file, puts sources in the graph so "what depends on this raw table" is answerable, and gives you somewhere to hang tests on data you did not produce.
2 · Staging models in this project do four things. Which is the prohibition, and why does it matter?
Renaming and recasting are two of the four jobs, so they are required rather than forbidden. The prohibition is joins, and it is what keeps the map from raw to modeled one to one. The moment you want to join, you want an intermediate model, which is session b5.
3 · Your marts need an approval rate per merchant per day. What belongs in the staging model?
A ratio cannot be aggregated: the average of daily rates is not the period rate unless every day had identical volume. Store the parts, divide at read time. Pre-splitting in staging means no downstream model is ever tempted, and this project's 6.2% decline rate is recomputed from 66 over 1,060 every time it appears.