Fatskills
Practice. Master. Repeat.
Study Guide: TECH **SQL Query Refactoring: Breaking Complex Queries, Sub-queries vs. JOINs**
Source: https://www.fatskills.com/data-science/chapter/tech-sql-query-refactoring-breaking-complex-queries-sub-queries-vs-joins

TECH **SQL Query Refactoring: Breaking Complex Queries, Sub-queries vs. JOINs**

By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.

⏱️ ~9 min read

SQL Query Refactoring: Breaking Complex Queries, Sub-queries vs. JOINs

A Hyper-Practical Guide for Data Analysts & Engineers


1. What This Is & Why It Matters

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.

Why This Matters in Production

  • Performance: A poorly structured query can lock tables, spike CPU, and bring down a production database during peak hours.
  • Maintainability: If your query looks like a Rube Goldberg machine, the next analyst (or future you) will waste hours deciphering it.
  • Debugging: A single 300-line query is a nightmare to debug. Break it into logical chunks, and you can isolate issues in seconds.
  • Scalability: As data grows, your query must scale. A well-refactored query runs efficiently on 10M rows; a bad one chokes at 1M.

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.


2. Core Concepts & Components


1. Sub-query (Nested Query)

  • Definition: A query inside another query, used to filter, aggregate, or transform data before the outer query runs.
  • Production Insight: Sub-queries are great for filtering (e.g., "only show customers who spent > $100") but can be slow if they execute row-by-row (correlated sub-queries). Use them when you need a single value or a small filtered set.

2. JOIN

  • Definition: Combines rows from two or more tables based on a related column (e.g., customer_id).
  • Production Insight: JOINs are faster for large datasets because the database optimizes them. But too many JOINs (especially CROSS JOINs) can explode row counts and kill performance.

3. Correlated Sub-query

  • Definition: A sub-query that references a column from the outer query (e.g., WHERE customer_id IN (SELECT customer_id FROM orders WHERE order_date > '2023-01-01')).
  • Production Insight: These are performance killers because they execute once per row in the outer query. Avoid them for large datasets.

4. Derived Table (Inline View)

  • Definition: A sub-query in the FROM clause that acts like a temporary table (e.g., SELECT * FROM (SELECT customer_id, SUM(amount) FROM orders GROUP BY customer_id) AS customer_spend).
  • Production Insight: Derived tables are cleaner than nested sub-queries and often faster because the database materializes them first.

5. CTE (Common Table Expression)

  • Definition: A named temporary result set defined with WITH (e.g., WITH high_value_customers AS (SELECT customer_id FROM customers WHERE lifetime_spend > 1000)).
  • Production Insight: CTEs make queries more readable and can improve performance (especially in PostgreSQL and SQL Server). But in MySQL, they’re often rewritten as derived tables, so test performance.

6. EXISTS vs. IN

  • Definition:
  • IN checks if a value exists in a list (e.g., WHERE customer_id IN (1, 2, 3)).
  • EXISTS checks if a sub-query returns any rows (e.g., WHERE EXISTS (SELECT 1 FROM orders WHERE orders.customer_id = customers.customer_id)).
  • Production Insight: EXISTS is faster for large datasets because it stops evaluating after the first match. IN loads the entire sub-query into memory first.

7. Window Functions

  • Definition: Functions like ROW_NUMBER(), RANK(), or SUM() OVER(PARTITION BY ...) that operate on a "window" of rows without collapsing them (unlike GROUP BY).
  • Production Insight: Window functions are powerful for complex aggregations (e.g., "show me each customer’s spend vs. the average spend in their segment") but can be memory-intensive if overused.

8. Query Plan (Execution Plan)

  • Definition: The database’s step-by-step plan for executing your query (e.g., EXPLAIN ANALYZE in PostgreSQL).
  • Production Insight: Always check the query plan before refactoring. A Seq Scan (full table scan) on a large table is a red flag.


3. Step-by-Step Hands-On Refactoring


Prerequisites

  • A SQL environment (PostgreSQL, MySQL, SQL Server, or BigQuery).
  • A dataset with at least two related tables (e.g., customers and orders).
  • Basic familiarity with SELECT, WHERE, and GROUP BY.

Task: Refactor a Monolithic Query

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.

Step 1: Break into Logical CTEs

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;

Step 2: Verify Performance

