Home/Learn/SQL/Subquery vs JOIN vs CTE — When to Use Which

Subquery vs JOIN vs CTE — When to Use Which

Intermediate
Subqueries & CTEs

Subqueries, JOINs, and CTEs often produce the same execution plan, but the right choice depends on whether you need columns from both tables, multiple references, or readable multi-step logic.

Overview

Modern query optimisers frequently rewrite subqueries as joins and vice versa, so performance is often identical. However, there are cases where they differ and where the choice matters for correctness and maintainability. Use a JOIN when you need columns from both tables — a subquery in WHERE cannot project those columns. Use a subquery (EXISTS/IN) for pure existence checks where you do not need the matched columns. Use a CTE when the logic has multiple steps that benefit from named intermediates, or when the same intermediate result is referenced more than once. The decision framework: need columns from both tables → JOIN; need existence check only → EXISTS/IN subquery; multi-step logic or reuse → CTE.

Three Ways to Write the Same Query

Show orders with customer city and product category — three equivalent approaches. The planner often produces the same plan for all three, but readability and maintainability differ significantly.

SQL — order report: subquery, JOIN, CTE comparison
-- Requirement: list order id, amount, customer city, product category
-- for completed orders in 2024.

-- Version 1: Correlated subquery in SELECT (readable but N+1 risk)
SELECT
    o.id,
    o.amount,
    (SELECT u.city FROM users u WHERE u.id = o.user_id)       AS city,
    (SELECT p.category FROM products p WHERE p.id = o.product_id) AS category
FROM orders o
WHERE o.status = 'completed'
  AND o.created_at >= '2024-01-01';
-- Risk: two correlated subqueries → two extra lookups per row.

-- Version 2: JOIN (best when you need columns from both tables)
SELECT
    o.id,
    o.amount,
    u.city,
    p.category
FROM orders o
JOIN users    u ON u.id = o.user_id
JOIN products p ON p.id = o.product_id
WHERE o.status = 'completed'
  AND o.created_at >= '2024-01-01';
-- Clean, one scan per table; optimizer free to choose hash/merge/nested loop.

-- Version 3: CTE (best when logic has multiple named steps)
WITH completed_orders AS (
    SELECT id, user_id, product_id, amount
    FROM orders
    WHERE status = 'completed'
      AND created_at >= '2024-01-01'
)
SELECT
    co.id,
    co.amount,
    u.city,
    p.category
FROM completed_orders co
JOIN users    u ON u.id = co.user_id
JOIN products p ON p.id = co.product_id;

Decision Framework and When Plans Actually Differ

The optimizer treats them the same in simple cases, but diverges with NOT IN vs NOT EXISTS, or when MATERIALIZED forces CTE caching.

SQL — when subquery/JOIN/CTE plans actually differ
-- When they differ in practice:

-- 1. Need both table's columns → must use JOIN (subquery in WHERE cannot project)
-- This is WRONG (cannot select o.amount AND u.email in WHERE subquery):
SELECT o.id, o.amount
FROM orders o
WHERE o.user_id = (SELECT id, email FROM users WHERE city = 'Mumbai'); -- ERROR

-- 2. Existence check only → EXISTS is cleaner and short-circuits
SELECT o.id, o.amount
FROM orders o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.city = 'Mumbai');

-- 3. NOT IN vs NOT EXISTS: semantically different with NULLs (see in-exists-any-all)

-- 4. CTE referenced twice: planner may scan source twice unless MATERIALIZED
WITH city_stats AS MATERIALIZED (
    SELECT city, COUNT(*) AS user_count, AVG(salary) AS avg_salary
    FROM employees e JOIN departments d ON d.id = e.department_id
    GROUP BY city
)
SELECT * FROM city_stats WHERE user_count > 10
UNION ALL
SELECT * FROM city_stats WHERE avg_salary > 80000;
-- MATERIALIZED: source scanned once, result reused for both references.

Key Points to Remember

  • 1Use JOIN when you need columns from both tables in the result — a subquery in WHERE cannot project them.
  • 2Use EXISTS/IN for pure existence or membership checks where you do not need the matched row's columns.
  • 3Use a CTE for multi-step logic, named intermediates, or when the same result is referenced more than once.
  • 4The optimizer often rewrites subqueries as joins automatically — check EXPLAIN to confirm the actual plan.
  • 5NOT IN vs NOT EXISTS is a correctness issue (NULL trap), not just a style choice — always prefer NOT EXISTS.
  • 6MATERIALIZED CTE prevents redundant re-evaluation when the CTE is referenced multiple times in the same query.

Interview Questions

Sign in to ask Aria
1

When must you use a JOIN instead of a subquery? Give an example where a subquery cannot work.

EasyFlipkart
2

In what scenarios would you choose a CTE over a subquery for readability and performance?

MediumAtlassian
3

Does the database always produce the same execution plan for a subquery and its equivalent JOIN? Explain.

HardAmazon
4

Explain the performance implication of referencing a CTE twice in the same query.

HardGoogle

Ask Aria about Subquery vs JOIN vs CTE — When to Use Which

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…