A subquery is another SELECT statement written inside a SQL statement. A scalar subquery is a subquery that "always returns a single value (one row, one column)," and it can be freely embedded anywhere a value is allowed, such as the SELECT clause or the WHERE clause.
SELECT col, (SELECT AVG(col) FROM tbl) AS avg_val -- attach the same aggregate value to every row FROM tbl;
From the orders table, for orders whose status is 'completed', retrieve order_id, user_id, amount, and additionally attach the average amount of all completed orders and the difference from that average as columns. Sort by amount in descending order.
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
Expected output (completed only, amount descending):
| order_id | user_id | amount | avg_amount | diff_from_avg |
|---|---|---|---|---|
| 102 | 1 | 12000 | 8875 | 3125 |
| 104 | 3 | 9500 | 8875 | 625 |
| 101 | 1 | 8000 | 8875 | -875 |
| 105 | 3 | 6000 | 8875 | -2875 |
※ avg_amount = (8000+12000+9500+6000)÷4 = 8875. 103 and 106 are excluded.
SELECT order_id, user_id, amount, (SELECT ROUND(AVG(amount)) -- scalar SQ: always returns a single value FROM orders WHERE status = 'completed' ) AS avg_amount, amount - (SELECT ROUND(AVG(amount)) -- can also be used inside an arithmetic expression FROM orders WHERE status = 'completed' ) AS diff_from_avg FROM orders WHERE status = 'completed' -- filter in the outer query ORDER BY amount DESC; /* Execution order (SQL's logical evaluation order): 1. Scalar SQ evaluated first → SELECT ROUND(AVG(amount)) FROM orders WHERE status='completed' → Result: 8875 2. FROM orders → read all rows 3. WHERE status='completed' → narrow to 4 rows 4. SELECT → attach avg_amount=8875 and diff_from_avg to each row 5. ORDER BY amount DESC → sort by amount descending */
LEGEND
① Run scalar SQ
SELECT ROUND(AVG(amount)) FROM orders WHERE status='completed'The subquery is evaluated first: from the 4 rows with status='completed' it computes the average (8000+12000+9500+6000)÷4 = 8875 and returns it as a single value. This value is attached to every row of the outer query.| order_id | amount | status | SQ target? |
|---|---|---|---|
| 101 | 8000 | completed | ✓ AVG target |
| 102 | 12000 | completed | ✓ AVG target |
| 103 | 3500 | pending | ✗ excluded |
| 104 | 9500 | completed | ✓ AVG target |
| 105 | 6000 | completed | ✓ AVG target |
| 106 | 2000 | cancelled | ✗ excluded |
(SELECT AVG(amount)...) scans the table only once and passes the same value 8875 to every row.WITH avg_val AS (...) SELECT ..., avg_val.v FROM orders, avg_val lets it be evaluated only once (see Q9).(SELECT user_id FROM users WHERE plan='premium') returns multiple rows, so it cannot be used as a scalar SQ and causes a runtime error. A scalar SQ must always be reduced to a single value with an aggregate function or LIMIT 1.AVG(amount) OVER (). However, in older DB environments where window functions are unavailable (e.g. MySQL 5.x), the scalar SQ is the only option. On PostgreSQL, MySQL 8.0+, and the like, window functions are recommended, but a scalar SQ is a portable way of writing that works on any RDBMS.WHERE col IN (subquery) filters rows using the list of values that the subquery returns. The key point is that instead of a fixed list IN (1, 3), you can generate a list that changes dynamically according to the state of the table.
SELECT * FROM orders WHERE user_id IN ( SELECT user_id FROM users WHERE plan = 'premium' -- the inner query generates a dynamic list );
user_id, the inner query must always SELECT exactly one column.From the orders table, retrieve the orders of users whose plan is 'premium' as order_id, user_id, amount, status, ordered_at. Sort by ordered_at in descending order.
| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 8000 | completed | 2024-05-01 |
| 102 | 1 | 12000 | completed | 2024-05-20 |
| 103 | 2 | 3500 | pending | 2024-05-10 |
| 104 | 3 | 9500 | completed | 2024-05-15 |
| 105 | 3 | 6000 | completed | 2024-05-25 |
| 106 | 4 | 2000 | cancelled | 2024-05-08 |
Expected output (premium users only, ordered_at descending):
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 105 | 3 | 6000 | completed | 2024-05-25 |
| 102 | 1 | 12000 | completed | 2024-05-20 |
| 104 | 3 | 9500 | completed | 2024-05-15 |
| 101 | 1 | 8000 | completed | 2024-05-01 |
※ 103 (free) and 106 (standard) are excluded
SELECT order_id, user_id, amount, status, ordered_at FROM orders WHERE user_id IN ( -- IN: keep rows matching the subquery result list SELECT user_id -- always return exactly one column FROM users WHERE plan = 'premium' -- dynamically generate the list of premium user_ids ) ORDER BY ordered_at DESC; /* Execution order (SQL's logical evaluation order): 1. Evaluate the subquery → SELECT user_id FROM users WHERE plan='premium' → Result list: [1, 3] 2. FROM orders → read all rows 3. WHERE user_id IN (1, 3) → narrow to 4 rows 4. SELECT ... → select the needed columns 5. ORDER BY ordered_at DESC → sort by date descending */
LEGEND
① Run subquery
SELECT user_id FROM users WHERE plan = 'premium'The subquery runs first. From users it takes the user_ids where plan='premium', generating the value list [1, 3].| user_id | name | plan | in list? |
|---|---|---|---|
| 1 | Tanaka Taro | premium | ✓ added to [1,3] |
| 2 | Sato Hanako | free | ✗ excluded |
| 3 | Suzuki Ichiro | premium | ✓ added to [1,3] |
| 4 | Yamada Jiro | standard | ✗ excluded |
| 5 | Ito Saburo | free | ✗ excluded |
IN (1, 3), but the important point is that it changes dynamically according to the state of the table.WHERE user_id IN (SELECT user_id FROM users WHERE plan='premium') returns the same result as INNER JOIN users ON orders.user_id = users.user_id WHERE users.plan='premium'. If you do not need to output the joined table's columns in SELECT, the IN subquery states the intent more clearly.WHERE col IS NOT NULL in the inner query.SELECT user_id, name FROM users causes a syntax error. SELECT only the single column that matches the IN comparison target.WHERE col NOT IN (subquery) returns the rows not contained in the subquery's result. This is the pattern for finding rows that do not exist in another table, such as "users who have never ordered" or "targets of an undelivered campaign."
SELECT * FROM users WHERE user_id NOT IN ( SELECT DISTINCT user_id FROM orders WHERE user_id IS NOT NULL -- prevent NULL contamination (important) );
WHERE col IS NOT NULL to the inner query, or use NOT EXISTS (Q6), which is recommended.From the users table, retrieve the users who have never placed an order. Return user_id, name, plan in ascending user_id order.
| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
| order_id | user_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 2 |
| 104 | 3 |
| 105 | 3 |
| 106 | 4 |
Expected output (users with no orders only):
| user_id | name | plan |
|---|---|---|
| 5 | Ito Saburo | free |
※ DISTINCT user_id = [1,2,3,4]. Only user_id=5 is outside the list.
SELECT user_id, name, plan FROM users WHERE user_id NOT IN ( -- keep only rows not contained in the list SELECT DISTINCT user_id -- remove duplicates with DISTINCT (efficiency) FROM orders WHERE user_id IS NOT NULL -- prevent NULL contamination (avoid the NOT IN trap) ) ORDER BY user_id; /* Execution order (SQL's logical evaluation order): 1. Evaluate the subquery → SELECT DISTINCT user_id FROM orders WHERE user_id IS NOT NULL → Result list: [1, 2, 3, 4] 2. FROM users → read all rows 3. WHERE user_id NOT IN (1,2,3,4) → only the row with user_id=5 remains 4. SELECT user_id, name, plan → select the needed columns 5. ORDER BY user_id → sort by user_id ascending */
LEGEND
① Run subquery
SELECT DISTINCT user_id FROM orders WHERE user_id IS NOT NULLFrom orders, get the user_ids that have orders, without duplicates. The result list [1,2,3,4] is generated. user_id=5 has no row in orders.| order_id | user_id (after DISTINCT) | added to list |
|---|---|---|
| 101 | 1 | ✓ added to list |
| 102 | 1 | (duplicate → ignored) |
| 103 | 2 | ✓ added to list |
| 104 | 3 | ✓ added to list |
| 105 | 3 | (duplicate → ignored) |
| 106 | 4 | ✓ added to list |
DISTINCT keeps the list small and lowers the evaluation cost.WHERE NOT EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.user_id). NOT EXISTS works as expected even when NULL is present (detailed in Q6).orders.user_id had even one NULL row. The NOT IN list would become [1, NULL, 2, 3, 4]. In SQL's three-valued logic, 5 NOT IN (1, NULL, 2, 3, 4) is evaluated as UNKNOWN, so user_id=5 is also excluded and the result becomes 0 rows.When you want to apply a WHERE condition to a result aggregated with GROUP BY, use a "derived table (inline view)"—a subquery written in the FROM clause. Because a normal WHERE clause is evaluated before GROUP BY, you cannot use post-aggregation columns directly in WHERE. A derived table removes this restriction.
SELECT * FROM ( SELECT col, SUM(amount) AS total -- aggregate on the inside FROM table GROUP BY col ) AS sub -- always give it an alias with AS WHERE total >= 10000; -- can filter by the post-aggregation value
From the orders table, aggregate the total amount per user targeting only orders whose status is 'completed', and return only the users whose total amount is 15,000 or more, in descending order of total amount. The columns to retrieve are user_id, total_amount.
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 3 | 6000 | completed |
| 106 | 4 | 2000 | cancelled |
Expected output (completed total ≥ 15000, total descending):
| user_id | total_amount |
|---|---|
| 1 | 20000 |
| 3 | 15500 |
※ user_id 2 (pending) and 4 (cancelled) are excluded by the inner WHERE. The post-aggregation outer WHERE does the final filtering.
SELECT user_id, total_amount FROM ( -- subquery in the FROM clause (derived table) SELECT user_id, SUM(amount) AS total_amount -- name the aggregate column with AS (referenced from outside) FROM orders WHERE status = 'completed' -- filter in the inner query before aggregating GROUP BY user_id ) AS user_totals -- always give the derived table an AS alias WHERE total_amount >= 15000 -- can filter by the aggregate column name (impossible in a normal WHERE) ORDER BY total_amount DESC; /* Execution order (SQL's logical evaluation order): 1. Evaluate the inner query (derived table) → aggregate completed by user_id 2. FROM ... AS user_totals → reference the aggregated result 3. WHERE total_amount threshold → filter by the condition 4. SELECT user_id, total_amount → select columns 5. ORDER BY total_amount DESC → total descending */
LEGEND
① Inner query (GROUP BY)
SELECT user_id, SUM(amount) FROM orders WHERE status='completed' GROUP BY user_idThe inner subquery runs first. It groups the 4 completed rows by user_id and computes the totals. user_id=2 (pending) and 4 (cancelled) are excluded by the inner WHERE.| order_id | user_id | amount | status | group |
|---|---|---|---|---|
| 101 | 1 | 8000 | completed | user1 group |
| 102 | 1 | 12000 | completed | user1 group |
| 103 | 2 | 3500 | pending | excluded |
| 104 | 3 | 9500 | completed | user3 group |
| 105 | 3 | 6000 | completed | user3 group |
| 106 | 4 | 2000 | cancelled | excluded |
AS alias (omitting it causes an error).WHERE SUM(amount) >= 15000 (error). By moving it into a derived table and filtering in the outer WHERE, you achieve the same thing, and you can also handle flexible processing such as JOINing with other tables.WITH user_totals AS (SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE status='completed' GROUP BY user_id) SELECT * FROM user_totals WHERE total_amount >= 15000 reduces the nesting depth (see Q9).AS user_totals to the FROM-clause subquery, many databases raise an error (required on both MySQL and PostgreSQL). Always give a derived table an alias.WHERE orders.status = ..., causes an error. From the outside you can only access the column names the derived table returned (such as user_totals.total_amount).HAVING SUM(amount) >= 15000 is shorter to write. On the other hand, for complex cases such as JOINing the aggregated result with another table, filtering by combining multiple aggregate columns, or aggregating the aggregated result further (double aggregation), a derived table (or CTE) is needed. In practice, a natural judgment is to "first try HAVING, and migrate to a derived table/CTE once it gets complex."WHERE EXISTS (subquery) keeps a row if the subquery "returns even one row." What it returns does not matter—it only checks whether a row exists—so SELECT 1 is enough on the inside.
SELECT * FROM users u WHERE EXISTS ( SELECT 1 -- the value can be anything; only row existence is checked FROM orders o WHERE o.user_id = u.user_id -- a "correlated subquery" referencing an outer column );
From the users table, retrieve the users who have at least one order with status 'completed'. Return user_id, name, plan in ascending user_id order.
| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
| order_id | user_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 1 | completed |
| 103 | 2 | pending |
| 104 | 3 | completed |
| 105 | 3 | completed |
| 106 | 4 | cancelled |
Expected output (has completed, user_id ascending):
| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 3 | Suzuki Ichiro | premium |
※ user_id 2 (pending only), 4 (cancelled only), and 5 (no orders) are excluded
SELECT user_id, name, plan FROM users u -- alias u for the outer query WHERE EXISTS ( -- TRUE if the SQ returns one or more rows SELECT 1 -- the value can be anything (1, *, 'x' are all equivalent) FROM orders o WHERE o.user_id = u.user_id -- correlated SQ referencing the outer u.user_id AND o.status = 'completed' -- passes if there is at least one completed ) ORDER BY user_id; /* Execution order (SQL's logical evaluation order): 1. FROM users u → process one row at a time 2. EXISTS(...) → run the subquery per user 3. SELECT user_id, name, plan → select the passing rows 4. ORDER BY user_id → user_id ascending */
LEGEND
① FROM users
FROM users uProcess all 5 rows of the users table one at a time. For each row, evaluate the EXISTS subquery.| user_id | name | plan |
|---|---|---|
| 1 | Tanaka Taro | premium |
| 2 | Sato Hanako | free |
| 3 | Suzuki Ichiro | premium |
| 4 | Yamada Jiro | standard |
| 5 | Ito Saburo | free |
SELECT 1 to make clear that "returning a value has no meaning."o.user_id = u.user_id inside EXISTS, where the inner query references an outer column (u.user_id), is called a correlated subquery. The inner query runs each time the outer query processes a row.SELECT * works, but since EXISTS uses no value at all it is wasteful. SELECT 1 or SELECT NULL is conventional and clarifies the intent.