Run EXPLAIN ANALYZE (PostgreSQL) or EXPLAIN (MySQL) on both queries:


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.

Step 3: Compare Execution Times

-- 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).


4. ? Production-Ready Best Practices


Performance

  • Prefer JOINs over sub-queries for large datasets (databases optimize JOINs better).
  • Use CTEs for readability, but test performance (MySQL treats them as derived tables).
  • Avoid SELECT *—only pull columns you need.
  • Index foreign keys (e.g., customer_id in orders).
  • Limit row counts early (e.g., WHERE before JOIN).

Maintainability

  • Break queries into CTEs (one per logical step).
  • Name CTEs descriptively (e.g., high_value_customers instead of cte1).
  • Add comments for complex logic.
  • Use consistent formatting (e.g., align JOIN conditions).

Debugging

  • Test CTEs independently (run them as standalone queries).
  • Check query plans (EXPLAIN ANALYZE).
  • Log slow queries (set up database monitoring).

Security

  • Avoid dynamic SQL (SQL injection risk).
  • Use parameterized queries in applications.
  • Grant least privilege (don’t give analysts DELETE access).


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Correlated sub-query in SELECT Query runs for hours on large tables. Replace with a LEFT JOIN or EXISTS.
CROSS JOIN instead of INNER JOIN Query returns millions of rows (cartesian product). Double-check JOIN conditions.
IN with a large sub-query High memory usage, slow performance. Use EXISTS or a JOIN.
No GROUP BY with aggregates Error: "column must appear in GROUP BY clause." Always GROUP BY non-aggregated columns.
Overusing DISTINCT Slow performance (deduplication is expensive). Use GROUP BY or ROW_NUMBER() instead.


6. ? Exam/Certification Focus


Typical Question Patterns

  1. "Which is faster: a sub-query or a JOIN?"
  2. Answer: JOINs are usually faster for large datasets. Sub-queries are better for filtering small sets.
  3. Trap: "Sub-queries are always slower" (not true for small datasets).

  4. "When should you use EXISTS vs. IN?"

  5. Answer: EXISTS is faster for large datasets (stops at first match). IN is better for static lists (e.g., IN (1, 2, 3)).

  6. "How would you refactor this query?"

  7. Answer: Break into CTEs, replace correlated sub-queries with JOINs, and check the query plan.

  8. "What’s the difference between a CTE and a derived table?"

  9. Answer: A CTE is named and reusable (defined with WITH). A derived table is anonymous (sub-query in FROM).

Key Trap Distinctions

  • INNER JOIN vs. LEFT JOIN:
  • INNER JOIN = only matching rows.
  • LEFT JOIN = all rows from left table + matches from right.
  • Trap: Using LEFT JOIN when you only need matches (slower).

  • WHERE vs. HAVING:

  • WHERE filters before aggregation.
  • HAVING filters after aggregation.
  • Trap: Using HAVING for non-aggregated filters (slower).


7. ? Hands-On Challenge

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.


8. ? Rapid-Reference Crib Sheet

Concept Syntax When to Use Performance Tip
CTE WITH cte_name AS (SELECT ...) Readability, reusable logic Test in MySQL (may not optimize).
Derived Table SELECT * FROM (SELECT ...) AS dt One-off sub-queries Faster than nested sub-queries.
JOIN INNER JOIN table ON key = key Combining tables Prefer over sub-queries for large datasets.
EXISTS WHERE EXISTS (SELECT 1 FROM ...) Checking existence Faster than IN for large datasets.
Window Function SUM() OVER(PARTITION BY ...) Aggregations without GROUP BY Use for rankings, running totals.
Query Plan EXPLAIN ANALYZE SELECT ... Debugging slow queries Look for Seq Scan (bad) or Index Scan (good).
⚠️ Correlated Sub-query WHERE col IN (SELECT ...) Avoid for large datasets Replace with JOIN or EXISTS.
⚠️ SELECT * SELECT * FROM table Never in production Always specify columns.


9. ? Where to Go Next

  1. PostgreSQL Query Planning – Deep dive into how PostgreSQL executes queries.
  2. SQL Performance Explained (Book) – Best book on SQL performance.
  3. BigQuery Query Execution – How BigQuery optimizes queries.
  4. SQLZoo Refactoring Exercises – Practice refactoring queries.


ADVERTISEMENT