54. Capstone: tune a slow query
The brief
A dashboard shows, for every customer, their total lifetime spend, their most-rented film category, and whether they've rented anything in the last 30 days ("active" vs. "lapsed"). The engineer who wrote it used the most natural-looking approach — three correlated subqueries per customer row, the "once per outer row" cost Module 6's correlated-subquery lesson warned about. Your job: diagnose it the same way, and fix it.
The slow query
SELECT
c.customer_id, c.first_name, c.last_name,
(SELECT sum(amount) FROM payment p WHERE p.customer_id = c.customer_id) AS lifetime_spend,
(
SELECT cat.name
FROM rental r
JOIN inventory i ON i.inventory_id = r.inventory_id
JOIN film f ON f.film_id = i.film_id
JOIN film_category fc ON fc.film_id = f.film_id
JOIN category cat ON cat.category_id = fc.category_id
WHERE r.customer_id = c.customer_id
GROUP BY cat.name
ORDER BY count(*) DESC, cat.name
LIMIT 1
) AS favorite_category,
EXISTS (
SELECT 1 FROM rental r
WHERE r.customer_id = c.customer_id
AND r.rental_date > current_date - 30
) AS is_active
FROM customer c;
Your task
- Measure first (Module 10's EXPLAIN ANALYZE lesson) — run this with
EXPLAIN ANALYZEand note the total execution time and theloops=counts on the subplans. Don't guess; measure. - Diagnose — which of the three subqueries is contributing the
most cost? Are any of them cheaper than they look (hint: think about
what
EXISTS, from Module 6, actually costs versus the other two)? - Rewrite each piece using the set-based techniques this course
built:
GROUP BYfor the aggregate,ROW_NUMBER() OVER (PARTITION BY ...)for favorite category (Module 7), and consider whether theEXISTSreally needs rewriting at all. - Measure again — confirm the rewrite is both correct (identical results) and faster.
Reference solution
WITH customer_spend AS (
SELECT customer_id, sum(amount) AS lifetime_spend
FROM payment
GROUP BY customer_id
),
category_counts AS (
SELECT
r.customer_id, cat.name AS category,
count(*) AS rental_count,
row_number() OVER (PARTITION BY r.customer_id ORDER BY count(*) DESC, cat.name) AS rn
FROM rental r
JOIN inventory i ON i.inventory_id = r.inventory_id
JOIN film f ON f.film_id = i.film_id
JOIN film_category fc ON fc.film_id = f.film_id
JOIN category cat ON cat.category_id = fc.category_id
GROUP BY r.customer_id, cat.name
)
SELECT
c.customer_id, c.first_name, c.last_name,
cs.lifetime_spend,
cc.category AS favorite_category,
EXISTS (
SELECT 1 FROM rental r
WHERE r.customer_id = c.customer_id
AND r.rental_date > current_date - 30
) AS is_active
FROM customer c
LEFT JOIN customer_spend cs ON cs.customer_id = c.customer_id
LEFT JOIN category_counts cc ON cc.customer_id = c.customer_id AND cc.rn = 1;
The EXISTS subquery was deliberately left alone. This is the
diagnostic judgment call Module 10's lessons kept building toward: EXISTS short-circuits on the first matching row and, with
an index on (customer_id, rental_date), is already cheap per customer
— rewriting it into a third CTE would add complexity without a
measurable benefit. Not everything that "runs once per row" is
automatically worth rewriting; the discipline is measuring which
subquery actually dominates the total cost, not reflexively rewriting
all three because one pattern looked suspicious.
A real correctness bug, caught by the verification step itself
The first version of this reference solution did not have ,
cat.name in either ORDER BY count(*) DESC above — and Step 4's
correctness check (comparing every customer's result between both
versions) caught 105 mismatched customers as a result. The cause:
172 customers have a genuine tie for their top rental category
(two or more categories with the identical rental count), and with no
tiebreaker, ORDER BY count(*) DESC / ORDER BY ... count(*) DESC
inside a window function are each free to break that tie in whatever
order is physically convenient — which is not guaranteed to agree
between a correlated subquery's execution path and a window function's,
even though both are "the same tie" logically. This is the ordering
non-determinism of ties and Module 7's ROW_NUMBER tiebreak note,
showing up for real, in a rewrite that looked obviously correct on
inspection. Adding cat.name as an explicit secondary sort key in
both versions (shown in the queries above) fixed it — after which the
correctness check reports 0 mismatches, for real, against all 599
customers.
This is worth sitting with as the actual point of this capstone: a rewrite that produces the right answer for most rows and looks correct by eye is still a bug, and the only way this one was caught was by actually running an automated, row-by-row comparison — not by reading the SQL and reasoning "that looks right," which is exactly what missed it the first time.
Check yourself
- What's the diagnostic first step before rewriting ANY suspected-slow query, and why does skipping it risk optimizing the wrong thing?
- Why was the
EXISTSsubquery left unchanged in the reference solution, when the other two were rewritten? - How would you verify the rewritten query is actually correct, not just faster?