DEV Community

Cover image for SUBQUERIES AND CTEs
Miss Analyst
Miss Analyst

Posted on

SUBQUERIES AND CTEs

When I first started learning SQL, writing a basic SELECT query wasn't too bad.
Then came questions like:

"What if I need to use the result of one query inside another query?"

That's where subqueries come in.
And when those queries start getting longer and harder to read, you might come across something called a CTE.
At first, both can seem confusing. But once you understand what they're doing, they actually make a lot of sense.

In this article, I'll break down subqueries and CTEs using simple examples and explain when you might want to use each one.

What Is a Subquery?

A subquery is simply a query inside another query. Instead of running two completely separate queries, we can put one query inside another and use its result.

Here's where they tend to show up in real code.

1. Inside a WHERE clause — "find rows above or below some number"

Say you run a small online store. You want to find your best customers: people who spent more than the average.

SELECT customer_id, name
FROM customers
WHERE lifetime_spend > (
  SELECT AVG(lifetime_spend) FROM customers
);
Enter fullscreen mode Exit fullscreen mode

The inner query gives back one number: the average. The outer query uses that number as a cutoff. You'll see this pattern everywhere — flagging a payment that's way above someone's usual spend, or a product selling faster than others in its category.

2. Inside a FROM clause — using a query result like a table

Say your marketing team wants the average order value per city, but only for cities where that average is above $50.

SELECT city, avg_order_value
FROM (
  SELECT city, AVG(order_total) AS avg_order_value
  FROM orders
  GROUP BY city
) AS city_averages
WHERE avg_order_value > 50;
Enter fullscreen mode Exit fullscreen mode

This inner query builds a small, temporary table, and the outer query filters it. You can't filter on avg_order_value in the same step where you calculate it, so this two-step approach is what lets you do it.

3. Inside a SELECT clause — pulling in one extra fact per row

Say someone asks: "next to each customer's name, show how many orders they've placed."

SELECT
  name,
  (SELECT COUNT(*) FROM orders WHERE orders.customer_id = customers.id) AS order_count
FROM customers;
Enter fullscreen mode Exit fullscreen mode

This only works if the subquery returns exactly one number per row. It's fine for small cases like this, but if you're doing it across a lot of rows, a JOIN is usually the faster choice.

4. Correlated subqueries — when the inner query needs the outer row

A correlated subquery looks at the current row from the outer query while it runs. It can't run on its own — it runs once for every row.

Say you want to find employees who earn more than the average salary in their own department (not the whole company).

SELECT e1.name, e1.salary, e1.department
FROM employees e1
WHERE e1.salary > (
  SELECT AVG(e2.salary)
  FROM employees e2
  WHERE e2.department = e1.department
);
Enter fullscreen mode Exit fullscreen mode

See that line, e2.department = e1.department? That's the connection back to the outer row. For each employee, the subquery works out the average pay for just their department. This kind of check is hard to write any other way, which is exactly when a subquery is the right call.

What Is a CTE?

A CTE (short for Common Table Expression) is a named subquery placed at the top of your query, using WITH. Same result, easier to read.

Here's the "cities with above-average order value" example again, written as a CTE:

WITH city_averages AS (
  SELECT city, AVG(order_total) AS avg_order_value
  FROM orders
  GROUP BY city
)
SELECT city, avg_order_value
FROM city_averages
WHERE avg_order_value > 50;
Enter fullscreen mode Exit fullscreen mode

It does the exact same thing as the earlier version. But read it out loud: "with city averages as this, select from city averages where..." It sounds close to plain English. That's the main reason people like CTEs.

Chaining CTEs together

This is where CTEs really help. Say you're building a dashboard and want to know which cities have the most big-spending customers.

WITH big_spenders AS (
  SELECT * FROM customers WHERE lifetime_spend > 1000
),
big_spenders_by_city AS (
  SELECT city, COUNT(*) AS spender_count
  FROM big_spenders
  GROUP BY city
)
SELECT * FROM big_spenders_by_city ORDER BY spender_count DESC;
Enter fullscreen mode Exit fullscreen mode

