Claude Sonnet 5.5
Top customer by spend for each month of 2025
WITH monthly_spend AS (
-- Step 1: total spend per customer per month
SELECT
date_trunc('month', o.created_at)::date AS month,
o.customer_id,
SUM(o.amount) AS total_spent
FROM orders o
WHERE o.created_at >= '2025-01-01'
AND o.created_at < '2026-01-01'
GROUP BY date_trunc('month', o.created_at), o.customer_id
),
ranked AS (
-- Step 2: rank customers within each month
SELECT
month,
customer_id,
total_spent,
RANK() OVER (
PARTITION BY month
ORDER BY total_spent DESC
) AS rnk
FROM monthly_spend
)
-- Step 3: keep the #1 customer(s) and attach names
SELECT
r.month,
c.name,
r.total_spent
FROM ranked r
JOIN customers c ON c.id = r.customer_id
WHERE r.rnk = 1
ORDER BY r.month;(Syntax is PostgreSQL. For MySQL, replace the month expression with DATE_FORMAT(o.created_at, '%Y-%m-01'). The rest works as is on MySQL 8+, SQL Server, etc.)
How the window function works
RANK() OVER (PARTITION BY month ORDER BY total_spent DESC)OVER (...)makes this a window function. UnlikeGROUP BY, it doesn't collapse rows. Each row keeps its identity and gets an extra computed column.PARTITION BY monthsplits the rows into independent groups, one per month. The ranking restarts at 1 in each month.ORDER BY total_spent DESCorders rows within each partition, so the biggest spender gets rank 1.RANK()assigns the position. If two customers tie for the highest spend, both get rank 1 (and the next gets rank 3), so both appear in the result.
Choosing the ranking function
| Function | Tie behavior | Use when |
|---|---|---|
RANK() | Ties share a rank, gaps follow (1,1,3) | You want all tied top spenders |
DENSE_RANK() | Ties share a rank, no gaps (1,1,2) | Same for top-1; differs only for top-N |
ROW_NUMBER() | Always unique (1,2,3), ties broken arbitrarily | You want exactly one row per month |
If you want exactly one customer per month, use ROW_NUMBER() with a deterministic tie-breaker:
ROW_NUMBER() OVER (PARTITION BY month ORDER BY total_spent DESC, customer_id)Notes
- Date filter: I used
>= '2025-01-01' AND < '2026-01-01'rather thanBETWEEN ... '2025-12-31', because the latter would miss orders on Dec 31 after midnight ifcreated_atis a timestamp. This form can also use an index oncreated_at. - Why two steps: window functions are evaluated after
GROUP BY, and you can't filter on them inWHEREin the same query level. That's why the rank is computed in a CTE and filtered in the outer query. - Months with no orders won't appear. To include all 12 months, drive the query from
generate_series('2025-01-01'::date, '2025-12-01', '1 month')andLEFT JOINthe ranked results wherernk = 1. - PostgreSQL shortcut:
SELECT DISTINCT ON (month) ... ORDER BY month, total_spent DESCgives one row per month without an explicit window function.