Skip to content

The SQL behind every business question

Revenue by week, year over year, cohorts, retention, churn and margin: a DuckDB SQL cookbook for the questions every business asks, with examples you can run.

13 min readDuckHouse

Part 06 · The Pocket Warehouse

A dozen queries.
Most of your questions.

The short version

  • Most business questions are a handful of patterns: group by time, compare like with like, smooth with a window, rank within a group, follow a cohort, count what was active on each day.
  • DuckDB has shortcuts that make each of them shorter: GROUP BY ALL, FILTER, QUALIFY, PIVOT and range().
  • Pondside's three questions, answered: Saturdays are growing year over year, a little more slowly than weekdays; subscribers have not stopped coming to the café; Mallard Blend earns the most in total and Kingfisher Kenya the most per bag.

Most business questions are the same dozen questions in different clothes. Learn the dozen and you can answer nearly anything your warehouse knows.

Part five gave Pondside a model: clean views over the raw syncs, and three marts on top, marts.daily_sales, marts.customers and marts.bean_margins. This part is a cookbook against that model. Every recipe runs on the sample data you can download (see part one), every result shown is the one you will get, and each recipe names the pattern it uses so you can carry it to your own data. Along the way, Pondside's three questions from part one finally get their answers.

Three habits before the first recipe

  • Start from the marts. They have already decided what counts as a sale, which time zone a day belongs to and whether money is in cents. Going back to the raw tables means re-deciding all of that, probably differently.
  • Write dates as half-open ranges. day >= DATE '2026-01-01' AND day < DATE '2026-10-01' includes every moment of September 30 and nothing of October 1, for dates and timestamps alike. BETWEEN is inclusive at both ends, which is right for whole dates and quietly wrong for timestamps.
  • Add money exactly, and round at the end. A floating-point number cannot hold most amounts of cents exactly, so a sum of them is off by a few billionths, and because DuckDB adds in parallel, which few billionths can vary from one run to the next. Usually that vanishes in the rounding; on a total that lands exactly on fifty cents it decides which way the rounding goes. Pondside's model already stores dollars as DECIMAL(18, 2) for this reason. For anything that is not, cast before adding, as in revenue::DECIMAL(18, 2), and the answer is the same every time. (The recipes below cast anyway, so they work on models that made a different choice.)

And before writing anything clever, look. DuckDB's SUMMARIZE profiles every column of a table at once: its type, minimum, maximum, approximate number of distinct values, averages, quartiles and how much is null. It is the fastest way to meet a table you have not seen before.

Meet a table
SUMMARIZE marts.daily_sales;

Revenue by month and channel

The first table in every board deck: months down the side, channels across the top. In most databases that takes a CASE expression per column. In DuckDB it is a PIVOT, which turns the distinct values of one column into columns of their own:

Revenue by month and channel, 2026
PIVOT (
  SELECT strftime(day, '%Y-%m') AS month, channel, revenue::DECIMAL(18, 2) AS revenue
  FROM marts.daily_sales
  WHERE day >= DATE '2026-01-01' AND day < DATE '2026-10-01'
)
ON channel
USING round(sum(revenue))
GROUP BY month
ORDER BY month;

Result, 9 rows

monthcafesubscriptionweb
2026-0142,94010,61819,549
2026-0238,52911,11817,749
2026-0343,72612,07019,926
2026-0444,70212,53620,937
2026-0552,10713,12620,894
2026-0650,19613,56421,797
2026-0752,56714,51822,669
2026-0855,02915,11024,157
2026-0949,12015,15223,844

Read it like a sentence: pivot the rows on channel, using the rounded sum of revenue, grouped by month. The café is the biggest channel and the most seasonal. The subscription is the smallest and the steadiest, growing every single month, which is what recurring revenue is for.

Is the Saturday rush growing, or is it just summer?

This is part one's second question, and it is a trap for anyone comparing this month with last month. A café's summer is busier than its spring, so August beating June says nothing about growth. The fix is the oldest one in analysis: compare like with like. September with September, Saturday with Saturday.

Here are four months either side of the summer, in both years, as orders per Saturday and per weekday. Dividing by the number of days, rather than adding up the month, keeps a month with five Saturdays from looking busier than one with four.

Orders per Saturday and per weekday, summers compared
SELECT
  strftime(sold_on, '%Y-%m') AS month,
  round(count(*) FILTER (WHERE dayofweek(sold_on) = 6)
    / count(DISTINCT sold_on) FILTER (WHERE dayofweek(sold_on) = 6)) AS per_saturday,
  round(count(*) FILTER (WHERE dayofweek(sold_on) BETWEEN 1 AND 5)
    / count(DISTINCT sold_on) FILTER (WHERE dayofweek(sold_on) BETWEEN 1 AND 5)) AS per_weekday
