Readineer
PATTERN 10SQL Patterns / Combining Data

SQL subqueries and CTEs: breaking complex queries into understandable steps

Learn how to use scalar and correlated subqueries, build intermediate results with derived tables, structure larger queries with WITH, and avoid accidentally comparing rows against the wrong dataset.

8 min read11 sections2 predictions
01Foundation

A query can use the result of another query

So far, our SQL queries have mainly worked directly with tables. Consider the same orders and customers tables:

Orders
order_idcustomer_idproductquantityunit_pricestatus
10011Wireless Mouse2750paid
10022Mechanical Keyboard13200pending
10033Laptop Sleeve31200paid
10044Office Chair18500cancelled
10051Wireless Mouse1750paid
10062Monitor Stand21800paid
Customers
customer_idcustomer_namecountry
1AshaIndia
2BenUK
3CarlaUAE
4DevIndia
5EshaSingapore

Sometimes one SQL operation is not enough to express the question clearly. For example, “which orders are worth more than the average order value?” Before we can identify those orders, we first need to know the average order value, and that calculation is itself a query: SELECT AVG(quantity * unit_price) FROM orders;. A subquery allows us to place one query inside another query and use its result: solve one part of the problem, use that result, then solve the larger problem.

Key idea
Subqueries can appear in several places and can return different shapes of results. We will focus on four important forms: scalar subqueries, correlated subqueries, derived tables, and common table expressions (CTEs).
02Scalar subqueries

Scalar subqueries return one value

A scalar subquery is a subquery used where SQL expects a single value. Suppose we want orders whose value is greater than the average order value. The order values are 1500, 3200, 3600, 8500, 750, 3600, and their average is 3525. Now use that value inside another query:

Query
SELECT
    order_id,
    customer_id,
    quantity * unit_price AS order_amount
FROM orders
WHERE quantity * unit_price > (
    SELECT AVG(quantity * unit_price)
    FROM orders
)
ORDER BY order_id;
Result
order_idcustomer_idorder_amount
100333600
100448500
100623600

The inner query produces one value, 3525, so the outer query behaves conceptually like WHERE quantity * unit_price > 3525. That is why this is called a scalar subquery.

Scalar subqueries can appear in SELECT

A scalar subquery can also produce a value in the SELECT list:

Query
SELECT
    order_id,
    quantity * unit_price AS order_amount,
    (
        SELECT AVG(quantity * unit_price)
        FROM orders
    ) AS overall_average
FROM orders
ORDER BY order_id;
Result
order_idorder_amountoverall_average
100115003525
100232003525
100336003525
100485003525
10057503525
100636003525

The same overall average appears beside every order because the subquery does not depend on the current order.

Gotcha: a scalar subquery must produce one value

Consider a SELECT that embeds (SELECT order_id FROM orders WHERE status = 'paid'). The inner query can return several paid orders (1001, 1003, 1005, 1006), but the outer query expects one scalar value. In many SQL databases, this produces an error because the subquery returned more than one row. Before using a scalar subquery, ask: does this query reliably return at most one value? Aggregate functions such as AVG, MAX, MIN, and COUNT often naturally produce one value, and a lookup by a unique key can also produce at most one matching row.
03Correlated subqueries

Correlated subqueries use the current outer row

The previous scalar subquery calculated one average for the entire orders table. Now consider a different requirement: “return orders whose value is greater than that customer’s own average order value.” For Asha, orders 1500 and 750 average 1125; for Ben, 3200 and 3600 average 3400. Each order must be compared against a different average depending on its customer. We can use a correlated subquery:

Query
SELECT
    o.order_id,
    o.customer_id,
    o.quantity * o.unit_price AS order_amount
FROM orders AS o
WHERE o.quantity * o.unit_price > (
    SELECT AVG(o2.quantity * o2.unit_price)
    FROM orders AS o2
    WHERE o2.customer_id = o.customer_id
)
ORDER BY o.order_id;
Result
order_idcustomer_idorder_amount
100111500
100623600

