learn-analytics-engineering-with-phoebe / Builder session 5 of 10
Learn Analytics Engineering with Phoebe · Builder track · Session 5 of 10

Intermediate models: the layer nobody explains well

Staging is forbidden to join. Marts are supposed to select and group, not derive. Everything that falls between those two rules lives in the intermediate layer, and this project has exactly three of them - materialized as nothing at all. Today you learn what belongs there, what ephemeral really costs, and how a warning that used to live in a wiki ("never join the two facts") became a model in the DAG with a test attached.

🟡 Builder track Analytics engineers · analysts · DE Runs in your browser · real DuckDB 45 min
0-3 · Welcome 3-17 · What the layer is for, and the three patterns 17-42 · Build-along: prove ephemeral, then the fan-out guard 42-45 · Q&A
Part 0

Why this layer confuses everyone

Staging and marts explain themselves. Staging is one model per source table, and a mart is something a person queries. Intermediate is defined by what it is not, which is why most projects either skip it (and grow marts full of duplicated derivation) or fill it with anything that felt awkward (and grow a middle layer nobody can name the purpose of).

There is a sharper definition, and this project follows it: an intermediate model exists when a piece of logic must happen exactly once, or when more than one mart needs it. Both tests are about duplication. Neither is about complexity - a two-line model belongs here if it stops a measure being re-derived in three places, and a hundred-line model does not belong here if only one mart will ever read it.

Live - presented in session Self-study - read after class ▶ Live SQL - real DuckDB, editable Sources covered
★ What you walk out with today A one-sentence test for whether something belongs in the intermediate layer, an honest account of ephemeral including how to debug a model that does not exist in the database, and the fan-out guard demonstrated twice - once inflating revenue to 121,003 and once holding it at 111,906.48.
Part 1 · covers community layering conventions, dbt documentation on models

The joins staging cannot do, the maths marts should not repeat 7 min live

Session b3 gave staging one rule and defended it hard: rename, recast, no joins. That rule is what makes eight staging models readable in an afternoon. But an order line on its own cannot tell you what it earned, because the commission rate lives on the merchant and the unit cost lives on the product. Somebody has to do that join. The only question is whether it happens once, in a place with a name, or three times, in three marts, slightly differently.

staging · 8 views One model per source table. Rename, recast, no joins. Forbidden to join, which is what keeps it cheap, disposable and obviously correct. intermediate · 3 The joins staging is not allowed to do, and the roll-ups a mart should not repeat. Ephemeral: inlined as a CTE into whatever refs it. Nothing materialized, nothing to refresh. Money is computed here ONCE - commission, cost, margin - so no mart re-derives it. marts · 12 tables Dimensions, facts and aggregates people query. Select and group, do not derive. If a mart is re-deriving a measure, the intermediate layer is missing a model. The test for whether something belongs in the intermediate layer: more than one mart needs it, or it must happen exactly once. Both are true of commission, and both are true of the fan-out guard - which is why both are models rather than comments.
🔍 Click to zoom - the layer defined by two rules it sits between

This project's three intermediate models are deliberately three different patterns rather than three of the same thing. Learn the patterns and you will recognise them in any project:

ModelThe patternWhat it does once, so nothing downstream repeats itConsumed by
int_order_lines_enriched The enrichment join Joins order lines to orders, products and merchants, then computes commission_amount, cost_amount and margin_amount. Money is derived here and nowhere else. fct_order_items
int_cart_contents The roll-up, plus a documented choice Rolls cart items up to one row per cart, and picks each cart's dominant merchant - the one on its highest-value line. That pick is a modeling decision with a cost, so it is written down. fct_carts
int_payments_per_order The fan-out guard Aggregates 1,060 payment attempts to one row per order, so nothing can join two facts at different grains and double the revenue. the singular test assert_payments_never_fan_out_revenue
LiveThe fan-out guard: a wiki warning turned into a model4 min▶

The hand-written version of this warehouse had a sentence in its documentation: never join fct_order_items to fct_transactions. It was correct, it was prominent, and it was completely unenforceable. An order line is one grain, a payment attempt is another, and an order with a decline followed by a successful retry appears twice on the payments side. Join them on order_id and every line on that order is duplicated - so the revenue you sum is money that was never taken.

