What the project answers
The first box loses money
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 →
A quarter of customers carry more than half the business
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 →
Free shipping costs a fifth of gross margin
$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 →
Going quiet is a weak signal, and worth saying so
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 →
Only two support categories predict cancellation
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
How does retention decay by signup cohort?
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.
Skipping is not churning
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
Which customers are worth having?
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.
A quarter of customers carry more than half the business
| 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 first box pays for almost nothing
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
Which support contact precedes cancellation?
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.
Why this is a case-control design
“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
What free shipping costs, and who consumes it
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.
Where the revenue actually comes from
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.
Where the build time actually goes
Profiling this project produced three results that contradicted what I would have guessed. Two turned into changes worth keeping, and the third turned out to be a mistake in how I was reading the numbers.
Nearly half the build runs before the first query
The obvious thing to profile is the SQL. On a project this size that is the
smaller half of the problem. A full dbt build takes 14.2
seconds of wall clock, and dbt reports 7.5 of those as its own elapsed
time. The rest is fixed startup, paid once per invocation whatever you
select:
| Phase | Share | Time |
|---|---|---|
import dbt.cli.main | 3.83s | |
| CLI init, adapter, profile | 1.94s | |
| manifest parse (warm) | 0.30s | |
| everything dbt actually runs | 7.47s | |
| artifact writing and teardown | 0.65s |
Bare Python starts in 0.10s, so that first row is dbt-core's own import
cost and nothing can be done about it from here. The effect is sharpest on
the small selective runs that are supposed to be the fast loop:
dbt build --select stg_customers, one trivial view, takes
7.4 seconds to do 1.0 second of work. It is the strongest
argument in the project for one large build over many narrow ones, and the
reason the staging_only and unit_tests_only
selectors are mostly overhead.
One part was recoverable. dbt sends anonymous usage statistics, and turning
them off in dbt_project.yml took 1.68 seconds
off an otherwise identical build, 18.41s to 16.73s. The cost scales with
node count and is not fixed per invocation, because dbt emits a
tracking event per node: the same switch is worth only 0.7 to 0.9s on
dbt parse or a single-model run.
Tests cost more than models, but no test is slow
Tests were 22.1 seconds of node time against 10.4 for all 20 models, which
reads like a signal to go optimize test SQL. It is not. The median test ran
in 104ms, the fastest in 39ms, and the slowest in 400ms,
against a fixed per-node floor of about 83ms. Roughly three quarters of the
time was overhead between queries, and 138 of the 198 tests were
single-column not_null checks.
Collapsing those into one columns_not_null test per model took
the suite from 198 data tests to 96 and 2.54s off the
build. Together with the telemetry switch above that is 18.41s to 14.19s,
and the two account for the whole difference. The
merged test counts every column in a single pass and returns the offending
column name with its null count, so a failure is more specific than
the 124 tests it replaced, not less. Sources kept their per-column tests,
because their severities differ deliberately: an empty region warns, a
missing customer id errors, and one merged test can only have one severity.
The parallelism number was measuring the wrong thing
This section used to read: node time totals 34.8 seconds while the wall clock reads 10.8, so parallelism is returning about 3.2x. It treats node time as a fixed amount of work being divided between threads. It is not, and running the identical build at three thread counts shows why:
| Threads | Wall, rep 1 | Wall, rep 2 | Node time | Implied “speedup” |
|---|---|---|---|---|
| 4 (current) | 14.07s | 13.92s | 23.9s | 1.7x |
| 8 | 14.23s | 15.29s | 44.1s | 3.0x |
| 16 | 14.94s | 15.28s | 58.6s | 3.9x |
Wall clock does not move. Node time rises almost exactly in proportion to the thread count, because each concurrent query is slowed by its neighbours by about the factor it gains from running beside them. That is what serialisation inside a single embedded DuckDB process looks like, and the ratio can be made to read anything from 1.7x to 3.9x without the build ever getting faster.
Why this one is worth admitting
The old figure was not a measurement, it was two numbers divided. It survived because it was flattering and nothing contradicted it. The check that caught it costs one flag and six builds: if a ratio claims parallelism, changing the thread count should move it. Here it moves the ratio and leaves the clock alone, which is the whole finding.
The practical read is that threads: 4 is already at the
wall-clock floor on this machine and raising it buys nothing at this data
size. On a real warehouse, where each query runs on separate compute, the
same experiment would come out differently — which is the reason to
run it there.
The slowest operator was not in a mart
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 times
| 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.
Incrementality
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 fourteen seconds. Nothing here was a production incident, and a 1.9 second operator on a laptop is not an emergency. 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.
How it is built
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.
Tests run at three layers, each doing a different job
- Source tests warn. Raw genuinely contains 120 duplicate order ids, 140 empty regions, and 80 orders referencing a subscription that does not exist. Those are upstream problems, asserted at warn severity so the build stays green while the defect stays visible on every run. Deleting the test would hide a real problem. Erroring would block a pipeline over something this project cannot fix at the source.
-
Staging repairs. Deduplication happens once, at the layer
that owns grain, so every model downstream may assume
order_idis unique. Empty-string regions collapse toNULL, because empty and unknown are different things and only one of them aggregates correctly. - Staging and mart tests error. Once staging has done its job, uniqueness becomes a hard contract. If it fails, the repair broke.
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.
Keys and enumerations are not enough
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:
- Revenue arithmetic. Six rules on every order: net never exceeds gross, gross revenue and delivery cost both stay positive, COGS never goes negative, and both net revenue and contribution margin reconcile to their components within a cent. Failures return the order id with the rule it broke.
- Retention denominator stability. Cohort size must be identical on every row of a cohort. Recomputing it per period is the one bug that leaves the grain correct, nothing null, and every percentage inside 0 to 100 while making the whole table wrong.
- Fact-to-staging reconciliation. Fact rows equal staging rows holding a valid subscription. The inner join is meant to drop orphans and nothing else, and a duplicated dimension row would fan out and silently add rows instead. The arithmetic above, enforced.
-
No week both skipped and delivered. Skips and deliveries
are the two outcomes of one weekly decision, so they are mutually
exclusive by construction. If they ever coexist,
mart_skip_behaviorcounts the same week twice and every risk multiple it publishes rests on an inflated denominator. Both rows would be individually valid and no key test would notice. - Order totals against line items. Every meal is priced independently, so a box total is the sum of what is in it. That makes an order carrying the wrong number of meals, carrying one twice, or drifting away from its items all detectable, and none of them trips a key test.
- No overlapping subscription spans. A real business rule this dataset cannot currently violate, since each customer holds exactly one subscription. Documented as passing trivially.
Every materialization here was chosen
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: 2.02s full refresh against 0.42s
incremental.
Portability is a profile swap
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.
Slowly changing dimensions
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.
Honest notes
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.
What is modeled into the generator
- Churn hazard highest in the first three tenure months, rising in December.
- Hazard roughly doubles after a delivery or meal-quality complaint.
- A skip streak raises hazard, which is what makes skipping an early signal.
- Skips run about 15% of live weeks, rising to 30% in December.
- Three classes of defect planted deliberately, so the tests have something to catch.
What is absent
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.
Scope
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 thirty seconds.