A subquery is a query inside another query. It lets SQL answer a small question first—such as “what is the average?” or “does this customer have an order?”—and then use that answer in the main query.
First, identify the answer shape
You do not need to memorise every kind of subquery. Start with one question: what does the inner query return? The answer shape tells you where it can safely go.
One value, such as an average or count, fits beside a value with =, >, or in the SELECT list.
A list of values, such as customer IDs, fits with IN.
Yes or no fits with EXISTS or NOT EXISTS.
A small temporary table fits in FROM and needs an alias.
A scalar subquery returns one value
A scalar subquery returns exactly one cell. In the example below, the inner query calculates the average paid order: (3500 + 1800 + 6000) / 3. The outer query places that same benchmark beside every customer name.
The average is not copied into the query by hand. SQL keeps the calculation and the report together, so they always use the same data.
SQL BROWSER RUNNER
Show a shared paid-order benchmark
Use one scalar subquery to calculate the average paid amount.
Edit the query, predict the rows it will return, then run it.
Use a subquery as a filter value
The inner query finds the average paid order. The outer query keeps only paid orders higher than that average. This pattern works whenever the rule depends on a value calculated from the same data: above average, latest date, highest score, or a customer-specific limit.
Read it in two steps: “Find the average paid amount.” Then: “Return paid orders greater than that amount.”
SQL BROWSER RUNNER
Find paid orders above average
Compare each paid order with a value calculated by a nested query.
Edit the query, predict the rows it will return, then run it.
Use IN for a list; use EXISTS for a relationship
IN asks whether one value appears in a returned list. SQL first makes a list of customer IDs that have a paid order, then keeps customers whose ID is in that list. It is a readable choice when the inner query naturally returns one column.
SQL BROWSER RUNNER
Filter customers with IN
Use a returned set of customer IDs to filter the outer table.
CREATETABLE customers (customer_id INTEGERPRIMARYKEY, customer_name TEXTNOTNULL, city TEXTNOTNULL);CREATETABLE orders (order_id INTEGERPRIMARYKEY, customer_id INTEGERNOTNULL,statusTEXTNOTNULL, amount_cents INTEGERNOTNULL);INSERTINTO customers VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat');INSERTINTO orders VALUES(101,1,'paid',3500),(102,1,'paid',1800),(103,2,'paid',6000),(104,3,'pending',2200);SELECT customer_name, city
FROM customers
WHERE customer_id IN(SELECT customer_id FROM orders WHEREstatus='paid')ORDERBY customer_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
EXISTS does not need a list. It checks whether at least one matching row exists for the current outer row. The inner query can use SELECT 1 because SQL only needs a yes/no answer—not a specific column value.
SQL BROWSER RUNNER
Filter customers with EXISTS
Check whether a related paid order exists for each outer customer.
CREATETABLE customers (customer_id INTEGERPRIMARYKEY, customer_name TEXTNOTNULL, city TEXTNOTNULL);CREATETABLE orders (order_id INTEGERPRIMARYKEY, customer_id INTEGERNOTNULL,statusTEXTNOTNULL, amount_cents INTEGERNOTNULL);INSERTINTO customers VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat');INSERTINTO orders VALUES(101,1,'paid',3500),(102,1,'paid',1800),(103,2,'paid',6000),(104,3,'pending',2200);SELECT customer_name
FROM customers AS customer
WHEREEXISTS(SELECT1FROM orders AS order_row
WHERE order_row.customer_id = customer.customer_id AND order_row.status='paid')ORDERBY customer_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
A correlated subquery runs in the context of the outer row
A correlated subquery refers to a column from the outer query. order_row.customer_id = customer.customer_id is the connection: for each customer being examined, the inner query counts only that customer's paid orders.
Imagine SQL reading the customer list one row at a time. For Amina, it asks “how many paid orders belong to customer 1?” For Bilal, it asks the same question with customer 2. This is expressive, even though a grouped join or CTE may be easier to read for a larger report.
SQL BROWSER RUNNER
Count paid orders for each customer
Correlate the inner order query to the current customer row.
CREATETABLE customers (customer_id INTEGERPRIMARYKEY, customer_name TEXTNOTNULL, city TEXTNOTNULL);CREATETABLE orders (order_id INTEGERPRIMARYKEY, customer_id INTEGERNOTNULL,statusTEXTNOTNULL, amount_cents INTEGERNOTNULL);INSERTINTO customers VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat');INSERTINTO orders VALUES(101,1,'paid',3500),(102,1,'paid',1800),(103,2,'paid',6000),(104,3,'pending',2200);SELECT customer.customer_name,(SELECTCOUNT(*)FROM orders AS order_row WHERE order_row.customer_id = customer.customer_id AND order_row.status='paid')AS paid_order_count
FROM customers AS customer
ORDERBY customer.customer_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
NOT EXISTS is the direct way to ask for missing relationships. It finds customers for whom the inner query cannot find even one order.
SQL BROWSER RUNNER
Find customers with no orders
Use NOT EXISTS to express an anti-relationship safely.
CREATETABLE customers (customer_id INTEGERPRIMARYKEY, customer_name TEXTNOTNULL, city TEXTNOTNULL);CREATETABLE orders (order_id INTEGERPRIMARYKEY, customer_id INTEGERNOTNULL,statusTEXTNOTNULL, amount_cents INTEGERNOTNULL);INSERTINTO customers VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat');INSERTINTO orders VALUES(101,1,'paid',3500),(102,1,'paid',1800),(103,2,'paid',6000),(104,3,'pending',2200);SELECT customer.customer_name
FROM customers AS customer
WHERENOTEXISTS(SELECT1FROM orders AS order_row WHERE order_row.customer_id = customer.customer_id)ORDERBY customer.customer_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
A subquery in FROM creates a temporary table
A subquery in FROM is often called a derived table. First it makes a small result with one row per paying customer and that customer's total. Then the outer query joins that temporary result to customers to show readable names.
The alias totals is required because the outer query needs a name for the temporary table. Give derived tables names that describe what one row represents—here, one row is one customer's paid total.
SQL BROWSER RUNNER
Join to per-customer totals
Aggregate in a derived table, then join readable customer names.
CREATETABLE customers (customer_id INTEGERPRIMARYKEY, customer_name TEXTNOTNULL, city TEXTNOTNULL);CREATETABLE orders (order_id INTEGERPRIMARYKEY, customer_id INTEGERNOTNULL,statusTEXTNOTNULL, amount_cents INTEGERNOTNULL);INSERTINTO customers VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat');INSERTINTO orders VALUES(101,1,'paid',3500),(102,1,'paid',1800),(103,2,'paid',6000),(104,3,'pending',2200);SELECT customer.customer_name, totals.paid_cents
FROM customers AS customer
JOIN(SELECT customer_id,SUM(amount_cents)AS paid_cents
FROM orders WHEREstatus='paid'GROUPBY customer_id
)AS totals ON totals.customer_id = customer.customer_id
ORDERBY totals.paid_cents DESC;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Choose the clearest tool
Use a scalar subquery for one calculated value, such as an average or maximum.
Use IN when you have a simple list of one-column values.
Use EXISTS or NOT EXISTS when the question is “does a related row exist?”
Use a join when you need columns from both tables in the result.
Use a CTE when a calculation has several named steps or you want to reuse an intermediate result.
Independent lab: find above-average customers
Now combine the ideas. First make one total per paying customer. Next calculate the average of those totals. Finally keep only customers whose own paid total is higher than that average. Take it one layer at a time—the query is longer, but each layer answers a simple question.
SQL BROWSER RUNNER
Compare customer totals to their peer average
Combine correlated and derived subqueries in one report.
CREATETABLE customers (customer_id INTEGERPRIMARYKEY, customer_name TEXTNOTNULL, city TEXTNOTNULL);CREATETABLE orders (order_id INTEGERPRIMARYKEY, customer_id INTEGERNOTNULL,statusTEXTNOTNULL, amount_cents INTEGERNOTNULL);INSERTINTO customers VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat');INSERTINTO orders VALUES(101,1,'paid',3500),(102,1,'paid',1800),(103,2,'paid',6000),(104,3,'pending',2200);-- Keep customers whose paid total is above the average paid-customer total.SELECT customer.customer_name,(SELECTSUM(order_row.amount_cents)FROM orders AS order_row WHERE order_row.customer_id = customer.customer_id AND order_row.status='paid')AS paid_cents
FROM customers AS customer
WHERE(SELECTSUM(order_row.amount_cents)FROM orders AS order_row WHERE order_row.customer_id = customer.customer_id AND order_row.status='paid')>(SELECTAVG(customer_total)FROM(SELECTSUM(amount_cents)AS customer_total FROM orders WHEREstatus='paid'GROUPBY customer_id))ORDERBY paid_cents DESC;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Common mistakes to avoid
Using = when the inner query returns many rows. Use IN, EXISTS, or reduce it to one value instead.
Forgetting the connection in a correlated subquery. Without it, every outer row gets the same answer.
Using NOT IN with a list that may contain NULL. Prefer NOT EXISTS for missing-relationship checks.
Building several deeply nested steps when a CTE would give each step a clear name.
Lesson review
I can identify whether an inner query returns one value, a list, a yes/no answer, or a temporary table.
I can read a subquery from the innermost parentheses outward.
I can explain how a correlated subquery receives the current outer row.
I can choose between IN, EXISTS, a join, and a CTE for clarity.
I can recognise the common error of using a many-row result where SQL expects one value.
KNOWLEDGE CHECK
Check your subquery reasoning
Answer all ten questions, then use the explanations to revisit the example that needs another pass.