In this project that sentence is a model. int_payments_per_order aggregates the attempts down to one row per order first, which is the only shape that can safely be joined to the sales fact. Three things change once the correct shape has a name:

  • It is reusable. Anybody who needs payment context per order refs the model instead of writing their own GROUP BY and getting the aggregation subtly wrong.
  • It is testable. The singular test assert_payments_never_fan_out_revenue joins the fact to this model and fails the build if the total moves by more than a cent. A warning in a wiki cannot fail a build.
  • It is discoverable. The model's own header comment explains why it exists, and it sits in the DAG next to the thing it protects. Documentation you have to go looking for is documentation nobody reads.
The pattern, stated generally Aggregate first, then join. Any time two facts are joined directly, one of them needs an intermediate model that reduces it to the other's grain - or the join needs to go through a conformed dimension instead. This is the headline of the session and you will run both versions in a minute.
LiveComputing money once, and what "once" is worth3 min▶

int_order_lines_enriched computes three derived measures in three lines of SQL:

  • commission_amount is net_amount times the merchant's commission_pct - which means it needs the merchant, which means it needs a join.
  • cost_amount is quantity times the product's unit_cost - which needs the product.
  • margin_amount is net_amount minus that cost.

Put those three lines in the intermediate layer and margin has exactly one definition in the whole estate. Leave them out and each of the twelve marts that wants margin writes its own version, and the versions drift: one rounds before summing, another after; one uses gross_amount because it read the column list too fast. Then somebody asks why the merchant scorecard and the finance dashboard disagree by 40 dollars, and the answer takes two days to find.

The measurable version: settled margin in this project is 46.89% of net revenue, and there is precisely one place in 23 models where that percentage could be made wrong. You will query it shortly.

Self-studyNaming, and the cost written next to a choice3 min read▶

The convention is int_<entity>_<verb>: int_order_lines_enriched, int_cart_contents, int_payments_per_order. The verb matters more than it looks, because it tells a reader what happened to the grain. Enriched means the grain did not change and columns were added. Per order means the grain changed. A reviewer who sees int_payments_per_order in a mart's ref() list knows immediately what shape arrives.

The second convention is harder and matters more: when a model makes a choice, the choice gets documented where the consumer will see it. int_cart_contents credits an entire cart to its dominant merchant, which means a cart containing two merchants' products credits all of its abandoned value to one of them. That is a real limitation, so it is written into _marts__models.yml on fct_carts.merchant_sk as a known limitation - not left in a Slack thread.

In Bazaar's current data every cart happens to contain products from a single merchant, so today the choice costs nothing at all. That is exactly why it must be written down: the day mixed carts appear, the mart quietly changes meaning and nothing in the build will complain. You will measure this in Demo 2, Q2.

Part 2 · covers dbt documentation on materializations and ephemeral

Ephemeral, honestly 5 min live

All three intermediate models are configured materialized: ephemeral, set once for the whole folder in dbt_project.yml. Ephemeral means dbt does not build the model at all. Instead it takes the model's SQL and pastes it as a common table expression into the top of every model that ref()s it. Nothing is created, nothing needs refreshing, and nobody can query a half-finished shape by accident, because there is nothing there to query.

1 · one model file int_order_lines_enriched materialized: ephemeral joins 4 staging models written once, reviewed once 2 · pasted into consumers with __dbt__cte__int_... as ( the whole model SQL ) select ... from lines a copy inside every consumer 3 · what the database has objects named int_% = 0 20 objects, 23 models nothing to keep fresh nothing to misuse by accident The honest cost the SQL is duplicated into every consumer it is not in the database, so debugging is harder read target/compiled/ instead of the warehouse Promote it to a table when more than a handful of models consume it or it is expensive and gets recomputed each time it is one config line: materialized: table Ephemeral is the default for this layer because plumbing should not be queryable. Change it the day the duplication or the recompute costs more than the accident it is preventing.
🔍 Click to zoom - inlined, not built, and what that trade costs
LiveHow to debug a model that is not in the database4 min▶

