A dbt project modeling a weekly plant-based meal delivery business end to end, from raw landing tables through to marts a BI tool can point at. 477,000 boxes, 86,000 skipped weeks, 20,000 customers, 24 months.
Shipping is free on every plan and the acquisition offer takes 40% off the first order, which leaves the opening box at minus $6.70 of contribution. A customer is not worth anything until their second delivery, and 95.2% of them get there. See the payback curve →
The longest-tenured 19.7% of customers produce 50.9% of all contribution. The two shortest bands are 38.0% of customers and 6.3% of it. See the distribution →
$3,152,165 absorbed across every box, with nothing collected against it, against $15,307,489 of margin earned on the food. It is not the heavy users who make it expensive: shipping runs 254% of margin on the lightest band and 20.5% on the heaviest. See the cost →
A three-week skip streak raises the chance of cancelling in the next 28 days by 1.27x against a normal delivered week. Real, and smaller than the intuition that a quiet customer is a lost one. See the streaks →
A meal-quality complaint moves the cancel rate +19.2 points and a delivery complaint +17.4. A billing question moves it +2.1 points. See the comparison →
What is being modeled
A weekly plant-based meal delivery business. A box ships each week unless the customer skips, skips are free, cancellation happens at any time, and shipping is free on every plan. There is no membership tier and no delivery fee, so the unit of engagement is the week and the largest uncapped cost is the shipping the business absorbs on every box it sends.
mart_retention_cohorts
Percentage of each signup cohort still active. Read a row across to see one month's signups decay. Read a column down to see whether the business is getting better at keeping people.
| Cohort | Size | M1 | M2 | M3 | M6 | M9 | M12 |
|---|---|---|---|---|---|---|---|
| 2024-09 | 791 | 92.0 | 81.5 | 71.2 | 59.4 | 52.2 | 44.4 |
| 2024-10 | 915 | 93.7 | 80.3 | 67.7 | 56.2 | 48.7 | 42.4 |
| 2024-11 | 844 | 93.8 | 78.9 | 71.5 | 60.4 | 50.6 | 44.2 |
| 2024-12 | 856 | 93.2 | 79.9 | 71.3 | 57.4 | 49.3 | 42.8 |
| 2025-01 | 876 | 93.6 | 80.9 | 72.5 | 59.7 | 50.1 | 42.5 |
| 2025-02 | 834 | 94.4 | 82.2 | 73.7 | 63.3 | 56.1 | 48.0 |
| 2026-06 | 865 | 93.0 | 80.2 | n/a | n/a | n/a | n/a |
| 2026-07 | 906 | 93.2 | n/a | n/a | n/a | n/a | n/a |
Two ways this normally goes wrong
The denominator moves. Cohort size is fixed at month zero and carried across every cell, so retention always divides by the same number. Recomputing it per cell silently reports “still active among those still observed” instead of “still active out of everyone who joined,” which flatters every chart it touches.
Censored cells get read as churn. The newest cohorts carry
no month-3 number because they have not existed for three months, and the
gaps above are the flag doing its job. An earlier draft of this page filled
them from the raw column and produced a 1.8% month-2 figure sitting beside
a 93.2% month-1, which looks like collapse and is only partial data. Filling
those with zero would look like catastrophic churn when it is missing
observation, so is_fully_observed marks which cells are safe
to compare and the gaps above stay honest.
mart_skip_behavior
Boxes ship weekly and a customer can skip any week for free, so going quiet and leaving are different states. Treating every skip as a loss would send win-back offers to people who were always coming back. Each week a subscription was live counts as an event, and each is followed 28 days forward to see whether a cancellation arrived.
| Week | Events | Cancelled within 28 days | Risk vs a delivered week |
|---|---|---|---|
| delivered week | 441,437 | 8.84% | 1.00x |
| skipped one week | 68,796 | 8.98% | 1.02x |
| skipped two weeks | 10,632 | 10.21% | 1.15x |
| skipped three weeks | 1,754 | 11.23% | 1.27x |
| skipped four or more | 349 | 10.89% | 1.23x |
A weak signal, reported as one
Three quiet weeks raise cancellation risk 1.27x. That is a real effect and a modest one, well below what the support-contact split shows, and the four-or-more bucket sits on only 349 events so its dip below the three-week figure is noise.
Reporting it at its actual size seemed more useful than dressing it up. The operational read is that a skip streak is worth a nudge and is not worth a discount, and the last 28 days of data are excluded because an event there has not had its full chance to be followed by a cancellation.
mart_customer_value · mart_box_sequence_economics
Retention matters because of what sits behind it. Sorting customers by how long they lasted and adding up what each group actually contributed, after food and delivery costs, gives a distribution that is far more lopsided than the headcount suggests.
| Lasted | Customers | Share of customers | Share of contribution | Avg lifetime value |
|---|---|---|---|---|
| under 1 month | 1,391 | 7.0% | 0.2% | $14 |
| one to three months | 6,146 | 31.0% | 6.1% | $122 |
| three to six months | 3,845 | 19.4% | 12.1% | $381 |
| six to twelve months | 4,535 | 22.9% | 30.8% | $824 |
| twelve months or more | 3,894 | 19.7% | 50.9% | $1,588 |
The two shortest bands are 38.0% of customers and 6.3% of contribution. The longest band is 19.7% of customers and 50.9%. Any retention work that treats those groups as equally worth saving is spending against the wrong end of the distribution.
The acquisition offer takes $34.37 off the first box, which leaves $1.59 of contribution on it. Every box after earns about $34. So a new customer contributes essentially nothing until their second delivery, and the whole question is whether they get there. 95.6% do.
Where acquisition cost would land
This model has no CAC in it. Inventing one and presenting a conclusion built
on it would be worse than useless, so mart_customer_value reports the
acquisition cost at which each band exactly breaks even. For customers who leave inside a month that number is $14.44, which is about one box. Any realistic acquisition spend makes that band value-destroying,
and it is 7.0% of everyone who ever ordered.
A reader with a real CAC figure can draw their own line across the table. That seemed more useful than picking a number and calling it an answer.
Survivorship
The payback curve describes boxes that were actually delivered at each position, and the population shrinks as it goes: only customers who stayed have a twentieth box. It is a description of what happened and not a forecast of what a new customer will do.
mart_churn_drivers
Split by category, only two categories carry any signal. A customer whose box arrived damaged or inedible is leaving. A customer with a billing question is behaving like everybody else.
Account contact keeps a −5.3 point gap that the correction did not remove. With roughly 300 exposed subscriptions behind it, that sits inside the range chance produces, and it is reported here as an open question instead of a fifth finding.
| Contact category | No contact | Contacted | Delta |
|---|---|---|---|
| meal quality | 52.97% | 72.22% | +19.25 pts |
| delivery | 53.04% | 70.43% | +17.39 pts |
| account | 53.66% | 58.46% | +4.80 pts |
| billing | 53.71% | 55.78% | +2.07 pts |
A confound had to come out first
Exposure was originally defined as a ticket in the 30 days before a subscription ended. For a subscription that lasted ten days, that window reaches back before it existed, so short-lived subscriptions came out systematically unexposed. Short-lived subscriptions are also the ones that churned, which pushed every category toward “contact predicts staying.” The first run showed billing as strongly protective, and that was an artifact.
The fix clamps the window to the subscription start and restricts the comparison to subscriptions with a full 30 days of tenure. Afterwards the the real effects grew and the spurious one collapsed. Delivery moved from +12.4 points to +16.3 and meal quality from +17.8 to +21.7, while billing fell from a spurious −3.8 to −0.4. Real effects strengthening while spurious ones fall away is what removing a bias looks like. Introducing one moves the numbers the other way.
“Customers who ever contacted support churn more” is true of almost any dataset for a boring reason: longer-tenured customers have more opportunity both to contact support and to cancel. Scoping exposure to a fixed 30-day window before the span closes puts churned and active subscriptions on the same footing. Active subscriptions serve as the controls, censored at the analysis end date, so a customer who complained last week and is still here counts as exposed and did not churn.
mart_shipping_economics · mart_product_mix
Shipping is free on every plan, so the business absorbs the delivery cost of every box it sends with nothing collected against it. Across the whole dataset that is $3,152,165 against $15,307,489 of margin earned on the food, or 20.6% of gross margin spent on getting it there.
| Boxes per active month | Customers | Margin before shipping | Shipping | Shipping as % of margin |
|---|---|---|---|---|
| under one box / mo | 1 | $0 | $7 | n/a |
| one to two / mo | 999 | $2,852 | $7,233 | 253.7% |
| two to three / mo | 1,672 | $184,262 | $44,901 | 24.4% |
| three or more / mo | 17,139 | $15,120,375 | $3,100,024 | 20.5% |
This runs the opposite way to the intuition
An uncapped benefit is usually most expensive on the heaviest users, and that was the hypothesis going in. It is wrong here. Shipping costs 254% of margin on customers taking one to two boxes a month and 20.5% on those taking three or more.
The reason is the first box. A customer with very few orders is mostly a customer whose only order was the discounted acquisition box, which carries almost no margin for the shipping to come out of. So free delivery is not a heavy-user problem, it is a second subsidy stacked on top of the acquisition offer, aimed at exactly the people least likely to return.
Three product lines at different price points. Dinner kits are the plan itself; breakfasts and lunches ride along as add-ons.
| Line | Catalog items | Share of boxes | List revenue | Share of revenue |
|---|---|---|---|---|
| dinner | 24 | 100.0% | $39,750,911 | 90.9% |
| lunch | 8 | 21.6% | $2,665,395 | 6.1% |
| breakfast | 8 | 21.6% | $1,320,930 | 3.0% |
Add-ons appear in about a fifth of boxes and carry 9.1% of revenue between them, which is the clearest piece of headroom in the model: the customers are already there and the delivery is already paid for.
What this table does not do
Revenue is attributed at list price, before order-level discounts and credits. Those apply to a whole box and cannot honestly be pushed down to a single item, so this measures menu economics and not realized contribution. Allocating a box-level discount across line items by revenue share would look precise and would mean nothing.
Profiling this project produced two results that contradicted what I would have guessed, and one of them turned into a change worth keeping.
Node time totals 34.8 seconds while the wall clock reads 10.8, so parallelism is returning about 3.2x. The split is the surprising part: 198 data tests cost more than twice what all 20 models cost to build. Anyone tuning this project by reading model SQL would be optimizing the smaller half.
EXPLAIN ANALYZE on the heaviest downstream query, expecting the
range join in fct_subscription_months to dominate. This is the
profile before the fix below, with staging still materialized as
views:
| Operator | Time | ms | Rows out |
|---|---|---|---|
WINDOW row_number() dedup | 1932.0 ms | 476,840 | |
IE_JOIN the range join | 67.7 ms | 144,285 | |
PROJECTION | 35.2 ms | 476,840 | |
SEQ_SCAN read raw.orders | 3.8 ms | 476,840 | |
HASH_GROUP_BY | 40.6 ms | 25 |
The range join costs 68 ms. The row_number() deduplication in
stg_orders cost 1,932 ms, roughly thirty times
the thing I went looking for. Because staging was materialized as a view,
that window re-executed for every model and every test reading
stg_orders, which is most of the project.
Re-running the same profile today returns no WINDOW operator at
all. It is gone because the dedup is now computed once into a table instead
of on every read, which is the clearest confirmation available that the
change did what it was meant to.
The fix was one line
Materializing stg_orders as a table so the window runs once:
| Models | Tests | Node total | Wall | |
|---|---|---|---|---|
| staging as views | 13.27s | 23.07s | 36.34s | 11.03s |
stg_orders as a table | 9.22s | 16.46s | 25.68s | 8.58s |
| change | −31% | −29% | −29% | −22% |
Both halves fell by about a third, which is the confirmation the diagnosis was right: models and tests alike were paying for that window every time they read staging. Both rows were measured on the same dataset, one after the other, with only the materialization changed.
What it cost
The change is not free. Every model that reads stg_orders now
depends on a table that has to be built first, so
dbt run --select fct_orders in isolation reads stale staging
and silently processes nothing. The selector has to become
+fct_orders. As a view that was implicit, and losing it is a
real cost that a build-time number alone would not show.
| Model | Build time | ms |
|---|---|---|
| fct_orders | 2,040 ms | |
| stg_orders | 1,481 ms | |
| fct_subscription_months | 692 ms | |
| mart_product_mix | 414 ms | |
| dim_customers | 398 ms | |
| mart_skip_behavior | 387 ms | |
| mart_box_sequence_economics | 333 ms | |
| mart_shipping_economics | 299 ms | |
| int_subscription_spans | 295 ms | |
| mart_retention_cohorts | 292 ms |
Two models are half of model build time. stg_orders sits
second by design: the cost moved there from everywhere else, which is the
whole idea.
fct_orders is the only incremental model, and after a week of
new deliveries lands it processes 6,815 rows instead of 476,840:
| Run | Time | Rows processed |
|---|---|---|
| full refresh | 2.02s | 476,840 |
| incremental | 0.42s | 6,815 |
| plus upstream stg_orders rebuild | 1.36s | 476,840 |
The model itself is 4.8x faster. Counting the upstream rebuild the honest figure is 1.78s against 2.02s, so the table materialization still wins, by much less than the model timing alone suggests. On a full build or a test run it wins clearly.
Keeping this in proportion
The whole project builds in under eleven seconds. Nothing here needed optimizing, and a 1.9 second operator is not a production incident. What this section demonstrates is the method: measure before guessing, since the bottleneck sat in a staging model, nowhere near the analytical SQL where I expected it, and check what a change costs as well as what it saves.
At warehouse scale the same reasoning has real money attached, because a view re-executed by fifty downstream consumers is billed fifty times.
DuckDB and dbt-core, running on a laptop. No warehouse account, no credentials, no cost. Seven staging models, three intermediate, ten marts, one SCD Type 2 snapshot, six singular tests. Every model, source, snapshot and column carries a description, so the generated data dictionary is complete.
order_id is unique. Empty-string regions collapse to
NULL, because empty and unknown are different things and only
one of them aggregates correctly.
The arithmetic closes: 476,960 raw orders, minus 120 duplicates removed in
staging, minus 80 orphans dropped by the inner join in
fct_orders, gives the 476,760 rows in the fact table.
Uniqueness, not-null and referential integrity say nothing about whether a number is right. A flipped sign on a credit, COGS computed off the wrong base, or a shipping cost that went missing passes every one of them. Six singular tests cover the assertions that generic tests cannot express:
Staging and intermediate are views, because nothing queries them directly and rebuilding costs nothing. Marts are tables, because a dashboard hits them repeatedly and the build cost is better paid once.
fct_orders is incremental with delete+insert.
Append would be faster, but it assumes the incoming slice never overlaps what
is already stored, so a late-arriving delivery would be counted twice.
Deleting matching ids first makes re-running the same window idempotent.
Measured on roughly 500k orders: 1.93s full refresh against 0.47s
incremental.
DuckDB is a profile choice. The architecture does not depend on it, and the
models move to Snowflake or BigQuery by pointing profiles.yml at
a different adapter. Exactly two places would need editing, both commented in
place: the date_diff spelling, and the deduplication in
stg_orders, which could collapse to QUALIFY on
warehouses that support it.
The subscriptions source holds current state only, so a plan change
overwrites the old value and the history disappears. That makes “did
revenue rise because we raised prices or because people upgraded”
unanswerable. A snapshot writes dbt_valid_from and
dbt_valid_to alongside each version, so a point-in-time join can
ask what plan a subscription was on when a particular box shipped.
The data is simulated
Which makes every conclusion here a consequence of its assumptions. Nothing here is a discovered fact about meal kits. The shipping result is most sensitive to COGS as a share of revenue: at 42% the box margin is fat enough to swallow the delivery cost and the finding thins out, while at the 60% used here, closer to published gross margins in the category, it holds. What the model provides is that the sensitivity is explicit and answerable.
Acquisition channel has no effect, and the data says so.
Active rates sit between 43.6% and 45.3% across all five channels, with
lifetime revenue between $1,952 and $2,046. This is an absence in the
simulation. It says nothing about whether channel matters in a real business,
since no channel effect was ever put in. The dimension stays in
dim_customers because a real business would segment on it, and
reporting the flat result seemed more useful than quietly dropping the column.
Also absent: pauses and reactivations, so each customer holds exactly one subscription. That means the singular test asserting no customer holds two overlapping subscriptions passes trivially. It is a correct test of a real business rule that this dataset cannot currently violate.
The exercise here is modeling and testing. Half a million rows is enough to make grain, incrementality and join correctness matter, and small enough to rebuild in twenty seconds.