By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
A Hyper-Practical Guide for Data Analysts & Engineers
You’ve inherited a 300-line SQL query that runs in 12 minutes, crashes your BI tool, and makes your database admin cry. Or worse: you wrote it yourself last quarter and now you’re staring at it like it’s hieroglyphics.
Query refactoring is the art of taking a monolithic, slow, or unreadable SQL query and breaking it into smaller, faster, and maintainable pieces—without changing the output. The two most common tools in your refactoring toolkit are sub-queries and JOINs, but choosing the wrong one can turn a 12-minute query into a 2-hour disaster.
Real-World Scenario:You’re a data analyst at an e-commerce company. Your boss asks for a report on "high-value customers who haven’t purchased in 90 days but engaged with a recent email campaign." The current query is a 200-line monster with nested sub-queries, CASE statements, and LEFT JOINs to the same table three times. It takes 15 minutes to run and times out in Tableau. Your mission: Refactor it into something that runs in under 30 seconds and doesn’t make your DBA threaten to revoke your database access.
CASE
LEFT JOIN
customer_id
CROSS JOIN
WHERE customer_id IN (SELECT customer_id FROM orders WHERE order_date > '2023-01-01')
FROM
SELECT * FROM (SELECT customer_id, SUM(amount) FROM orders GROUP BY customer_id) AS customer_spend
WITH
WITH high_value_customers AS (SELECT customer_id FROM customers WHERE lifetime_spend > 1000)
IN
WHERE customer_id IN (1, 2, 3)
EXISTS
WHERE EXISTS (SELECT 1 FROM orders WHERE orders.customer_id = customers.customer_id)
ROW_NUMBER()
RANK()
SUM() OVER(PARTITION BY ...)
GROUP BY
EXPLAIN ANALYZE
Seq Scan
customers
orders
SELECT
WHERE
Original Query (The Nightmare):
SELECT c.customer_id, c.name, c.email, COUNT(o.order_id) AS total_orders, SUM(o.amount) AS lifetime_spend, MAX(o.order_date) AS last_order_date, CASE WHEN MAX(o.order_date) < CURRENT_DATE - INTERVAL '90 days' THEN 'Inactive' ELSE 'Active' END AS status, (SELECT COUNT(*) FROM email_campaigns ec WHERE ec.customer_id = c.customer_id AND ec.campaign_id = 42) AS engaged_with_campaign FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE c.lifetime_spend > 1000 AND c.customer_id IN ( SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(order_id) > 5 ) GROUP BY c.customer_id, c.name, c.email ORDER BY lifetime_spend DESC;
Problems with this query:1. Correlated sub-query (engaged_with_campaign) runs row-by-row.2. Nested IN sub-query is inefficient.3. LEFT JOIN + GROUP BY is slower than a derived table.4. Hard to read and debug.
engaged_with_campaign
WITH -- High-value customers (spend > $1000) high_value_customers AS ( SELECT customer_id, name, email, lifetime_spend FROM customers WHERE lifetime_spend > 1000 ), -- Customers with >5 orders frequent_customers AS ( SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(order_id) > 5 ), -- Order metrics per customer customer_order_metrics AS ( SELECT customer_id, COUNT(order_id) AS total_orders, SUM(amount) AS lifetime_spend, MAX(order_date) AS last_order_date FROM orders GROUP BY customer_id ), -- Campaign engagement campaign_engagement AS ( SELECT customer_id, COUNT(*) AS engaged_with_campaign FROM email_campaigns WHERE campaign_id = 42 GROUP BY customer_id ) -- Final result SELECT hvc.customer_id, hvc.name, hvc.email, com.total_orders, com.lifetime_spend, com.last_order_date, CASE WHEN com.last_order_date < CURRENT_DATE - INTERVAL '90 days' THEN 'Inactive' ELSE 'Active' END AS status, COALESCE(ce.engaged_with_campaign, 0) AS engaged_with_campaign FROM high_value_customers hvc INNER JOIN frequent_customers fc ON hvc.customer_id = fc.customer_id INNER JOIN customer_order_metrics com ON hvc.customer_id = com.customer_id LEFT JOIN campaign_engagement ce ON hvc.customer_id = ce.customer_id ORDER BY com.lifetime_spend DESC;
Run EXPLAIN ANALYZE (PostgreSQL) or EXPLAIN (MySQL) on both queries:
EXPLAIN
EXPLAIN ANALYZE [Your query here];
What to look for:- Seq Scan (Full Table Scan): Bad if on a large table.- Index Scan: Good (uses an index).- Hash Join: Usually efficient.- Nested Loop: Can be slow for large datasets.
-- Original query SELECT * FROM ( [Original query here] ) AS original_query; -- Refactored query SELECT * FROM ( [Refactored query here] ) AS refactored_query;
Expected result:- Original: ~12 minutes (or timeout).- Refactored: ~5-10 seconds (100x faster).
SELECT *
JOIN
high_value_customers
cte1
DELETE
INNER JOIN
DISTINCT
Trap: "Sub-queries are always slower" (not true for small datasets).
"When should you use EXISTS vs. IN?"
Answer: EXISTS is faster for large datasets (stops at first match). IN is better for static lists (e.g., IN (1, 2, 3)).
IN (1, 2, 3)
"How would you refactor this query?"
Answer: Break into CTEs, replace correlated sub-queries with JOINs, and check the query plan.
"What’s the difference between a CTE and a derived table?"
Trap: Using LEFT JOIN when you only need matches (slower).
WHERE vs. HAVING:
HAVING
Challenge:Refactor this query to use JOIN instead of a sub-query:
SELECT customer_id, name, (SELECT COUNT(*) FROM orders WHERE orders.customer_id = customers.customer_id) AS order_count FROM customers;
Solution:
SELECT c.customer_id, c.name, COUNT(o.order_id) AS order_count FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.name;
Why it works:- Replaces a correlated sub-query (slow) with a LEFT JOIN + GROUP BY (faster).- LEFT JOIN ensures customers with no orders are still included.
WITH cte_name AS (SELECT ...)
SELECT * FROM (SELECT ...) AS dt
INNER JOIN table ON key = key
WHERE EXISTS (SELECT 1 FROM ...)
EXPLAIN ANALYZE SELECT ...
Index Scan
WHERE col IN (SELECT ...)
SELECT * FROM table
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.