Each step has a name, and each step builds on the one before it. Try writing this with nested subqueries instead, and you'll end up with closing brackets stacked on top of each other. Nobody enjoys reading that.

Recursive CTEs — the one thing subqueries can't do

This is the real strength of CTEs. Recursive CTEs handle anything shaped like a tree: org charts, product categories (like "Electronics > Laptops > Gaming Laptops"), comment threads with replies to replies.

Here's an org chart example — showing who reports to who, all the way down.

WITH RECURSIVE org_chart AS (
  -- Start at the top: anyone with no manager
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Then find everyone who reports to someone we already found
  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;
Enter fullscreen mode Exit fullscreen mode

It starts at the top of the company, then keeps adding the next level down, one step at a time, until there's nobody left to add. Without a recursive CTE, you'd need to write a loop in your app code to do this.

A product category tree works almost the same way. Just swap manager_id for parent_category_id.

WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_category_id, 1 AS depth
  FROM categories
  WHERE parent_category_id IS NULL

  UNION ALL

  SELECT c.id, c.name, c.parent_category_id, ct.depth + 1
  FROM categories c
  JOIN category_tree ct ON c.parent_category_id = ct.id
)
SELECT * FROM category_tree ORDER BY depth, name;
Enter fullscreen mode Exit fullscreen mode

Same pattern, different data. Once you see it once, you'll notice it everywhere.

Subquery vs CTE: Quick Comparison

Subquery CTE
Can you reuse it in the same query? No, you'd have to write it again Yes, just reference its name
Easy to read when there are many steps? Gets messy fast Stays readable, step by step
Can it repeat itself (recursion)? No Yes, with WITH RECURSIVE
Can it use the outer row for context? Yes Not directly

"But Which One Is Faster?"

People ask this a lot, so let's settle it: there's no single right answer. Be careful trusting anyone who claims one is always faster.

  • Older versions of Postgres (before version 12) always computed a CTE fully before using it, even if you only needed a few rows out of it. That gave CTEs a bad reputation. Postgres 12 and later fixed this.
  • In SQL Server, MySQL 8+, and modern Postgres, a CTE and an equivalent subquery are usually treated the same way by the database, with the same speed.
  • Recursive CTEs aren't really part of this comparison, since there's no subquery way to repeat a query like that.

Don't choose based on an old rumor about speed. Choose based on what's easier to read. If speed really matters for a specific query, check the real execution plan instead of guessing.

So, Which One Should You Use?

Most of the time it comes down to how many steps your query needs. Here's a simple way to decide:

Use a subquery when:

  • It's a small, one-off check, like "only show rows above the average."
  • Giving it a name would be more effort than it's worth.
  • It needs to use the current row from the outer query (a correlated subquery).

Use a CTE when:

  • Your query has more than one step, and naming each step makes it easier to follow.
  • You need to use the same result more than once in your query.
  • You're working with tree-shaped data, like org charts or categories.
  • You're debugging and want to check one step at a time.

Using Both Together (This Is Normal)

Most real queries mix the two. A CTE handles the overall structure, and a small subquery handles a quick check inside it. Here's a monthly sales report that does both:

WITH monthly_sales AS (
  SELECT
    DATE_TRUNC('month', order_date) AS month,
    SUM(amount) AS total_sales
  FROM orders
  GROUP BY 1
)
SELECT
  month,
  total_sales,
  total_sales - (SELECT AVG(total_sales) FROM monthly_sales) AS diff_from_avg
FROM monthly_sales
ORDER BY month;
Enter fullscreen mode Exit fullscreen mode

The CTE groups sales by month. The small subquery at the end compares each month to the average. Simple, and easy to follow.

Final thoughts

Subqueries and CTEs both do similar work, and most SQL developers use both, depending on the situation. Any CTE without recursion could technically be written as a subquery instead — but that doesn't mean it should be. If your query has several steps, let a CTE lay them out clearly. If you just need one quick check, a subquery works fine. And if your data is shaped like a tree, a recursive CTE is really your only option.

The best test isn't "which one is technically correct." It's "can I read this again in six months without getting confused." Aim for that, and you'll be fine.

Top comments (0)