The important condition is WHERE o2.customer_id = o.customer_id. The inner query refers to o.customer_id from the current row in the outer query, so the subquery changes depending on which order SQL is evaluating.

How the correlation works

For order 1001 (o.customer_id = 1), the inner query conceptually becomes SELECT AVG(...) FROM orders WHERE customer_id = 1;. Asha’s average is 1125, and order 1001 is worth 1500:

Order 1001 (Asha):  1500 > 1125  → TRUE
Order 1005 (Asha):   750 > 1125  → FALSE
Order 1006 (Ben):   3600 > 3400  → TRUE
Order 1002 (Ben):   3200 > 3400  → FALSE
Key idea
The correlation changes the comparison from “compare every order with one global average” to “compare every order with the average for its own customer.”
04The featured failure

The missing correlation that answers the wrong question

Suppose the requirement is find orders worth more than the customer’s average order value. Someone writes the query below. It runs successfully, but something important is missing: o2.customer_id = o.customer_id.

PAUSE & PREDICTWhat is the inner query calculating?
Query
SELECT
    o.order_id,
    o.customer_id,
    o.quantity * o.unit_price AS order_amount
FROM orders AS o
WHERE o.quantity * o.unit_price > (
    SELECT AVG(o2.quantity * o2.unit_price)
    FROM orders AS o2
)
ORDER BY o.order_id;
Your prediction

The fix: connect the inner query to the outer row

Add the correlation. The condition o2.customer_id = o.customer_id is what changes the meaning from the overall average to this customer’s average:

Query
SELECT
    o.order_id,
    o.customer_id,
    o.quantity * o.unit_price AS order_amount
FROM orders AS o
WHERE o.quantity * o.unit_price > (
    SELECT AVG(o2.quantity * o2.unit_price)
    FROM orders AS o2
    WHERE o2.customer_id = o.customer_id
)
ORDER BY o.order_id;
Key idea
When reading a correlated subquery, find the expression that connects the inner query to the current outer row. If that relationship is missing, the query may silently answer a broader question.
05Derived tables

Use a query result like a table

So far, our subqueries have appeared inside WHERE and SELECT. A subquery can also appear inside FROM, and when it does, its result acts like a temporary table for the surrounding query. This is commonly called a derived table. Suppose we want customers whose total order value exceeds 5,000. First, calculate one row per customer, then query that intermediate result as if it were a table:

Query
SELECT
    customer_id,
    total_order_value
FROM (
    SELECT
        customer_id,
        SUM(quantity * unit_price) AS total_order_value
    FROM orders
    GROUP BY customer_id
) AS customer_totals
WHERE total_order_value > 5000
ORDER BY customer_id;
intermediate: customer_totals
customer_idtotal_order_value
12250
26800
33600
48500
Result
customer_idtotal_order_value
26800
48500

The inner query creates customer_totals; the outer query then filters that intermediate result. Derived tables are useful when a problem naturally has stages: stage 1 calculates one total per customer, and stage 2 filters those customer totals. This can be easier to understand than trying to express every transformation in one flat query, and it is also useful when the result of one query needs to be filtered, joined, aggregated again, ranked, or transformed further.

Key idea
A derived table needs a useful shape: the outer query can only use the columns the inner query exposes (customer_id, total_order_value), not columns like product, status, or order_id that are no longer part of the derived-table result. Once a subquery becomes a derived table, the outer query sees only the columns produced by that subquery.
06Common table expressions

Common table expressions with WITH

Derived tables work well, but deeply nested queries can become difficult to read. SQL provides another way to name an intermediate query result: WITH, which creates a Common Table Expression, usually called a CTE. The previous derived-table query can be written as:

Query
WITH customer_totals AS (
    SELECT
        customer_id,
        SUM(quantity * unit_price) AS total_order_value
    FROM orders
    GROUP BY customer_id
)
SELECT
    customer_id,
    total_order_value
