Business query patterns — period comparisons, customer-level analysis and cohorts
The queries analysts write every week — YoY and MTD comparisons, customer-level metrics, and cohort retention tables.
This lesson builds on: Window functions — ranking, LAG/LEAD, running totals and moving averages.
- Build year-over-year and period-to-date comparisons that respect seasonality
- Create a customer-level table (first order, recency, frequency, monetary value)
- Build a monthly acquisition cohort retention table in SQL
- Interpret retention curves in business terms
Period comparisons
Month-over-month is distorted by seasonality. Year-over-year (YoY) compares a period with the same period a year earlier.
WITH monthly AS (
SELECT strftime('%Y', o.order_date) AS yr, strftime('%m', o.order_date) AS mo,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)) AS revenue
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY 1, 2
)
SELECT mo AS month,
ROUND(SUM(CASE WHEN yr = '2024' THEN revenue END)) AS rev_2024,
ROUND(SUM(CASE WHEN yr = '2025' THEN revenue END)) AS rev_2025,
ROUND(100 * (SUM(CASE WHEN yr = '2025' THEN revenue END)
/ SUM(CASE WHEN yr = '2024' THEN revenue END) - 1), 1) AS yoy_pct
FROM monthly
GROUP BY mo
ORDER BY mo;| month | rev_2024 | rev_2025 | yoy_pct |
|---|---|---|---|
| 01 | 83263 | 79079 | -5 |
| 02 | 55123 | 66487 | 20.6 |
| 03 | 84849 | 91231 | 7.5 |
| 04 | 79378 | 85694 | 8 |
| 05 | 94626 | 91737 | -3.1 |
| 06 | 85966 | 95463 | 11 |
| 07 | 86241 | 99539 | 15.4 |
| 08 | 78649 | 96914 | 23.2 |
| 09 | 72481 | 90775 | 25.2 |
| 10 | 76907 | 79657 | 3.6 |
| 11 | 99771 | 122499 | 22.8 |
| 12 | 120355 | 145452 | 20.9 |
The conditional aggregation pivots years into columns. Every month can now be judged against its seasonal twin.
Period-to-date
"Are we ahead of last year so far?" requires equal-length periods. If today were 15 November 2025, compare 1 Jan–15 Nov 2025 with 1 Jan–15 Nov 2024 — not with the full year 2024.
SELECT strftime('%Y', o.order_date) AS year,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct))) AS ytd_revenue_to_15_nov
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
AND strftime('%m-%d', o.order_date) <= '11-15'
AND strftime('%Y', o.order_date) IN ('2024', '2025')
GROUP BY 1;| year | ytd_revenue_to_15_nov |
|---|---|
| 2024 | 847543 |
| 2025 | 935453 |
Comparing total revenue across years mixes in stores that opened or closed. A like-for-like (LFL) comparison includes only stores trading in both full periods. In Orion Retail, Ankara (opened April 2024) and Baku Airport (closed June 2024) would be excluded from a 2025-vs-2024 LFL. Organisations define LFL rules differently (minimum months open, refurbishments, online) — always state the rule. Module 8 covers LFL in depth.
Customer-level tables
Many analyses start by turning transactions into one row per customer:
WITH order_rev AS (
SELECT o.order_id, o.customer_id, o.order_date,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)) AS revenue
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY o.order_id, o.customer_id, o.order_date
)
SELECT customer_id,
MIN(order_date) AS first_order,
MAX(order_date) AS last_order,
CAST(julianday('2025-12-31') - julianday(MAX(order_date)) AS INT) AS recency_days,
COUNT(*) AS frequency,
ROUND(SUM(revenue), 2) AS monetary
FROM order_rev
GROUP BY customer_id
ORDER BY monetary DESC
LIMIT 6;| customer_id | first_order | last_order | recency_days | frequency | monetary |
|---|---|---|---|---|---|
| 2909 | 2023-01-02 | 2025-12-29 | 2 | 64 | 7536.09 |
| 3680 | 2023-01-16 | 2025-12-31 | 0 | 86 | 7245.31 |
| 3657 | 2023-02-25 | 2025-12-18 | 13 | 54 | 7014.07 |
| 2793 | 2023-02-04 | 2025-12-16 | 15 | 77 | 7003.61 |
| 3853 | 2023-02-15 | 2025-11-09 | 52 | 62 | 6763.11 |
| 1668 | 2023-01-17 | 2025-11-24 | 37 | 60 | 6759.85 |
Recency, frequency and monetary value are the inputs of RFM segmentation (Module 8). Note the analysis date (31 Dec 2025) is fixed and explicit — using "today" would make results change every day.
Cohort analysis
A cohort is a group of customers who share a starting event — here, the month of their first completed order. A cohort table shows what share of each cohort is still active N months later. It answers: are newer customers more or less loyal than older ones?
WITH firsts AS (
SELECT customer_id, MIN(strftime('%Y-%m', order_date)) AS cohort
FROM orders WHERE status = 'completed' GROUP BY customer_id
),
activity AS (
SELECT DISTINCT o.customer_id, f.cohort,
(CAST(strftime('%Y', o.order_date) AS INT) - CAST(substr(f.cohort, 1, 4) AS INT)) * 12
+ (CAST(strftime('%m', o.order_date) AS INT) - CAST(substr(f.cohort, 6, 2) AS INT)) AS month_index
FROM orders o JOIN firsts f ON f.customer_id = o.customer_id
WHERE o.status = 'completed'
),
sizes AS (SELECT cohort, COUNT(*) AS cohort_size FROM firsts GROUP BY cohort)
SELECT a.cohort, s.cohort_size,
ROUND(100.0 * SUM(a.month_index = 1) / s.cohort_size, 1) AS m1_pct,
ROUND(100.0 * SUM(a.month_index = 3) / s.cohort_size, 1) AS m3_pct,
ROUND(100.0 * SUM(a.month_index = 6) / s.cohort_size, 1) AS m6_pct,
ROUND(100.0 * SUM(a.month_index = 12) / s.cohort_size, 1) AS m12_pct
FROM activity a JOIN sizes s ON s.cohort = a.cohort
WHERE a.cohort BETWEEN '2023-01' AND '2023-12'
GROUP BY a.cohort, s.cohort_size
ORDER BY a.cohort;| cohort | cohort_size | m1_pct | m3_pct | m6_pct | m12_pct |
|---|---|---|---|---|---|
| 2023-01 | 615 | 24.1 | 29.1 | 31.1 | 30.1 |
| 2023-02 | 324 | 26.2 | 23.5 | 29.6 | 18.8 |
| 2023-03 | 371 | 26.7 | 27.8 | 24 | 24.3 |
| 2023-04 | 248 | 18.1 | 25 | 25.4 | 19 |
| 2023-05 | 198 | 18.7 | 25.3 | 25.8 | 27.8 |
| 2023-06 | 194 | 18.6 | 14.4 | 26.3 | 24.7 |
| 2023-07 | 161 | 18 | 17.4 | 18.6 | 19.3 |
| 2023-08 | 128 | 13.3 | 24.2 | 17.2 | 12.5 |
| 2023-09 | 101 | 15.8 | 15.8 | 15.8 | 6.9 |
| 2023-10 | 81 | 13.6 | 12.3 | 18.5 | 18.5 |
| 2023-11 | 127 | 24.4 | 10.2 | 14.2 | 21.3 |
| 2023-12 | 117 | 15.4 | 14.5 | 13.7 | 17.9 |
How to read it: m3_pct for the 2023-01 cohort is the percentage of customers who made their first purchase in January 2023 and purchased again in April 2023. Each step:
firsts— each customer's cohort (first purchase month).activity— every month in which each customer bought, expressed as months since their cohort month (month_index).sizes— customers per cohort.- Final — share of each cohort active at month 1, 3, 6, 12. (
SUM(condition)works in SQLite because TRUE is 1; elsewhere useSUM(CASE WHEN … THEN 1 ELSE 0 END).)
The earliest cohorts are larger because they include long-standing customers making their first purchase in the data — a left-censoring effect: customers who signed up before 2023 appear as "new" in January 2023. Professional cohort analysis acknowledges this or uses signup date where available.
Overall repeat rate can stay flat while each new cohort retains worse — masked by a growing base of older loyal customers. Cohorts expose that early.
Practice
Revenue in January was 30% below December. What is the best first interpretation in a retail business?
How many customers made their first completed order in 2025? One number.
In a cohort table, the 2024-03 cohort shows m3 = 12%. What does that mean?
Independent assignment
Using the telecom database, build a cohort table by signup month for 2024 cohorts, showing the share of subscribers still billed 3, 6 and 12 months after signup (use monthly_usage). Write three sentences interpreting the result for the Commercial Director.
Summary
- Use YoY and equal-length period-to-date comparisons in seasonal businesses; LFL removes opening/closing effects.
- Customer-level tables (first/last order, recency, frequency, monetary) are the base for segmentation.
- Cohort tables track retention by starting month; be aware of censoring at the data's edges.