This is the real cost of ephemeral, and the reason people abandon it after one bad afternoon. Your usual debugging move is to run select * from the_model limit 10 and look. With an ephemeral model there is nothing to select from - the name does not exist in the warehouse at all. Three moves replace it, in order of how often you will need them:

  • dbt compile --select int_order_lines_enriched, then read target/compiled/. The compiled file contains the model's own SQL, fully resolved, ready to paste into any SQL client. This is the answer 90% of the time, and it is the single most useful debugging habit in dbt.
  • Read the consumer's compiled SQL instead. Open target/compiled/bazaar/models/marts/fct_order_items.sql and the intermediate model is right there at the top as with __dbt__cte__int_order_lines_enriched as (...). That is not a paraphrase of what dbt did, it is what dbt handed DuckDB. Session b4's explorer shows the same thing side by side with the Jinja.
  • Flip it to a view for one afternoon. Change the config to materialized: view, rebuild, poke at it with a normal query, then change it back. It is one line, and there is no shame in it.
The habit worth forming When a model is wrong, do not stare at the Jinja. Compile it and read the SQL the warehouse actually received. Roughly half of all "dbt is behaving strangely" reports resolve themselves in the first ten seconds of reading compiled output.
LiveThe other cost: duplicated SQL, and when it starts to hurt3 min▶

An ephemeral model with two consumers is compiled into two queries, each containing a full copy of its SQL. That is fine at two. At eight consumers you have eight copies of the same joins, and three things go wrong at once:

  • The warehouse recomputes it eight times. On DuckDB and 1,434 rows this is unmeasurable. On a billion-row table in a warehouse you pay per query for, it is the whole bill.
  • Compiled queries get enormous. Nested ephemeral models compound, and a 2,000-line compiled query is not something you can read when you need to.
  • Query plans get worse, not better. Optimisers cope well with a table and less well with the same subquery eight times.

The fix is one line. materialized: table in the model's config, and the same SQL now builds an object once and everybody selects from it. Nothing else in the project changes, because every consumer already goes through ref() - which is the point of session b4. The model's name stays, so no downstream SQL is edited.

The trade you are accepting when you promote it: the shape becomes queryable, so somebody will find it and build on it, and now you own a half-finished thing as an interface. That is usually the moment to ask whether it should have been a mart with documentation and tests all along.

Self-studyThe four materializations, and where ephemeral sits3 min read▶

Materialization is dbt's answer to "what object should this SELECT become". There are four in core use, and this project uses three of them:

  • view - a view, rebuilt in milliseconds, always fresh, recomputed on every read. All 8 staging models. Cheap to build, and the cost moves to whoever queries it.
  • table - dropped and recreated in full on every run. All 12 marts. Expensive to build, fast to query, and simple enough that nothing about it can surprise you.
  • ephemeral - not built at all, inlined as a CTE. The 3 intermediate models. Zero storage, zero refresh, and zero ability to inspect it directly.
  • incremental - a table that only processes new or changed rows. Not used here, on purpose. Done properly it needs a dataset far larger than 985 orders, and the interesting parts (merge strategies, late-arriving rows, backfills, warehouse-specific tuning) are out of scope for this course. Named in b2 with the trade-off stated.

The useful mental model: materialization is a performance and safety decision, not a modeling decision. The same SELECT is correct under all four. You choose between them based on how often it is read, how expensive it is to compute, and whether anybody should be able to query it directly - and you can change your mind later with one line, because ref() hides the difference from everything downstream.

Demo 1 of 2

Prove it is not there, then double the revenue ★ 14 min · everyone builds

These editors run real DuckDB in your browser with this project already built from dbt's own compiled SQL. First we prove the ephemeral models genuinely do not exist. Then we do the thing the wiki said not to do, watch revenue inflate by nine thousand dollars, and fix it with the shape int_payments_per_order provides.

Prove the absence. Three intermediate models, zero objects in the database. Twenty objects for twenty-three models.

