← Back to Blog

CTE vs Subquery: When to Use Each in SQL

CTEs and Subqueries Both Solve the Same Problem

Both Common Table Expressions (CTEs) and subqueries let you break complex queries into smaller steps. In interviews, you'll use them constantly — the question is which one to reach for and when.

What Is a CTE?

A CTE (Common Table Expression) is a named temporary result set defined with the WITH keyword:

WITH active_users AS (
    SELECT user_id, COUNT(*) as login_count
    FROM logins
    WHERE login_date >= '2025-01-01'
    GROUP BY user_id
    HAVING COUNT(*) >= 5
)
SELECT u.name, u.email, a.login_count
FROM users u
JOIN active_users a ON u.id = a.user_id
ORDER BY a.login_count DESC;

What Is a Subquery?

A subquery is a query nested inside another query:

SELECT u.name, u.email, a.login_count
FROM users u
JOIN (
    SELECT user_id, COUNT(*) as login_count
    FROM logins
    WHERE login_date >= '2025-01-01'
    GROUP BY user_id
    HAVING COUNT(*) >= 5
) a ON u.id = a.user_id
ORDER BY a.login_count DESC;

Both queries produce identical results. The difference is style and sometimes performance.

When to Use CTEs

1. Multi-Step Queries

CTEs shine when you need to build up results step by step:

WITH monthly_revenue AS (
    SELECT DATE_TRUNC('month', order_date) as month,
           SUM(amount) as revenue
    FROM orders
    GROUP BY 1
),
with_growth AS (
    SELECT month,
           revenue,
           LAG(revenue) OVER (ORDER BY month) as prev_month,
           revenue - LAG(revenue) OVER (ORDER BY month) as growth
    FROM monthly_revenue
)
SELECT month, revenue, growth,
       ROUND(100.0 * growth / NULLIF(prev_month, 0), 1) as growth_pct
FROM with_growth
WHERE growth IS NOT NULL
ORDER BY month;

Trying to write this as nested subqueries would be painful to read.

2. Reusing the Same Logic Multiple Times

If you reference the same derived table more than once, a CTE avoids duplication:

WITH user_metrics AS (
    SELECT user_id,
           COUNT(*) as total_orders,
           SUM(amount) as total_spent
    FROM orders
    GROUP BY user_id
)
SELECT
    (SELECT AVG(total_spent) FROM user_metrics) as avg_spending,
    (SELECT AVG(total_spent) FROM user_metrics WHERE total_orders > 5) as avg_spending_power_users,
    (SELECT COUNT(*) FROM user_metrics WHERE total_spent > 1000) as high_value_count;

3. Recursive Queries

Only CTEs support recursion — useful for hierarchical data:

WITH RECURSIVE org_chart AS (
    -- Base case: CEO (no manager)
    SELECT id, name, manager_id, 1 as level
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- Recursive case: employees with managers
    SELECT e.id, e.name, e.manager_id, oc.level + 1
    FROM employees e
    JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart ORDER BY level, name;

When to Use Subqueries

1. Simple Filtering

For simple EXISTS or IN checks, subqueries are more concise:

-- Find customers who placed an order in the last 30 days
SELECT name, email
FROM customers
WHERE id IN (
    SELECT DISTINCT customer_id
    FROM orders
    WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
);

2. Scalar Subqueries

When you need a single value in a SELECT or WHERE clause:

-- Find employees earning above average
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);

3. Correlated Subqueries

When the inner query references the outer query:

-- Find each department's highest earner
SELECT name, department, salary
FROM employees e1
WHERE salary = (
    SELECT MAX(salary)
    FROM employees e2
    WHERE e2.department = e1.department
);

Performance Comparison

In most modern databases (PostgreSQL, BigQuery, Snowflake), CTEs and subqueries perform identically. The optimizer inlines CTEs just like subqueries.

However, there are exceptions:

  • PostgreSQL < 12: CTEs were optimization fences (always materialized). After v12, they're inlined by default.
  • MySQL 8.0+: CTEs are supported and generally well-optimized
  • Materialized CTEs: Some databases let you force materialization, which is useful when a CTE is referenced multiple times and is expensive to compute

Interview tip: If asked about CTE vs subquery performance, say "In modern databases, they're typically optimized the same way. I choose based on readability. But in older PostgreSQL versions, CTEs could be slower because they acted as optimization barriers."

Best Practices for Interviews

Do:

  • Use CTEs for multi-step analysis — it shows structured thinking
  • Name CTEs descriptively (monthly_revenue, not t1)
  • Use subqueries for simple filters (IN, EXISTS)
  • Start your solution by sketching the CTE structure before writing SQL

Don't:

  • Nest subqueries more than 2 levels deep — use CTEs instead
  • Create unnecessary CTEs for simple operations
  • Mix CTEs and deeply nested subqueries in the same query

Interview Example: Putting It Together

Find the month with the highest number of first-time purchasers.

WITH first_purchases AS (
    SELECT customer_id,
           MIN(order_date) as first_order_date
    FROM orders
    GROUP BY customer_id
),
monthly_new_customers AS (
    SELECT DATE_TRUNC('month', first_order_date) as month,
           COUNT(*) as new_customers
    FROM first_purchases
    GROUP BY 1
)
SELECT month, new_customers
FROM monthly_new_customers
ORDER BY new_customers DESC
LIMIT 1;

This would be much harder to read as nested subqueries.

Practice CTE Problems

Master CTEs with our CTE interview problems — from basic single CTEs to recursive queries and multi-step analysis.

Practice Makes Perfect

Ready to test your skills?

Practice real Ctes interview questions from top companies — with solutions.

Get interview tips in your inbox

Join data scientists preparing smarter. No spam, unsubscribe anytime.