FROM customer_totals
WHERE total_order_value > 5000
ORDER BY customer_id;

The main query can then refer to customer_totals almost as though it were a table.

CTEs help name the steps of a problem

Consider “among paid orders, find customers whose total paid order value exceeds 3,000.” A CTE lets us name stage 1 (calculate paid totals), and the main query handles stage 2 (keep totals above 3,000):

Query
WITH paid_customer_totals AS (
    SELECT
        customer_id,
        SUM(quantity * unit_price) AS paid_order_value
    FROM orders
    WHERE status = 'paid'
    GROUP BY customer_id
)
SELECT
    customer_id,
    paid_order_value
FROM paid_customer_totals
WHERE paid_order_value > 3000
ORDER BY customer_id;

The query reads almost like the problem: create paid customer totals, then return totals above 3000. That readability is one of the main reasons CTEs are useful.

CTEs can be joined to other tables

A CTE can also become an input to a join. To show customer names alongside their totals:

Query
WITH customer_totals AS (
    SELECT
        customer_id,
        SUM(quantity * unit_price) AS total_order_value
    FROM orders
    GROUP BY customer_id
)
SELECT
    c.customer_name,
    ct.total_order_value
FROM customer_totals AS ct
JOIN customers AS c
    ON c.customer_id = ct.customer_id
ORDER BY c.customer_name;
Result
customer_nametotal_order_value
Asha2250
Ben6800
Carla3600
Dev8500

The CTE prepares the order-level data; the main query adds customer information. This helps separate “how should orders be summarised?” from “how should the result be presented?”

Multiple CTEs can represent multiple steps

A WITH clause can define more than one CTE. If the problem has two logical stages, calculate customer totals then keep high-value customers, we could chain them:

Query
WITH customer_totals AS (
    SELECT customer_id, SUM(quantity * unit_price) AS total_order_value
    FROM orders
    GROUP BY customer_id
),
high_value_customers AS (
    SELECT customer_id, total_order_value
    FROM customer_totals
    WHERE total_order_value > 5000
)
SELECT c.customer_name, h.total_order_value
FROM high_value_customers AS h
JOIN customers AS c ON c.customer_id = h.customer_id
ORDER BY c.customer_name;

This does not mean every query should be split into many CTEs. Use them when the names make the logic easier to follow; too many tiny CTEs can make a simple query harder rather than easier.

07Choosing a form

Derived table or CTE, and a word on performance

These two forms can often express the same logic; the choice is often about readability. A derived table works well when the intermediate result is short, used once, and easy to understand inline. A CTE is often clearer when the intermediate query is substantial, the logic has named stages, several intermediate results are involved, or the same intermediate result needs to be referenced again within the statement, where supported.

Subqueries do not always mean poor performance

Do not assume that changing a derived table into a CTE automatically makes a query faster; how CTEs are optimized or materialized can vary by database and query. It is tempting to adopt rules such as “subqueries are slow, joins are fast, CTEs are faster,” but these are not reliable general rules. Modern database optimizers can transform queries in many ways, and two SQL expressions that look very different may be optimized into similar execution plans.

Performance depends on the database system, indexes, data volume, data distribution, query structure, and optimizer behaviour. Choose the form that correctly and clearly expresses the requirement, then inspect the execution plan and measure if the query is performance-sensitive.

08Recognition cues

When to consider a subquery or CTE

Consider a subquery or CTE when one calculation depends on the result of another, you need to compare a value against an overall aggregate, you need to compare a row against an aggregate for its own group, a query naturally breaks into several logical stages, you want to aggregate data and then perform more work on that result, a nested query is becoming difficult to read, or an intermediate result deserves a meaningful name. Watch for these common mistakes:

A scalar subquery unexpectedly returns several rows.
A correlated subquery is missing the condition connecting it to the outer query.
A global aggregate is used when a per-customer or per-group aggregate was required.
A derived table does not expose a column needed by the outer query.
Several nested subqueries make the query difficult to understand.
A CTE is added only because someone assumes it will improve performance.
09A practical mental model