Find the money they computed. Commission, cost and margin were derived in an ephemeral model and landed in the fact table. One definition, one place.

Break it on purpose. Join the sales fact to the payments fact on order_id. Revenue goes from 111,906.48 to 121,003 and nothing errors.

Fix it with the guard. Aggregate the payments to one row per order first, join that, and revenue comes back to the cent.

SELECT count(*) FILTER (WHERE table_name LIKE 'int_%') AS objects_named_int,
       count(*)                                        AS objects_total
FROM information_schema.tables
WHERE table_schema IN ('main_staging', 'main_marts');
SELECT round(sum(net_amount), 2)        AS net_revenue,
       round(sum(commission_amount), 2) AS commission,
       round(sum(cost_amount), 2)       AS cost_of_goods,
       round(sum(margin_amount), 2)     AS margin,
       round(100.0 * sum(margin_amount) / sum(net_amount), 2) AS margin_pct
FROM main_marts.fct_order_items
WHERE is_settled;
SELECT (SELECT count(*) FROM main_marts.fct_order_items WHERE is_settled)                 AS fact_rows_before,
       count(*)                                                                           AS rows_after_join,
       (SELECT round(sum(net_amount), 2) FROM main_marts.fct_order_items WHERE is_settled) AS revenue_before,
       round(sum(f.net_amount), 2)                                                        AS revenue_after_join
FROM main_marts.fct_order_items f
JOIN main_marts.fct_transactions t ON t.order_id = f.order_id
WHERE f.is_settled;
WITH int_payments_per_order AS (
    SELECT order_id,
           count(*)         AS payment_attempts,
           sum(is_approved) AS approvals
    FROM main_staging.stg_bazaar__transactions
    GROUP BY order_id
)
SELECT count(*)                          AS fact_rows,
       round(sum(f.net_amount), 2)       AS net_revenue,
       round(avg(p.payment_attempts), 3) AS avg_attempts_per_line
FROM main_marts.fct_order_items f
LEFT JOIN int_payments_per_order p ON p.order_id = f.order_id
WHERE f.is_settled;
Real world

Nine thousand dollars of revenue that does not exist, from a join that looked completely reasonable. No error, no warning, no null, no failed test - unless somebody wrote the test. This is the failure mode that makes people distrust a warehouse, and it is almost never caught by the person who introduces it, because the number moves in the direction everyone was hoping for. The intermediate layer is where you put the correct shape so that the reasonable-looking join is not the one that is available; session b7 shows the singular test that fails the build if anybody removes it.

Demo 2 of 2

Your turn: rebuild one, then price a modeling choice ★ 10 min · build your own

An ephemeral model is just SQL that got pasted somewhere. So you can always rebuild it by hand and check that what the fact table carries is what the model computed.

LiveQ1 · Rebuild the ephemeral model and check it agrees4 min▶

Here is the commission line of int_order_lines_enriched, computed from staging by hand and compared line by line with what fct_order_items carries. If the intermediate model is doing what it claims, every one of the 1,434 lines agrees exactly.

WITH int_order_lines_enriched AS (
    SELECT lines.order_id,
           lines.line_no,
           round(lines.net_amount * merchants.commission_pct, 2) AS commission_amount
    FROM main_staging.stg_bazaar__order_items lines
    JOIN main_staging.stg_bazaar__merchants merchants
      ON merchants.merchant_id = lines.merchant_id
)
SELECT count(*) AS lines_checked,
       sum(CASE WHEN i.commission_amount = f.commission_amount THEN 1 ELSE 0 END) AS agreeing
FROM int_order_lines_enriched i
JOIN main_marts.fct_order_items f
  ON f.order_id = i.order_id AND f.line_no = i.line_no;

Try changing round(..., 2) to no rounding at all and run it again. The agreement count collapses, which is a small demonstration of why rounding belongs in one model rather than in every consumer.

LiveQ2 · What does the dominant-merchant choice cost today?4 min▶

int_cart_contents credits each cart to a single merchant. Before defending or attacking that decision, measure it: how many carts actually contain more than one merchant's products?

