How Subqueries and CTEs Work in SQL
As SQL queries become more complex, a single table is often not enough to produce the result we need.
Suppose we want to answer the following question:
Which orders have a total amount greater than the average order amount?
Before identifying those orders, the database must first calculate the average.
SELECT AVG(total_amount)
FROM orders;
It must then compare each order with that value.
SQL allows us to express both steps in a single statement:
SELECT
order_id,
customer_id,
total_amount
FROM orders
WHERE total_amount > (
SELECT AVG(total_amount)
FROM orders
);
The SELECT statement inside the parentheses is called a subquery.
A subquery is a query nested inside another SQL statement. It produces a value or a set of rows that the outer query can use.
Conceptually, the query works in two stages:
Inner query
→ Calculate the average order amount
Outer query
→ Return orders above that average
A Common Table Expression, or CTE, can express the same logic in a different form:
WITH order_average AS (
SELECT AVG(total_amount) AS average_amount
FROM orders
)
SELECT
o.order_id,
o.customer_id,
o.total_amount
FROM orders o
CROSS JOIN order_average a
WHERE o.total_amount > a.average_amount;
Both subqueries and CTEs help divide complex SQL into logical steps. However, they differ in where they are written, how their results are referenced, how clearly they communicate intent, and how the query optimizer may execute them.
There are two perspectives worth keeping in mind:
What intermediate result does the subquery or CTE logically produce?
How does the database physically calculate and use that result?
A subquery provides a result to another query
Suppose we have the following orders table:
order_idcustomer_idorder_statustotal_amount
| 5001 | 1001 | PAID | 120,000 |
| 5002 | 1001 | CANCELED | 80,000 |
| 5003 | 1002 | PAID | 150,000 |
| 5004 | 1003 | PAID | 50,000 |
Assume that the average order amount is 100,000.
The inner query returns one value:
SELECT AVG(total_amount)
FROM orders;
Result:
100000
The outer query uses that value as a comparison target:
SELECT
order_id,
customer_id,
total_amount
FROM orders
WHERE total_amount > (
SELECT AVG(total_amount)
FROM orders
);
The result contains the orders above the average:
order_idcustomer_idtotal_amount
| 5001 | 1001 | 120,000 |
| 5003 | 1002 | 150,000 |
A subquery can return several different shapes of data:
- a single value
- a single row
- multiple rows from one column
- multiple rows and columns that behave like a table
The shape returned by the subquery must match what the outer query expects.
Scalar subqueries return one value
A subquery that returns exactly one row and one column is called a scalar subquery.
SELECT
order_id,
total_amount,
(
SELECT AVG(total_amount)
FROM orders
) AS average_amount
FROM orders;
The average is displayed next to every order:
order_idtotal_amountaverage_amount
| 5001 | 120,000 | 100,000 |
| 5002 | 80,000 | 100,000 |
| 5003 | 150,000 | 100,000 |
| 5004 | 50,000 | 100,000 |
A scalar subquery can be used anywhere a single value is valid, including:
- the SELECT list
- a comparison in WHERE
- a condition in HAVING
- an expression in ORDER BY
If the subquery unexpectedly returns several rows, it cannot be treated as a single value.
Consider this query:
SELECT
order_id
FROM orders
WHERE customer_id = (
SELECT customer_id
FROM customers
);
If the customers table contains several customers, the inner query returns multiple IDs. The equality operator expects one value, so the query is invalid for that result shape.
When multiple values are expected, operators or constructs such as IN, EXISTS, or JOIN may be more appropriate.
Multi-row subqueries return a set of values
Suppose we want to find the customers who have at least one paid order.
The inner query can return the customer IDs associated with paid orders:
SELECT customer_id
FROM orders
WHERE order_status = 'PAID';
Its result might be:
1001
1002
1003
The outer query can use IN to test whether each customer ID belongs to that set:
SELECT
customer_id,
customer_name
FROM customers
WHERE customer_id IN (
SELECT customer_id
FROM orders
WHERE order_status = 'PAID'
);
Conceptually:
Customer 1001
→ Present in the inner result
→ Include the customer
Customer 1002
→ Present in the inner result
→ Include the customer
IN is a natural choice when a value should be compared with a set of possible values.
Special care is required with NOT IN, however.
SELECT
customer_id,
customer_name
FROM customers
WHERE customer_id NOT IN (
SELECT customer_id
FROM orders
);
If the subquery result contains NULL, SQL’s three-valued logic may produce an unexpected result. A comparison against a set containing an unknown value cannot always be evaluated as simply true or false.
When the requirement is to find rows for which no related row exists, NOT EXISTS often expresses the intention more safely and clearly.
A correlated subquery refers to the current outer row
The average-order subquery used earlier is independent of the outer query:
SELECT AVG(total_amount)
FROM orders;
It calculates the same average regardless of which outer order is currently being examined.
Now consider a different requirement:
Return orders whose amount is greater than the average for that particular customer.
Isaac’s orders must be compared with Isaac’s average. Sophie’s orders must be compared with Sophie’s average.
SELECT
o.order_id,
o.customer_id,
o.total_amount
FROM orders o
WHERE o.total_amount > (
SELECT AVG(o2.total_amount)
FROM orders o2
WHERE o2.customer_id = o.customer_id
);
The inner query refers to o.customer_id, which belongs to the outer query:
WHERE o2.customer_id = o.customer_id
This is called a correlated subquery.
Logically, it can be understood as follows:
Examine order 5001
→ customer_id is 1001
→ Calculate the average for customer 1001
→ Compare order 5001 with that average
Examine order 5002
→ customer_id is 1001
→ Compare it with customer 1001's average
Examine order 5003
→ customer_id is 1002
→ Compare it with customer 1002's average
This explanation can make it seem as though the database must always execute the inner query from the beginning for every outer row.
That is the logical meaning, but it is not necessarily the physical execution strategy.
The optimizer may transform the query by:
- rewriting it as a JOIN
- calculating grouped results first
- using a semi-join strategy
- caching reusable results
- materializing an intermediate result
A correlated subquery is therefore not automatically slow.
It can become expensive when the outer query produces many rows and the inner lookup must be repeated without an efficient access path. The only reliable way to judge its performance is to inspect the execution plan and measure the actual amount of work.
EXISTS asks whether a matching row exists
Consider the following requirement:
Return customers who have placed at least one order.
We could use a JOIN:
SELECT DISTINCT
c.customer_id,
c.customer_name
FROM customers c
JOIN orders o
ON o.customer_id = c.customer_id;
If Isaac has two orders, the JOIN initially produces two Isaac rows. DISTINCT is then needed to reduce them to one customer row.
EXISTS expresses the requirement more directly:
SELECT
c.customer_id,
c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
The inner query is not being used to return order data.
It answers one question for each customer:
Does an order exist for Isaac?
→ Yes
→ Include Isaac
Does an order exist for Olivia?
→ No
→ Exclude Olivia
The value 1 in SELECT 1 is not special. EXISTS checks only whether at least one matching row can be found.
The following form has the same logical meaning:
WHERE EXISTS (
SELECT *
FROM orders o
WHERE o.customer_id = c.customer_id
);
SELECT 1 is commonly used because it makes the intent clear: the actual column values are not needed.
NOT EXISTS finds rows without a relationship
To find customers who have never placed an order, use NOT EXISTS:
SELECT
c.customer_id,
c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
The question is:
Is there no order associated with this customer?
The same requirement can be expressed with a LEFT JOIN:
SELECT
c.customer_id,
c.customer_name
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
A modern optimizer may transform both forms into similar anti-join strategies.
Neither syntax is universally faster.
The better choice depends on:
- whether the SQL clearly expresses the business requirement
- how NULL values are handled
- how many rows the execution plan processes
- which indexes are available
- how the optimizer rewrites the query
When only existence or non-existence matters, EXISTS and NOT EXISTS usually communicate that intention clearly.
A subquery in FROM behaves like a temporary table
A subquery can also appear in the FROM clause.
Suppose we first calculate the number and total value of orders for each customer:
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM orders
GROUP BY customer_id;
That result can then be joined to the customer table:
SELECT
c.customer_id,
c.customer_name,
COALESCE(os.order_count, 0) AS order_count,
COALESCE(os.total_amount, 0) AS total_amount
FROM customers c
LEFT JOIN (
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM orders
GROUP BY customer_id
) os
ON os.customer_id = c.customer_id;
The subquery inside FROM behaves like a table within the scope of the statement.
It is often called a derived table or an inline view.
Conceptually:
Read orders
→ Aggregate them by customer
→ Name the result os
→ Join os with customers
This technique can be useful when directly joining several one-to-many tables would multiply rows unnecessarily.
Aggregating each source to the required grain before joining can reduce the size of the intermediate result.
However, writing a subquery in FROM does not necessarily mean that the database must create a complete temporary table first.
The optimizer may:
- merge the derived table into the outer query
- materialize it as an intermediate result
- push outer conditions into the subquery
- reorder joins
- eliminate unnecessary columns or rows
The written SQL structure and the physical execution plan may therefore differ.
A CTE is a named intermediate query
Deeply nested subqueries can become difficult to read.
Consider this query:
SELECT
c.customer_name,
os.order_count,
os.total_amount
FROM customers c
LEFT JOIN (
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM orders
WHERE order_status = 'PAID'
GROUP BY customer_id
) os
ON os.customer_id = c.customer_id
WHERE os.total_amount >= 100000;
A CTE allows us to give the intermediate result a meaningful name.
CTE stands for Common Table Expression.
WITH paid_order_summary AS (
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM orders
WHERE order_status = 'PAID'
GROUP BY customer_id
)
SELECT
c.customer_name,
s.order_count,
s.total_amount
FROM customers c
JOIN paid_order_summary s
ON s.customer_id = c.customer_id
WHERE s.total_amount >= 100000;
paid_order_summary is not a permanent table.
It is a named result set that exists only within the current SQL statement.
The query now exposes its logical stages:
Stage 1
Aggregate paid orders by customer
Stage 2
Join the summary with customers
Stage 3
Keep customers whose paid total is at least 100,000
The main advantage of a CTE is not necessarily speed.
Its most immediate benefit is that it can make the logical structure of a complex query easier to understand.
Multiple CTEs can express a data-processing pipeline
A statement may define several CTEs:
WITH paid_orders AS (
SELECT
order_id,
customer_id,
total_amount
FROM orders
WHERE order_status = 'PAID'
),
customer_totals AS (
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM paid_orders
GROUP BY customer_id
)
SELECT
c.customer_name,
t.order_count,
t.total_amount
FROM customer_totals t
JOIN customers c
ON c.customer_id = t.customer_id;
The data flow is visible from the names:
paid_orders
→ Paid orders only
customer_totals
→ Paid orders grouped by customer
Final SELECT
→ Add customer information
This can be easier to follow than several levels of nested parentheses.
However, dividing every small expression into a separate CTE can make the query fragmented. A CTE is most useful when it represents a meaningful logical stage rather than an arbitrary temporary step.
A CTE is not always a materialized temporary table
A common misunderstanding is that every CTE works like this:
Execute the WITH query
→ Store its result in a temporary table
→ Read that table in the final query
That may happen, but it is not guaranteed.
Depending on the database, version, and query structure, the optimizer may either inline the CTE or materialize it.
Inlining
The CTE is merged into the surrounding query and optimized as part of one larger execution plan.
CTE definition
+
Outer query
→ One integrated plan
When a CTE is inlined, outer predicates may be pushed into it, allowing irrelevant rows to be removed earlier.
Materialization
The CTE is evaluated first and stored as an intermediate result.
Execute CTE
→ Store intermediate result
→ Read it from the outer query
Materialization may be useful when a complex result is referenced repeatedly.
It can also be expensive when the intermediate result is large because the database must write, store, and read that result.
This is why neither of the following statements is universally true:
CTEs are faster than subqueries.
CTEs are slower because they always create temporary tables.
Performance depends on whether the optimizer inlines or materializes the CTE, how often it is referenced, and how large the intermediate result becomes.
Recursive CTEs build the next result from the previous one
A normal CTE defines one result set.
A recursive CTE refers back to its own previous result to produce the next level.
A common use case is hierarchical data.
Consider the following department table:
department_iddepartment_nameparent_department_id
| 1 | Headquarters | NULL |
| 2 | Development | 1 |
| 3 | Platform Team | 2 |
| 4 | AI Team | 2 |
| 5 | Sales | 1 |
Suppose we want to return Headquarters and every department beneath it.
WITH RECURSIVE department_tree AS (
SELECT
department_id,
department_name,
parent_department_id,
0 AS depth
FROM departments
WHERE department_id = 1
UNION ALL
SELECT
d.department_id,
d.department_name,
d.parent_department_id,
dt.depth + 1
FROM departments d
JOIN department_tree dt
ON d.parent_department_id = dt.department_id
)
SELECT
department_id,
department_name,
parent_department_id,
depth
FROM department_tree;
A recursive CTE has two main parts.
Anchor query
The anchor query selects the starting point:
SELECT
department_id,
department_name,
parent_department_id,
0 AS depth
FROM departments
WHERE department_id = 1
The starting department is Headquarters.
Recursive query
The recursive query finds rows related to the previous result:
SELECT
d.department_id,
d.department_name,
d.parent_department_id,
dt.depth + 1
FROM departments d
JOIN department_tree dt
ON d.parent_department_id = dt.department_id
Conceptually, the process is:
Iteration 1
Find Headquarters
Iteration 2
Find children of Headquarters
→ Development and Sales
Iteration 3
Find children of Development and Sales
→ Platform Team and AI Team
Stop when no new child rows are found
Recursive CTEs are useful for:
- organization charts
- category trees
- folder structures
- comments and replies
- bills of materials
- dependency relationships
- graph traversal
- number or date sequences
Recursive queries need a termination path
A recursive relationship can continue indefinitely if the data contains a cycle.
For example:
Department A reports to Department B
Department B reports to Department A
The traversal may repeatedly return to rows it has already visited.
Database systems often impose a maximum recursion depth, but the data model should also prevent invalid cycles when possible.
A depth condition can provide additional protection:
WHERE dt.depth < 10
However, a depth limit does not correct a fundamentally invalid hierarchy.
When using a recursive CTE, verify that:
- the anchor row is clearly defined
- every recursive step moves toward a new row
- cycles cannot cause infinite repetition
- the expected maximum depth is reasonable
- the recursive join column is indexed appropriately
Subqueries, JOINs, and CTEs are not competing features
The same business requirement can often be expressed in several ways.
Suppose we want to find customers who have placed an order.
JOIN
SELECT DISTINCT
c.customer_id,
c.customer_name
FROM customers c
JOIN orders o
ON o.customer_id = c.customer_id;
A JOIN is natural when columns from both sides are needed.
EXISTS
SELECT
c.customer_id,
c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
EXISTS is natural when only the presence of a related row matters.
CTE
WITH customers_with_orders AS (
SELECT DISTINCT customer_id
FROM orders
)
SELECT
c.customer_id,
c.customer_name
FROM customers c
JOIN customers_with_orders co
ON co.customer_id = c.customer_id;
A CTE can make the intermediate business concept explicit and reusable within the statement.
No form is always best.
RequirementNatural choice
| Return columns from related tables | JOIN |
| Test whether a related row exists | EXISTS or NOT EXISTS |
| Compare against one calculated value | Scalar subquery |
| Treat an aggregate result like a table | Derived table or CTE |
| Name and organize complex stages | CTE |
| Traverse hierarchical data | Recursive CTE |
SQL should first express the business requirement correctly and clearly. Performance should then be validated with the execution plan and realistic data.
How the optimizer may transform subqueries and CTEs
The written form of a query does not completely determine how it will be executed.
The optimizer may:
- rewrite a subquery as a JOIN
- transform IN into a semi-join
- transform EXISTS into a semi-join
- transform NOT EXISTS into an anti-join
- merge a derived table into the outer query
- inline a CTE
- materialize a subquery or CTE
- push outer predicates into an inner query
- reorder related operations
For this reason, a query should not be judged solely by whether it contains a subquery or CTE.
When investigating performance, check the following.
Number of rows produced by the inner query
If an inner query produces millions of rows before being joined or filtered, the intermediate result may become expensive.
Repetition in a correlated subquery
Determine how many outer rows are processed and how often the correlated lookup is performed.
Available indexes
The columns used to connect the outer and inner queries may require an index.
CREATE INDEX idx_orders_customer
ON orders (customer_id);
This can support a correlated condition such as:
WHERE o.customer_id = c.customer_id
Inlining or materialization
Check whether the derived table or CTE is merged into the surrounding query or stored as a separate intermediate result.
Estimated row counts
The optimizer must estimate how many rows each stage will produce.
Incorrect estimates can lead to an inefficient join order, repeated lookup strategy, or materialization decision.
MariaDB and MySQL provide EXPLAIN and related detailed plan formats for investigating these decisions.
EXPLAIN
SELECT
c.customer_id,
c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
The key performance question is not:
Does this query contain a subquery?
It is:
How many rows does the database read, and which access path does it use to produce the result?
Practical guidelines for using subqueries and CTEs
Subqueries and CTEs are powerful, but excessive nesting or unnecessary fragmentation can make SQL harder to understand.
Give intermediate results meaningful names
A CTE name should describe the business meaning of its result.
WITH paid_order_summary AS (...)
This communicates more than:
WITH temp1 AS (...)
Avoid repeating the same calculation
If the same filtering or aggregation logic appears repeatedly, a named CTE may make the statement easier to understand.
However, the fact that the CTE is written once does not guarantee that it will be physically calculated only once. Check the execution plan.
Watch the outer row count of a correlated subquery
A correlated lookup may be efficient for a small outer result with a useful inner index.
It may become expensive when millions of outer rows trigger repeated searches.
Use EXISTS when only existence matters
If no columns from the related rows are needed, JOIN may create unnecessary duplicate combinations.
EXISTS often expresses the intention more accurately.
Replace deeply nested logic with meaningful stages
Several levels of nested subqueries may become easier to read when converted into a small number of well-named CTEs.
Do not divide a query into too many CTEs
A separate CTE for every minor expression can make the overall data flow harder to follow.
Each CTE should represent a meaningful transformation or business concept.
Measure with realistic data
Do not compare two query forms using only one elapsed-time measurement.
Also inspect:
- rows read
- rows returned
- execution frequency
- intermediate result size
- temporary-storage use
- index access
- estimated rows versus actual rows
Key concepts for the Information Processing Engineer exam
ConceptKey idea
| Subquery | A SELECT statement contained inside another SQL statement |
| Scalar subquery | Returns one row and one column |
| Multi-row subquery | Returns several rows and can be used with IN, ANY, or ALL |
| Correlated subquery | Refers to a row from the outer query |
| EXISTS | Tests whether a matching row exists |
| NOT EXISTS | Tests whether no matching row exists |
| Inline view | A subquery used like a table in the FROM clause |
| CTE | A named result set defined with WITH |
| Recursive CTE | Repeatedly refers to its previous result |
| Materialization | Calculates and stores an intermediate result before reading it again |
Common exam topics include:
- the number of values or rows returned by a subquery
- single-row versus multi-row operators
- outer references in correlated subqueries
- EXISTS and NOT EXISTS
- basic CTE syntax
- anchor and recursive members of recursive CTEs
- unexpected behavior of NOT IN when NULL is present
A useful way to solve a subquery question is to evaluate the inner query first and then apply its result to the outer query.
Understanding complex SQL begins with intermediate results
The most important part of subqueries and CTEs is not the parentheses or the WITH keyword.
It is understanding what each query produces and how that result is used by the next stage.
Subquery
→ Provides a value or row set to an outer query
Correlated subquery
→ Produces a result based on the current outer row
EXISTS
→ Tests whether a related row exists
CTE
→ Gives a meaningful name to an intermediate result
Recursive CTE
→ Uses the previous result to produce the next level
Writing a subquery or CTE does not mean that the database will execute the SQL exactly in the order it appears.
The optimizer may rewrite the query as a JOIN, merge stages, push predicates inward, or materialize an intermediate result.
The relationship can be summarized as follows:
Subqueries and CTEs divide a complex query into logical intermediate results, while the optimizer reorganizes those results into a physical execution plan.
Good SQL is not simply the shortest statement or the statement with the most CTEs.
Good SQL has clear stages, predictable intermediate results, correct treatment of existence and relationships, limited unnecessary repetition, and an execution plan that performs well on real data.
Understanding subqueries and CTEs therefore means more than knowing how to nest a SELECT statement or write a WITHclause.
It means understanding the shape of every intermediate result, how it relates to the outer query, and how the database may transform it during optimization.
References
- ISO/IEC 9075-2:2023 — Information Technology — Database Languages SQL — Part 2: Foundation — ISO / IEC
- Subqueries — MariaDB
- Scalar Subqueries — MariaDB
- Row Subqueries — MariaDB
- Subqueries with IN and NOT IN — MariaDB
- Subqueries with EXISTS — MariaDB
- Subqueries with JOINs — MariaDB
- Subqueries in a FROM Clause: Derived Tables — MariaDB
- Common Table Expressions — MariaDB
- WITH — MariaDB
- Non-Recursive Common Table Expressions Overview — MariaDB
- Recursive Common Table Expressions Overview — MariaDB
- Subquery Optimizations — MariaDB
- Semi-Join Subquery Optimizations — MariaDB
- Subquery Cache — MariaDB
- Optimizations for Derived Tables — MariaDB
- Derived Table Merge Optimization — MariaDB
- Condition Pushdown into Derived Table Optimization — MariaDB
- EXPLAIN — MariaDB
- ANALYZE FORMAT=JSON — MariaDB