FROM clean.cafe_sales
WHERE month(sold_on) BETWEEN 6 AND 9
GROUP BY ALL
ORDER BY month;

Result, 8 rows

monthper_saturdayper_weekday
2025-06205131
2025-07205134
2025-08208134
2025-09198135
2026-06226149
2026-07227159
2026-08242158
2026-09211153

FILTER is doing the work there. It attaches a condition to one aggregate, so a single pass over the table counts Saturdays and weekdays side by side. (dayofweek numbers days from Sunday as 0, so Saturday is 6 and Monday to Friday are 1 to 5.)

The answer has two parts. Summer is real but small: in 2025, summer Saturdays ran about four percent above September's. Growth is bigger: every month of 2026 beats the same month of 2025, by 7 percent in September and up to 16 percent in August. So yes, the Saturday rush is growing. But the weekdays are growing faster, by 13 to 19 percent, which is the more interesting news: the café is becoming a weekday habit.

To see when on a Saturday the extra orders arrive, put September's Saturdays side by side by hour:

September Saturdays, by hour
SELECT
  hour(sold_at) AS hour,
  round(count(*) FILTER (WHERE year(sold_at) = 2025) / 4, 1) AS sept_2025,
  round(count(*) FILTER (WHERE year(sold_at) = 2026) / 4, 1) AS sept_2026,
  round(100 * (sept_2026 / sept_2025 - 1)) AS change_pct
FROM clean.cafe_sales
WHERE dayofweek(sold_at) = 6 AND month(sold_at) = 9
GROUP BY ALL
ORDER BY hour;

Result, 10 rows

hoursept_2025sept_2026change_pct
74039.8-1
849.546.8-5
938.348.827
106.56.50
1114.313.5-6
122124.818
1312.81517
1455.510
155.54.3-22
165.36.523

Both Septembers have four Saturdays, hence the / 4. The last column uses a DuckDB convenience: a column can refer to another defined earlier in the same SELECT, so change_pct is written in terms of sept_2025 and sept_2026 instead of repeating both counts.

Read it with care. Four Saturdays is a small sample, and a single hour moves by a few orders from one week to the next, so the big swings in the quiet afternoon hours are noise. The broad shape is still worth a look: the 7 and 8 o'clock hours are flat while 9 o'clock and lunch grew. Either the early rush is not growing, or the bar is already making as many drinks as it can at that hour. Data cannot tell those apart; someone standing behind the counter at 8:15 on a Saturday can.

A query tells you where to look. It rarely tells you why.

Window functions: moving averages, running totals, top sellers

Daily numbers are noisy. Saturday is always bigger than Tuesday, so a chart of daily revenue is a saw blade. The cure is a window function: an aggregate computed over a frame of neighboring rows, without collapsing them into one. Two frames cover most needs.

1234567891011126 preceding + current rowrunning total: every row since the partition begancurrent row
Two window frames. A seven-day average looks at the current row and the six before it, and slides; a running total looks at every row since the start of its partition, here the month.
A seven-day average and month-to-date revenue for the café
WITH daily AS (
  SELECT day, revenue::DECIMAL(18, 2) AS revenue
  FROM marts.daily_sales
  WHERE channel = 'cafe'
)
SELECT
  day,
  round(revenue) AS revenue,
  round(avg(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)) AS avg_7_days,
  round(sum(revenue) OVER (PARTITION BY date_trunc('month', day) ORDER BY day)) AS month_to_date
FROM daily
QUALIFY day >= DATE '2026-09-21'
ORDER BY day;

Result, 10 rows

dayrevenueavg_7_daysmonth_to_date
2026-09-211,4951,55334,555
2026-09-221,4261,58835,981
2026-09-231,7391,62937,720
2026-09-241,3511,61339,070
2026-09-251,3581,60740,428
2026-09-262,1381,59742,566
2026-09-272,0571,65244,624
2026-09-281,3181,62745,941
2026-09-291,5121,63947,453
2026-09-301,6671,62949,120

Daily revenue jumps by more than half on the weekend; the seven-day average barely moves. Note the QUALIFY at the end. It filters on the result of window functions, the way HAVING filters on the result of a GROUP BY. Filtering with WHERE instead would run before the windows and starve the first rows of their six preceding days, so the averages on September 21 would be wrong.

Top N per group

“What are the best sellers each quarter?” is the same idea with a ranking window. Number the items within each quarter by how many sold, then keep the top few:

The café's three best sellers, each quarter of 2026
SELECT quarter(sold_on) AS quarter, item, sum(quantity) AS sold
FROM clean.cafe_items
WHERE year(sold_on) = 2026
GROUP BY quarter(sold_on), item
QUALIFY row_number() OVER (PARTITION BY quarter ORDER BY sold DESC) <= 3
ORDER BY quarter, sold DESC;

Result, 9 rows

