Window functions are the boss-level SQL skill. Most QA engineers never learn them. You will. These 6 problems cover the functions that appear in interviews and real work.
Rank customers by total spending. Show ties with the same rank.
SELECT c.first_name, c.last_name,
SUM(o.total_amount) AS total_spent,
RANK() OVER (ORDER BY SUM(o.total_amount) DESC) AS spending_rank
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.customer_id, c.first_name, c.last_name
ORDER BY spending_rank;Find the most expensive product in each category. If there is a tie, pick the one that comes first alphabetically.
SELECT category, product_name, price
FROM (
SELECT category, product_name, price,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY price DESC, product_name ASC
) AS rn
FROM products
) ranked
WHERE rn = 1
ORDER BY price DESC;Show daily revenue with a running total. This is how finance teams track cumulative revenue.
SELECT
order_date,
SUM(total_amount) AS daily_revenue,
SUM(SUM(total_amount)) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
WHERE status = 'completed'
GROUP BY order_date
ORDER BY order_date;For each customer order, show the previous order date and the gap in days. Useful for churn analysis.
SELECT c.first_name, c.last_name,
o.order_id, o.order_date,
LAG(o.order_date) OVER (
PARTITION BY o.customer_id
ORDER BY o.order_date
) AS previous_order_date,
DATEDIFF(
o.order_date,
LAG(o.order_date) OVER (
PARTITION BY o.customer_id
ORDER BY o.order_date
)
) AS days_since_last_order
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.order_date;Show each product revenue as a percentage of its category total. Window functions make this easy.
SELECT p.category, p.product_name,
SUM(oi.quantity * oi.unit_price) AS product_revenue,
SUM(SUM(oi.quantity * oi.unit_price)) OVER (
PARTITION BY p.category
) AS category_total,
ROUND(
SUM(oi.quantity * oi.unit_price) * 100.0 /
SUM(SUM(oi.quantity * oi.unit_price)) OVER (
PARTITION BY p.category
), 1
) AS pct_of_category
FROM products p
JOIN order_items oi ON p.product_id = oi.product_id
GROUP BY p.category, p.product_name
ORDER BY p.category, product_revenue DESC;Find the top 2 revenue-generating products in each category. Use DENSE_RANK to handle ties.
SELECT category, product_name, revenue, rnk
FROM (
SELECT p.category, p.product_name,
SUM(oi.quantity * oi.unit_price) AS revenue,
DENSE_RANK() OVER (
PARTITION BY p.category
ORDER BY SUM(oi.quantity * oi.unit_price) DESC
) AS rnk
FROM products p
JOIN order_items oi ON p.product_id = oi.product_id
GROUP BY p.category, p.product_name
) ranked
WHERE rnk <= 2
ORDER BY category, rnk;ROW_NUMBER gives unique ranks (1,2,3). RANK skips after ties (1,1,3). DENSE_RANK does not skip (1,1,2). Interviewers LOVE asking this difference. Know it cold.
Q: What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
A: All three assign numbers to rows within a partition. ROW_NUMBER always gives unique sequential numbers (1,2,3,4) — even for ties, it picks arbitrarily. RANK gives the same number to ties but SKIPS the next number (1,1,3,4 — no 2). DENSE_RANK gives the same number to ties WITHOUT skipping (1,1,2,3). Use ROW_NUMBER when you need exactly one row per group. Use RANK for leaderboards. Use DENSE_RANK when you need "top N" including all ties.
Key Point: Window functions let you add aggregates to individual rows without collapsing them. RANK, ROW_NUMBER, LAG, and running totals — learn all four patterns.