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,PIVOTandrange(). - 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.BETWEENis 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 inrevenue::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.
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:
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
| month | cafe | subscription | web |
|---|---|---|---|
| 2026-01 | 42,940 | 10,618 | 19,549 |
| 2026-02 | 38,529 | 11,118 | 17,749 |
| 2026-03 | 43,726 | 12,070 | 19,926 |
| 2026-04 | 44,702 | 12,536 | 20,937 |
| 2026-05 | 52,107 | 13,126 | 20,894 |
| 2026-06 | 50,196 | 13,564 | 21,797 |
| 2026-07 | 52,567 | 14,518 | 22,669 |
| 2026-08 | 55,029 | 15,110 | 24,157 |
| 2026-09 | 49,120 | 15,152 | 23,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.
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
| month | per_saturday | per_weekday |
|---|---|---|
| 2025-06 | 205 | 131 |
| 2025-07 | 205 | 134 |
| 2025-08 | 208 | 134 |
| 2025-09 | 198 | 135 |
| 2026-06 | 226 | 149 |
| 2026-07 | 227 | 159 |
| 2026-08 | 242 | 158 |
| 2026-09 | 211 | 153 |
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:
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
| hour | sept_2025 | sept_2026 | change_pct |
|---|---|---|---|
| 7 | 40 | 39.8 | -1 |
| 8 | 49.5 | 46.8 | -5 |
| 9 | 38.3 | 48.8 | 27 |
| 10 | 6.5 | 6.5 | 0 |
| 11 | 14.3 | 13.5 | -6 |
| 12 | 21 | 24.8 | 18 |
| 13 | 12.8 | 15 | 17 |
| 14 | 5 | 5.5 | 10 |
| 15 | 5.5 | 4.3 | -22 |
| 16 | 5.3 | 6.5 | 23 |
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.
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
| day | revenue | avg_7_days | month_to_date |
|---|---|---|---|
| 2026-09-21 | 1,495 | 1,553 | 34,555 |
| 2026-09-22 | 1,426 | 1,588 | 35,981 |
| 2026-09-23 | 1,739 | 1,629 | 37,720 |
| 2026-09-24 | 1,351 | 1,613 | 39,070 |
| 2026-09-25 | 1,358 | 1,607 | 40,428 |
| 2026-09-26 | 2,138 | 1,597 | 42,566 |
| 2026-09-27 | 2,057 | 1,652 | 44,624 |
| 2026-09-28 | 1,318 | 1,627 | 45,941 |
| 2026-09-29 | 1,512 | 1,639 | 47,453 |
| 2026-09-30 | 1,667 | 1,629 | 49,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:
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
| quarter | item | sold |
|---|---|---|
| 1 | Almond Croissant | 4,166 |
| 1 | Latte | 3,935 |
| 1 | Flat White | 2,908 |
| 2 | Latte | 4,508 |
| 2 | Almond Croissant | 4,001 |
| 2 | Flat White | 3,386 |
| 3 | Latte | 4,636 |
| 3 | Cold Brew | 3,601 |
| 3 | Flat White | 3,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.”
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
| cohort | subscribers | month_1 | month_3 | month_6 | month_12 |
|---|---|---|---|---|---|
| 2025-01 | 40 | 88 | 80 | 65 | 43 |
| 2025-02 | 35 | 91 | 80 | 63 | 46 |
| 2025-03 | 42 | 90 | 83 | 67 | 52 |
| 2025-04 | 40 | 85 | 70 | 60 | 40 |
| 2025-05 | 40 | 88 | 75 | 63 | 48 |
| 2025-06 | 43 | 84 | 72 | 67 | 49 |
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():
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
| month | active_at_start | churned | churn_pct | mrr_at_end |
|---|---|---|---|---|
| 2026-01 | 460 | 38 | 8.3 | 10,654 |
| 2026-02 | 483 | 35 | 7.2 | 11,222 |
| 2026-03 | 506 | 26 | 5.1 | 12,124 |
| 2026-04 | 546 | 31 | 5.7 | 12,600 |
| 2026-05 | 567 | 39 | 6.9 | 13,212 |
| 2026-06 | 594 | 38 | 6.4 | 13,668 |
| 2026-07 | 617 | 29 | 4.7 | 14,636 |
| 2026-08 | 663 | 45 | 6.8 | 15,196 |
| 2026-09 | 684 | 41 | 6 | 15,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.
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_cafe | visits_a_month_before | visits_a_month_after | visiting_more | visiting_less | no_change |
|---|---|---|---|---|---|
| 96 | 0.32 | 0.35 | 42 | 29 | 25 |
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:
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
| bean | bags_sold | revenue | bean_cost_per_bag | margin | margin_per_bag |
|---|---|---|---|---|---|
| Mallard Blend | 14,947 | 269,046 | 3.2 | 221,251.19 | 14.8 |
| Heron Ethiopia | 7,903 | 165,963 | 4.56 | 129,925.32 | 16.44 |
| Lily Pad Decaf | 4,419 | 79,542 | 4.01 | 61,802.11 | 13.99 |
| Morning Mist Guatemala | 2,721 | 51,699 | 3.72 | 41,566.51 | 15.28 |
| Kingfisher Kenya | 2,297 | 50,534 | 5.04 | 38,957.12 | 16.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 shape | Pattern | DuckDB tools |
|---|---|---|
| How much, by month and by kind? | Group by time, pivot | date_trunc, PIVOT |
| Is it growing, or is it the season? | Compare like with like, per day | FILTER, count(DISTINCT …) |
| What is the trend under the noise? | Window frames | avg() OVER (ROWS …) |
| What are the top N of each? | Rank within a group | row_number(), QUALIFY |
| Do people stay? | Cohorts with fair checkpoints | date_trunc, FILTER |
| What was active each month? | A calendar spine | range(), CROSS JOIN |
| Did a change change behavior? | Before and after, same people | FILTER over date windows |
| Which earns most? | Totals and rates, side by side | a 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
- PIVOT — DuckDB documentation
- QUALIFY — filtering on window functions
- Window functions — frames, partitions and ranking
- FILTER clause — conditional aggregates
- Friendly SQL — GROUP BY ALL, SUMMARIZE, column aliases and more
- range() — generating series of numbers, dates and timestamps