quarteritemsold
1Almond Croissant4,166
1Latte3,935
1Flat White2,908
2Latte4,508
2Almond Croissant4,001
2Flat White3,386
3Latte4,636
3Cold Brew3,601
3Flat White3,578

Cold brew climbs into the top three in the third quarter and nowhere else, which is the summer showing up in the menu. One small DuckDB wrinkle: QUALIFY cannot yet be combined with GROUP BY ALL, so this query spells its grouping out.

Cohorts: do subscribers stay?

A subscription business lives or dies on retention, and the standard way to see it is a cohort table. Group subscribers by the month they joined (their cohort), then ask what share of each cohort was still subscribed one, three, six and twelve months later. Comparing cohorts with each other, rather than this month with last, separates “we are keeping people longer” from “we signed up more people.”

Pond Club retention by monthly cohort
SELECT
  strftime(date_trunc('month', started_on), '%Y-%m') AS cohort,
  count(*) AS subscribers,
  round(100 * count(*) FILTER (WHERE ended_on IS NULL OR ended_on > started_on + INTERVAL 1 MONTH) / count(*)) AS month_1,
  round(100 * count(*) FILTER (WHERE ended_on IS NULL OR ended_on > started_on + INTERVAL 3 MONTH) / count(*)) AS month_3,
  round(100 * count(*) FILTER (WHERE ended_on IS NULL OR ended_on > started_on + INTERVAL 6 MONTH) / count(*)) AS month_6,
  round(100 * count(*) FILTER (WHERE ended_on IS NULL OR ended_on > started_on + INTERVAL 12 MONTH) / count(*)) AS month_12
FROM clean.subscriptions
WHERE started_on < DATE '2025-07-01'
GROUP BY ALL
ORDER BY cohort;

Result, 6 rows

cohortsubscribersmonth_1month_3month_6month_12
2025-014088806543
2025-023591806346
2025-034290836752
2025-044085706040
2025-054088756348
2025-064384726749
month 1month 3month 6month 122025-0140 joined88%80%65%43%2025-0235 joined91%80%63%46%2025-0342 joined90%83%67%52%2025-0440 joined85%70%60%40%2025-0540 joined88%75%63%48%2025-0643 joined84%72%67%49%
The same cohorts, shaded by how many are still subscribed. Read across a row to see one cohort age; read down a column to compare cohorts at the same age.

The shape is typical of a subscription: the steepest drop is early, and the people who stay past six months tend to keep staying. Roughly one in eight leaves after the first box, and between two in five and a half of every cohort is still subscribed a year in.

Churn and recurring revenue, month by month

Two numbers every subscription business reports: MRR, monthly recurring revenue, the monthly value of every subscription that is active; and churn, the share of subscribers at the start of a month who leave during it. Both need something the subscriptions table does not have: a row for every month, including months in which nothing happened to a given subscriber. That row of months is called a calendar spine, and DuckDB makes one with range():

MRR and churn, 2026
WITH months AS (
  SELECT CAST(range AS DATE) AS month
  FROM range(TIMESTAMP '2026-01-01', TIMESTAMP '2026-10-01', INTERVAL 1 MONTH)
)
SELECT
  strftime(m.month, '%Y-%m') AS month,
  count(*) FILTER (WHERE s.started_on < m.month
    AND (s.ended_on IS NULL OR s.ended_on >= m.month)) AS active_at_start,
  count(*) FILTER (WHERE s.ended_on >= m.month
    AND s.ended_on < m.month + INTERVAL 1 MONTH) AS churned,
  round(100 * churned / active_at_start, 1) AS churn_pct,
  sum(s.monthly_price) FILTER (WHERE s.started_on < m.month + INTERVAL 1 MONTH
    AND (s.ended_on IS NULL OR s.ended_on >= m.month + INTERVAL 1 MONTH)) AS mrr_at_end
FROM months m
CROSS JOIN clean.subscriptions s
GROUP BY m.month
ORDER BY m.month;

Result, 9 rows

monthactive_at_startchurnedchurn_pctmrr_at_end
2026-01460388.310,654
2026-02483357.211,222
2026-03506265.112,124
2026-04546315.712,600
2026-05567396.913,212
2026-06594386.413,668
2026-07617294.714,636
2026-08663456.815,196
2026-0968441615,564

The CROSS JOIN pairs every month with every subscription, and the FILTER clauses decide which pairs count. At Pondside's size that is a few thousand pairs; for a business with a million subscribers you would join on the date range instead. Two definitions are worth stating out loud, because a reader will ask: a subscription with a failed payment (past_due in Stripe) still counts as active here, since it has not been canceled; and MRR is measured at the end of each month.

Pond Club's recurring revenue rose by about half in nine months, while churn wandered between five and eight percent a month. That combination, sign-ups outrunning a steady leak, is what healthy early growth looks like. It is also a reminder that churn compounds: six percent a month means losing about half of a group of subscribers within a year, much as the cohort table showed.

