meal-kit-analytics

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.

20models
100tests
476kboxes modeled
86kweeks skipped

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.

30% 50% 70% 90% 100% M0 M1 M2 M3 M4 M5 M6 M7 M8 M9 M10 M11 M12 mean of 12 cohorts individual cohorts
CohortSizeM1M2 M3M6M9M12
2024-0979192.081.571.259.452.244.4
2024-1091593.780.367.756.248.742.4
2024-1184493.878.971.560.450.644.2
2024-1285693.279.971.357.449.342.8
2025-0187693.680.972.559.750.142.5
2025-0283494.482.273.763.356.148.0
2026-0686593.080.2n/an/an/an/a
2026-0790693.2n/an/an/an/an/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.

0.00x 0.50x 1.00x 1.50x delivered week 1.00x skipped one week 1.02x skipped two weeks 1.15x skipped three weeks 1.27x skipped four or more 1.23x
WeekEventsCancelled within 28 days Risk vs a delivered week
delivered week441,4378.84%1.00x
skipped one week68,7968.98%1.02x
skipped two weeks10,63210.21%1.15x
skipped three weeks1,75411.23%1.27x
skipped four or more34910.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

0% 10% 20% 30% 40% 50% 7.0% 0.2% under 1 month 31.0% 6.1% one to three months 19.4% 12.1% three to six months 22.9% 30.8% six to twelve months 19.7% 50.9% twelve months or more share of customers share of contribution
LastedCustomersShare of customers Share of contributionAvg lifetime value
under 1 month1,3917.0%0.2%$14
one to three months6,14631.0%6.1%$122
three to six months3,84519.4%12.1%$381
six to twelve months4,53522.9%30.8%$824
twelve months or more3,89419.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

$0 $200 $400 $600 box 1: −$6.70 box 2 clears $0 1 5 10 15 20 26 box number

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.

0.0 pts 10.0 pts 20.0 pts 30.0 pts meal quality 19.2 pts delivery 17.4 pts account 4.8 pts billing 2.1 pts
Contact categoryNo contactContactedDelta
meal quality52.97%72.22%+19.25 pts
delivery53.04%70.43%+17.39 pts
account53.66%58.46%+4.80 pts
billing53.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.

0.0% 100.0% 200.0% 300.0% one to two / mo 253.7% two to three / mo 24.4% three or more / mo 20.5%
Boxes per active monthCustomers Margin before shippingShipping Shipping as % of margin
under one box / mo1$0$7n/a
one to two / mo999$2,852$7,233253.7%
two to three / mo1,672$184,262$44,90124.4%
three or more / mo17,139$15,120,375$3,100,02420.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.

LineCatalog itemsShare of boxes List revenueShare of revenue
dinner24100.0%$39,750,91190.9%
lunch821.6%$2,665,3956.1%
breakfast821.6%$1,320,9303.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

14.2swall, 4 threads
6.7sbefore any SQL
47%of the build
124nodes

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:

PhaseShareTime
import dbt.cli.main3.83s
CLI init, adapter, profile1.94s
manifest parse (warm)0.30s
everything dbt actually runs7.47s
artifact writing and teardown0.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:

ThreadsWall, rep 1Wall, rep 2 Node timeImplied “speedup”
4 (current)14.07s13.92s23.9s1.7x
814.23s15.29s44.1s3.0x
1614.94s15.28s58.6s3.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:

OperatorTimemsRows out
WINDOW row_number() dedup1932.0 ms476,840
IE_JOIN the range join67.7 ms144,285
PROJECTION35.2 ms476,840
SEQ_SCAN read raw.orders3.8 ms476,840
HASH_GROUP_BY40.6 ms25

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:

ModelsTestsNode totalWall
staging as views13.27s23.07s36.34s11.03s
stg_orders as a table9.22s16.46s25.68s8.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

ModelBuild timems
fct_orders2,040 ms
stg_orders1,481 ms
fct_subscription_months692 ms
mart_product_mix414 ms
dim_customers398 ms
mart_skip_behavior387 ms
mart_box_sequence_economics333 ms
mart_shipping_economics299 ms
int_subscription_spans295 ms
mart_retention_cohorts292 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:

RunTimeRows processed
full refresh2.02s476,840
incremental0.42s6,815
plus upstream stg_orders rebuild1.36s476,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_id is unique. Empty-string regions collapse to NULL, 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_behavior counts 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.