Ask what the dependency actually is

1 · Need one value from another query?

Use a scalar subquery.

2 · Does that value depend on the current outer row?

Use a correlated subquery.

3 · Need a multi-row intermediate result?

Use a derived table or CTE.

4 · Would naming the step help?

If yes, a CTE is often a good fit.

Key idea
The goal is not to use the most advanced-looking syntax. The goal is to make the dependency between steps explicit.
10Check your understanding

One more prediction

Consider these orders. Requirement: return orders whose value is greater than the average order value for that same customer. Which query is correct?

orders
order_idcustomer_idorder_amount
100111500
100223200
100333600
100448500
10051750
100623600
ONE MORE PREDICTIONWhich query correctly expresses the requirement?
Query
-- Option A
WHERE o.order_amount > (
    SELECT AVG(o2.order_amount) FROM orders AS o2
)

-- Option B
WHERE o.order_amount > (
    SELECT AVG(o2.order_amount) FROM orders AS o2
    WHERE o2.customer_id = o.customer_id
)

-- Option C
WHERE o.customer_id = (
    SELECT AVG(o2.order_amount) FROM orders AS o2
)

-- Option D
WHERE o.order_amount > AVG(o.order_amount)
Your prediction
11Summary

Summary

A subquery lets one query use the result of another query. Use a scalar subquery when one value is required; a correlated subquery when that calculation depends on the current outer row; a derived table when you need a multi-row intermediate result; and a CTE when naming that intermediate result makes the query easier to follow.

Key idea
The most important correlated-subquery rule is: make sure the inner query is connected to the correct outer row. SELECT AVG(order_amount) FROM orders calculates a global average, while SELECT AVG(o2.order_amount) FROM orders o2 WHERE o2.customer_id = o.customer_id calculates an average for the current customer’s rows. Both queries are valid; they answer different questions. Subqueries and CTEs are most useful when they make those dependencies and stages easier to see.

Next, we will move into working with real-world date and timestamp data, including filtering periods, date arithmetic, extraction, and boundary-safe time ranges.

Go deeper

Ask Colearn about this pattern

Not sure why your subquery errors, or why it computes the wrong average? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why does my scalar subquery error with 'more than one row'?

A scalar subquery is used where SQL expects a single value, so it must return at most one row and one column. If it can return several rows, many databases raise an error.

Reduce it to one value with an aggregate (AVG/MAX/COUNT) or a lookup by a unique key.

from the unit →
Why is my subquery giving the overall average instead of a per-customer one?

Without a correlation condition (WHERE o2.customer_id = o.customer_id), the inner query has no reference to the outer row, so it always computes the global aggregate.

Add the condition that ties the inner query to the current outer row to make it per-customer.

from the unit →
Does turning a derived table into a CTE make my query faster?

Not necessarily. How CTEs are optimized or materialized varies by database and query, and a derived table and a CTE can produce similar plans.

Use the structure that expresses the logic most clearly, then measure if the query is performance-sensitive.

from the unit →
Should I use a subquery or a join?

Choose the form that expresses the requirement clearly. A subquery/CTE fits when a problem has stages or one calculation depends on another; a join fits when you need related columns side by side.

Modern optimizers often transform equivalent forms into similar plans, so favour clarity first.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P08Pattern · 7 minEXISTS & anti-joinsA correlated subquery that tests existence rather than a value.P04Pattern · 8 minAggregation & GROUP BYThe per-group totals that derived tables and CTEs build on.P07Pattern · 8 minSQL joinsHow a CTE becomes an input to a join to add related columns.
Up next · Pattern 11
Dates and timestamps

Next, we move into real-world date and timestamp data: filtering periods, date arithmetic, extraction, and boundary-safe time ranges.

Continue to P11 →Browse all patterns