A subquery in the WHERE clause and a subquery in the FROM clause look similar when you glance at them. Both wrap a SELECT inside parentheses, and both feed a result into an outer query. What trips people up is that WHERE expects a value or a set of values, while FROM expects a table. Those two expectations pull subqueries in different directions, and mixing them up produces errors that don’t always explain themselves clearly.
This guide walks through both patterns using the same worked example, then runs through a checklist of the errors beginners hit most often: the symptom, the likely cause, and the fix.
What a Subquery Is, in One Sentence
A subquery is a SELECT statement nested inside another SQL statement, whose result is used by the outer statement as if it were a value, a list of values, or a temporary table.
That sentence covers both placements you’ll meet here. In a WHERE clause, the subquery supplies something to compare against. In a FROM clause, the subquery supplies rows and columns to select from — a derived table.
Sample Schema for the Examples
Everything below uses two small tables that mirror a common reporting setup: customers and their orders.
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
customer_name VARCHAR(100),
region VARCHAR(50)
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER,
order_total DECIMAL(10, 2),
order_date DATE
);
The goal throughout is to answer questions like: which customers have spent more than the average order? Or, what is the average order total per region? Both are natural subquery territory.
Subqueries in the WHERE Clause
A WHERE-clause subquery produces a result the outer query compares against. The comparison operator you use determines what shape that result must take.
Scalar subqueries: one row, one column
When your comparison expects a single value — using =, >, <, >=, or <= — the subquery must return exactly one row and exactly one column. If it returns more, the database raises an error like “subquery returns more than one row.” If it returns zero rows, the comparison becomes NULL and no rows survive.
SELECT customer_id, order_total
FROM orders
WHERE order_total > (
SELECT AVG(order_total) FROM orders
);
The subquery here returns a single number — the average order total across the whole table. The outer query then filters orders above that number. This is one of the cleanest uses of a subquery, because the threshold is computed rather than hard-coded.
IN subqueries: many rows, one column
When your comparison is IN or NOT IN, the subquery may return any number of rows, but still only one column.
SELECT customer_name
FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_total > 500
);
This reads as: select customers whose ID appears in the list of customer IDs who placed an order over 500. The inner query returns a list of IDs; the outer query checks membership.
A warning about NOT IN with NULLs
NOT IN behaves unexpectedly when the subquery’s result set contains NULL. Consider:
SELECT customer_name
FROM customers
WHERE customer_id NOT IN (
SELECT customer_id FROM orders
);
If any row in orders has a NULL customer_id, this query returns zero rows — even if many customers have no orders at all. The reason is that x NOT IN (NULL, ...) evaluates to NULL rather than TRUE, so no row passes the filter. The fix is to add WHERE customer_id IS NOT NULL inside the subquery, or to rewrite using NOT EXISTS, which handles NULLs the way you’d expect.
EXISTS: asking whether any row matches
EXISTS returns a boolean — TRUE if the subquery produces at least one row, FALSE otherwise. The subquery’s selected column does not matter; it’s the existence of rows that counts.
SELECT customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
Note the correlation: the inner query references c.customer_id from the outer query. This is a correlated subquery — it re-executes for each candidate row of the outer query. That makes it powerful and, on large tables, potentially slow. A query written with EXISTS often has an equivalent JOIN formulation that performs better on modern optimizers, which the next section touches on.
Subqueries in the FROM Clause (Derived Tables)
A FROM-clause subquery is treated as a table for the duration of the outer query. It must produce a complete row-and-column result, and it must be given an alias — most databases reject a derived table without one.
The most common shape is two steps: aggregate in the inner query, then join or filter on that aggregation in the outer query.
SELECT region, AVG(region_avg) AS overall_region_avg
FROM (
SELECT c.region,
c.customer_id,
AVG(o.order_total) AS region_avg
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.region, c.customer_id
) AS per_customer
GROUP BY region;
The inner query returns a table with one row per customer (region, customer_id, and their average order total). The outer query then treats that table like any other and aggregates again. This two-tier pattern is where derived tables earn their place — you can’t reference an alias defined in the same SELECT’s aggregate in a WHERE clause, so wrapping it in a subquery is the standard workaround.
Naming the derived table matters
Some databases allow omitting the alias for a derived table; most do not. When in doubt, always name it. Names like t, sub, or per_customer are fine as long as they describe the role.
One Concrete Example End to End
Here is a small task: “For each region, find customers whose total spending exceeds that region’s average customer spending.”
Step 1 — inner query: total spend per customer.
SELECT c.customer_id, c.region, SUM(o.order_total) AS total_spend
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.region
Run this on its own first. Confirm it returns one row per customer with a summed spend column.
Step 2 — add region averages.
Wrap the first query in a FROM clause so you can reference both the per-customer total and the per-region average in the outer SELECT.
SELECT f.customer_id, f.region, f.total_spend, r.region_avg
FROM (
SELECT c.customer_id, c.region, SUM(o.order_total) AS total_spend
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.region
) AS f
JOIN (
SELECT region, AVG(total_spend) AS region_avg
FROM (
SELECT c.customer_id, c.region, SUM(o.order_total) AS total_spend
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.region
) AS inner_totals
GROUP BY region
) AS r ON r.region = f.region
WHERE f.total_spend > r.region_avg;
Step 3 — verify. The result should list only customers whose total spend is strictly above their region’s average. A quick sanity check: for each region in the output, the minimum total_spend should be strictly greater than the region_avg shown on that region’s rows, and no customer should appear twice.
Note the duplication of the per-customer aggregation in both derived tables. That’s real cost, and it’s the main reason derived tables get reused via CTEs in more complex queries, which the next section touches.
Choosing Between a Subquery and a JOIN
Many WHERE-clause subqueries can be rewritten as JOINs, and vice versa. Knowing when each reads more clearly and performs better saves time later.
A subquery in WHERE reads better when:
- The subquery is only used for filtering, and you don’t want its columns in the output.
- The condition is “is/isn’t a member of this set,” which maps naturally to
IN,NOT IN, orEXISTS. - The filter compares against an aggregate of the same table (like “above average”).
A JOIN reads better when:
- You need columns from both tables in the output.
- The relationship is one-to-many and you want to see the multiplication (one customer row per order).
- Performance matters and an optimizer can decorrelate a JOIN more efficiently than a correlated EXISTS.
When NOT to use a subquery at all:
- If the subquery returns a very large set and you’re using
IN, some engines handleEXISTSor a JOIN better because they can short-circuit. Measure before assuming; the difference can swing either direction depending on the optimizer. - If you find yourself writing the same derived table twice (as in the region example above), a CTE — the
WITH x AS (...)form — usually reads better and avoids the duplication. - If a scalar subquery in the SELECT list is evaluated per row and calls a slow function, move the logic into a JOIN with a pre-aggregated derived table instead.
Troubleshooting Checklist: Symptom to Fix
Symptom: “Subquery returns more than one row”
Cause: You used a comparison operator (=, >, <) that expects a scalar, but the subquery returns multiple rows.
Fix: Either narrow the subquery with a WHERE or LIMIT 1, or switch the operator to IN if you intend to compare against a set.
-- Broken: subquery may return many rows
WHERE order_total = (SELECT order_total FROM orders WHERE customer_id = 7)
-- Fixed with IN
WHERE order_total IN (SELECT order_total FROM orders WHERE customer_id = 7)
Symptom: “Every row” or “no rows” when you expected some
Cause: The subquery returned zero rows, so the comparison evaluates to NULL, and NULL passes no filter (and fails all NOT IN filters).
Fix: Confirm the subquery returns rows by running it standalone. If empty is a valid outcome, restructure the logic — EXISTS or an outer JOIN with a COALESCE often works better.
Symptom: “Subquery in FROM has no alias”
Cause: The derived table was not given a name.
Fix: Add AS some_name immediately after the closing parenthesis. This is a hard error in PostgreSQL, MySQL, and SQL Server.
Symptom: Query runs but is very slow
Cause: A correlated subquery is being re-evaluated per row of the outer query, and the outer query is large.
Fix: Check the query plan. If the correlated subquery dominates the cost, try rewriting it as a JOIN against a pre-aggregated derived table. On many engines this lets the optimizer hash or sort once rather than re-run the inner query repeatedly.
Symptom: Column “used in GROUP BY or aggregate” error
Cause: You tried to reference an aggregate alias you just defined in the same SELECT’s WHERE clause. WHERE runs before aggregation, so aliases defined in the select list aren’t visible yet.
Fix: Wrap the aggregation in a FROM-clause subquery, then filter in the outer query — as shown in the region example above.
A Closing Note on Readability
Subqueries are a workhorse tool, and they make some problems dramatically clearer to write than any JOIN-based alternative. The average-order filter is the classic case: it states the intent directly. Once a query nests three levels deep, though, readability drops fast. When that happens, the same logic usually converts to a chain of CTEs with WITH, which names each intermediate result and produces a query you can debug one block at a time.
The right rule of thumb: reach for a subquery when the nesting is shallow and the intent is obvious. Reach for a CTE or a JOIN when the query grows past one level of nesting, or when the same derived table would appear twice.
Readers often write in with a query where the subquery works in isolation but misbehaves when nested. If you describe the tables involved and the condition you’re trying to enforce, the cause is usually one of the five symptoms above — paste the query and it can be narrowed down.