WITH cart_merchants AS (
    SELECT ci.cart_id, count(DISTINCT p.merchant_id) AS merchants_in_cart
    FROM main_staging.stg_bazaar__cart_items ci
    JOIN main_staging.stg_bazaar__products p ON p.product_id = ci.product_id
    GROUP BY ci.cart_id
)
SELECT merchants_in_cart,
       count(*) AS carts,
       round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct_of_carts
FROM cart_merchants
GROUP BY 1
ORDER BY 1;

So the choice is free right now and will not stay free. When a marketplace adds a shared basket, this model starts attributing one merchant's abandonment to another and the build stays green throughout. The honest response is not to fix it today - it is to have the limitation written on the column, which it is, and to add the test that would notice. Session b7 is where you write that test.

Self-studyQ3 · Which of these three would you promote to a table, and when?3 min▶

No SQL. Think about each model against the two criteria - number of consumers, and cost to compute:

  • int_order_lines_enriched has one consumer today and joins four staging models. At Bazaar's size, ephemeral is obviously right. Scale the order lines to 500 million and the same model is recomputed inside fct_order_items on every single run, and you are paying to re-join four tables for a result you already had. That is the classic promote-to-table case, and at that size it is usually the incremental case too.
  • int_cart_contents has one consumer and does a window function over cart items. Same answer, same reasoning, arriving sooner because window functions are the expensive part.
  • int_payments_per_order is the interesting one. Its only consumer is a test. Should something exist as a model when no mart reads it? Yes, and it is worth being able to argue why: the model is how the correct shape gets a name, a home in the DAG, and a place for the comment explaining the trap. The day an analyst needs payment context per order, the right thing already exists and is already tested. A model that prevents a mistake is earning its keep even with zero downstream marts.

The general rule to take to your own project: promote when more than a handful of models consume it, or when it is expensive enough that recomputing it per consumer shows up on the bill. Until one of those is true, ephemeral keeps your database free of shapes that were never meant to be interfaces.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Sources covered

This page covers:

✓dbt documentation - materializations, and ephemeral in particularPart 2 + Demo 1 · proved by absence in information_schema, with the debugging workflow
✓Community layering conventions - what belongs in the intermediate layer, and namingPart 1 · the two-part test, plus this project's three models as three patterns
◐Kimball & Ross - grain, and why two facts at different grains cannot be joinedDemo 1 · the pattern is implemented here; it is designed in learn-data-modeling
◐Incremental models at scale, and warehouse-specific tuningNamed plainly as out of scope · they need a dataset far larger than 985 orders and the interesting parts are vendor-specific
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What does materialized: ephemeral actually do?

Nothing is created, so nothing needs refreshing and nobody can query a half-finished shape by accident. The proof is in Demo 1: zero objects named int_%, 20 objects for 23 models.

2 · Joining fct_order_items to fct_transactions on order_id takes revenue from 111,906.48 to 121,003. Why?

Aggregate first, then join. int_payments_per_order reduces the attempts to one row per order, which is the only shape that can safely meet the sales fact - and a singular test fails the build if that stops being true.

3 · You need to debug an ephemeral model. What is the first move?

B is impossible - the model is not in the warehouse. Reading compiled output is the fastest debugging move in dbt generally, and with ephemeral models it is the only direct one. You can also read the consumer's compiled SQL and find the model inlined at the top as a CTE.

Builder session 5 cheat sheet · pin this

The testMore than one mart needs it, or it must happen exactly once. Complexity is irrelevant.
EphemeralBuilt as nothing. dbt pastes the SQL as a CTE into every consumer.
What it buysNo storage, no refresh, and nobody can query a half-finished shape.
What it costsDuplicated SQL per consumer, and no object to inspect. Read target/compiled/.
Promote whenMore than a handful of consumers, or expensive to recompute. One config line.
Money onceint_order_lines_enriched derives commission, cost and margin. Margin: 46.89%.
The fan-out guardAggregate first, then join. Joining the two facts inflates 111,906.48 to 121,003.
Document the choiceint_cart_contents picks one merchant per cart. Free today, not forever. Next: b6.