Do subscribers still come to the café?

Part one's first question. The worry is cannibalization: a regular who now gets beans in the post might stop coming in for a flat white. The test is a before and after comparison for the same people: for each subscriber the café recognizes, count their visits in the six months before they subscribed and the six months after.

Café visits before and after subscribing
WITH subscribers AS (
  SELECT email, min(started_on) AS started_on
  FROM clean.subscriptions
  WHERE started_on BETWEEN DATE '2025-07-01' AND DATE '2026-03-31'
  GROUP BY email
),
visits AS (
  SELECT
    s.email,
    count(*) FILTER (WHERE c.sold_on >= s.started_on - INTERVAL 6 MONTH
      AND c.sold_on < s.started_on) AS before,
    count(*) FILTER (WHERE c.sold_on >= s.started_on
      AND c.sold_on < s.started_on + INTERVAL 6 MONTH) AS after
  FROM subscribers s
  JOIN clean.cafe_sales c USING (email)
  GROUP BY s.email
)
SELECT
  count(*) AS subscribers_seen_in_cafe,
  round(avg(before) / 6, 2) AS visits_a_month_before,
  round(avg(after) / 6, 2) AS visits_a_month_after,
  count(*) FILTER (WHERE after > before) AS visiting_more,
  count(*) FILTER (WHERE after < before) AS visiting_less,
  count(*) FILTER (WHERE after = before) AS no_change
FROM visits;

Result

subscribers_seen_in_cafevisits_a_month_beforevisits_a_month_aftervisiting_morevisiting_lessno_change
960.320.35422925

The date range is deliberate. Only people who subscribed between July 2025 and March 2026 have six full months of café history on both sides, since the data starts in January 2025 and ends in September 2026: the same censoring idea as the cohorts.

The answer is reassuring. The 96 subscribers the café can recognize visit about as often after subscribing as before, slightly more if anything, and more of them are visiting more than less. There is no sign that Pond Club is replacing the café. Two honest caveats: most café sales are anonymous, so these are only the regulars Square knows by name, and a before-and-after comparison shows what happened, not why. But “no evidence of cannibalization” from the people you can see is a long way from the spreadsheet guesswork part one started with.

Which beans earn the most?

Part one's third question needed sales from two channels and costs from a spreadsheet. The model already did the joining; the query is short:

Margin after beans, in total and per bag
SELECT
  bean,
  bags_sold,
  revenue,
  bean_cost_per_bag,
  margin,
  round(margin / bags_sold, 2) AS margin_per_bag
FROM marts.bean_margins
ORDER BY margin DESC;

Result, 5 rows

beanbags_soldrevenuebean_cost_per_bagmarginmargin_per_bag
Mallard Blend14,947269,0463.2221,251.1914.8
Heron Ethiopia7,903165,9634.56129,925.3216.44
Lily Pad Decaf4,41979,5424.0161,802.1113.99
Morning Mist Guatemala2,72151,6993.7241,566.5115.28
Kingfisher Kenya2,29750,5345.0438,957.1216.96

Two different answers, both true, and the difference is the useful part. In total, the house blend wins by a mile: Mallard Blend is cheap green coffee sold in volume. Per bag, the order flips: Kingfisher Kenya costs the most to buy but sells for enough more that each bag earns about two dollars more than a bag of Mallard. Ethiopia is close behind.

That changes the decision a roaster faces. Every customer nudged from the house blend to the Kenya is worth about two dollars more a bag, before labor and packaging. Whether that is worth a tasting table at the farmers' market is a business question, but it is now a question with a number attached.

The patterns, on one page

Strip the coffee away and this part used a short list of patterns, which between them answer a surprising share of what any business asks.

Question shapePatternDuckDB tools
How much, by month and by kind?Group by time, pivotdate_trunc, PIVOT
Is it growing, or is it the season?Compare like with like, per dayFILTER, count(DISTINCT …)
What is the trend under the noise?Window framesavg() OVER (ROWS …)
What are the top N of each?Rank within a grouprow_number(), QUALIFY
Do people stay?Cohorts with fair checkpointsdate_trunc, FILTER
What was active each month?A calendar spinerange(), CROSS JOIN
Did a change change behavior?Before and after, same peopleFILTER over date windows
Which earns most?Totals and rates, side by sidea mart built for the question

Every one of those queries was written by hand, by someone who knows SQL and knows the model. Pondside's owner is not that person, and most owners are not. The next part is about the thing that changes that: a standard way for an AI assistant to read a warehouse's schema, write this SQL itself, run it and explain the answer. Part seven: MCP, explained.

Further reading

Launch your first warehouse.

No contracts and no minimums. Pick a size and a region, attach from your terminal, and pause it whenever you like.