A single blended LTV:CAC number tells you almost nothing – it mixes five-year-old loyalists with last month's discount hunters. Cohorts are where the metric becomes honest. Here is the full build, step by step, with the SQL for each stage.
LTV:CAC asks whether a customer eventually pays back what it cost to acquire them, and by what multiple. Computed across your whole customer base it is almost always misleading: the numerator is dominated by customers acquired years ago at yesterday's prices, while the denominator reflects this quarter's spend. The fix is to hold acquisition period constant – group customers by the month of their first order, then track each group's cumulative value and compare it to what that specific month's acquisition cost.
Cohorting turns one vanity number into an answer to the operative questions: are recent cohorts paying back faster or slower than older ones, and how many months does a cohort take to cover its own CAC?
Three definitions, fixed before any SQL, because every downstream number inherits them:
The prep prerequisites are the usual ones: a deduplicated orders table (PREP_ORDERS, one row per order) and a conformed spend table (PREP_CHANNEL_SPEND, one row per day per channel). If those don't exist yet, start with What is data prep for Snowflake.
One row per customer, stamped with their cohort month. This dataflow is worth materialising on its own – RFM, retention and repurchase models all reuse it.
-- Cohort = month of first ever order
create or replace table MODEL_COHORTS as
select customer_id,
min(order_date) as first_order_date,
date_trunc('month', min(order_date)) as cohort_month
from PREP_ORDERS
group by customer_id;
Join orders back to cohorts, bucket every order into a "month number" (how many months after acquisition it happened), and accumulate. The window function does the LTV curve for you.
-- Cumulative net revenue per cohort, by cohort age
with cohort_orders as (
select c.cohort_month,
datediff('month', c.cohort_month,
date_trunc('month', o.order_date)) as month_number,
o.net_revenue
from PREP_ORDERS o
join MODEL_COHORTS c using (customer_id)
),
by_age as (
select cohort_month,
month_number,
sum(net_revenue) as revenue
from cohort_orders
group by 1, 2
)
select cohort_month,
month_number,
revenue,
sum(revenue) over (
partition by cohort_month
order by month_number
) as cumulative_revenue
from by_age
order by cohort_month, month_number;
Sanity-check the grain before moving on: exactly one row per cohort month per month number, and month_number = 0 revenue should reconcile with first-order revenue for that month. A fan-out here – usually a duplicate customer in the cohort table – inflates every LTV downstream.
Divide cumulative revenue per customer by the cohort's CAC. Cohort size and spend come from the acquisition month alone.
-- LTV:CAC for every cohort at every age
with cohort_size as (
select cohort_month, count(*) as customers
from MODEL_COHORTS
group by 1
),
cohort_spend as (
select date_trunc('month', date_day) as cohort_month,
sum(spend) as acquisition_spend
from PREP_CHANNEL_SPEND
group by 1
)
select l.cohort_month,
l.month_number,
s.customers,
round(sp.acquisition_spend / nullif(s.customers, 0), 2) as cohort_cac,
round(l.cumulative_revenue / nullif(s.customers, 0), 2) as ltv_per_customer,
round((l.cumulative_revenue / nullif(s.customers, 0)) /
nullif(sp.acquisition_spend / nullif(s.customers, 0), 0), 2) as ltv_to_cac
from COHORT_LTV l
join cohort_size s using (cohort_month)
join cohort_spend sp using (cohort_month)
order by l.cohort_month, l.month_number;
Three reads matter more than the rest:
month_number where ltv_to_cac crosses 1.0. If it is drifting later across recent cohorts, acquisition is getting more expensive relative to the customers it buys – whatever the topline CAC chart says.Remember that young cohorts are incomplete by construction: month 6 hasn't happened yet for a customer acquired in May. Cut any cohort-vs-cohort chart at ages every plotted cohort has reached, or recent cohorts will look artificially weak – the same right-censoring trap covered in the retention guide.
Run once, this analysis answers one meeting's question. The value is in making it a model: the three dataflows above (cohorts → cohort LTV → LTV:CAC), scheduled monthly after the orders and spend dataflows refresh, with the grain checks run before anything ships. That takes real orchestration – dependencies, not timers. Teams typically reach for dbt plus Airflow here, which works, but puts an engineer back in the loop of what is fundamentally analyst logic; the trade-off is laid out in Refyner vs dbt / Airflow.
In Refyner, this is the built-in Customer LTV model – lifetime value by acquisition cohort, channel and product, prepped and scheduled on your own Snowflake, with the dataflows open for your analysts to adapt. Building and sampling dataflows is free; scheduled runs are pay-as-you-go – details on the pricing page.
Keep reading:
Margin, if you can. Revenue-based LTV:CAC flatters low-margin catalogues and can make a loss-making cohort look healthy. If landed costs aren't modelled yet, start with net revenue (after discounts and refunds), label the metric clearly as revenue-based, and upgrade the dataflow to contribution margin when cost data lands in Snowflake.
Compare cohorts at fixed ages – LTV:CAC at month 3, 6 and 12 – rather than waiting for a final number. A cohort's ratio only ever grows as it ages, so the honest comparison is same-age against older cohorts. Most eCommerce brands see enough repeat behaviour by month 3 to judge whether a cohort is tracking better or worse than its predecessors.
One row per cohort month per month-number (cohort age). That grain supports the classic cohort triangle, cumulative LTV curves, payback-month calculations and the LTV:CAC ratio at any age – and it's small enough to refresh cheaply on a monthly schedule.
Cohorts, LTV curves and payback – by channel and product – populate from your own Snowflake. Your analysts adapt the dataflows; nobody starts from a blank canvas.