SQL Subqueries
A subquery is a query inside another query. It lets you use one result as an input to the next question: “employees who earn more than the average”, “customers who placed an order in March”, “the average order value per customer”. This lesson shows the three places a subquery can go, and runs each one step by step.
- Use a scalar subquery that returns one value
- Filter with
IN (subquery) - Query a derived table in
FROM - Add a subquery column in
SELECT, and know when to use a JOIN instead
Three places for a subquery
| Where | Returns | Example use |
|---|---|---|
WHERE / HAVING | one value, or a list | above-average salaries, customers who ordered |
FROM (derived table) | a whole table | aggregate first, then filter or join |
SELECT list | one value per row | the company average next to each salary |
The inner query is always written in parentheses.
Sample data
| emp_id | first_name | dept_id | salary |
|---|---|---|---|
| 100 | Meera | NULL | 250000 |
| 101 | Asha | 1 | 125000 |
| 102 | Rahul | 1 | 98000 |
| 103 | Priya | 1 | 72000 |
| 104 | Karan | 1 | 72000 |
| 105 | Vikram | 2 | 105000 |
| 106 | Neha | 2 | 54000 |
| 107 | Arjun | 2 | 48000 |
| 108 | Kavya | 3 | 88000 |
| 109 | Rohan | 3 | 45000 |
| 110 | Sneha | 4 | 78000 |
| 111 | Amit | 1 | 25000 |
Scalar subquery: one value
Employees who earn more than the company average:
SELECT first_name, salaryFROM employeesWHERE salary > (SELECT AVG(salary) FROM employees)ORDER BY salary DESC;| first_name | salary |
|---|---|
| Meera | 250000 |
| Asha | 125000 |
| Vikram | 105000 |
| Rahul | 98000 |
- MySQL runs the inner query first:
AVG(salary)is 88,333.3333. - The outer query becomes
WHERE salary > 88333.3333. - Four employees earn more: Meera, Asha, Vikram and Rahul.
Why not WHERE salary > AVG(salary)? Because aggregates are not allowed in WHERE; the subquery computes the average separately.
SELECT first_name FROM employees WHERE salary > AVG(salary);ERROR 1111 (HY000): Invalid use of group function
The highest-paid person in Engineering:
SELECT first_name, salaryFROM employeesWHERE dept_id = 1 AND salary = (SELECT MAX(salary) FROM employees WHERE dept_id = 1);| first_name | salary |
|---|---|
| Asha | 125000 |
IN subquery: a list of values
Customers who have placed at least one delivered order:
SELECT customer_id, customer_nameFROM customersWHERE customer_id IN ( SELECT customer_id FROM orders WHERE status = 'Delivered')ORDER BY customer_id;| customer_id | customer_name |
|---|---|
| 1 | Ananya Sharma |
| 2 | Rohit Kumar |
| 3 | Fatima Khan |
| 4 | David Fernandes |
| 5 | Ishita Bose |
- The inner query lists the
customer_idof every delivered order: 1, 2, 1, 4, 2, 3, 5. INchecks each customer against that list; repeats in the list do not matter.- Five customers match.
NOT IN asks the opposite question. Products that have never been ordered:
SELECT product_nameFROM productsWHERE product_id NOT IN (SELECT product_id FROM order_items);| product_name |
|---|
| Monitor Arm |
NOT IN returns nothing if the subquery returns a NULL. order_items.product_id is part of the primary key and cannot be NULL, so it is safe here; in general prefer NOT EXISTS.
Subquery in FROM: derived tables
A subquery in FROM produces a temporary table that the outer query reads. It must have an alias. Average order value per customer:
SELECT t.customer_id, COUNT(*) AS orders, ROUND(AVG(t.total)) AS avg_order_valueFROM ( SELECT o.order_id, o.customer_id, SUM(oi.quantity * p.price) AS total FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id GROUP BY o.order_id, o.customer_id) AS tGROUP BY t.customer_idORDER BY avg_order_value DESC;| customer_id | orders | avg_order_value |
|---|---|---|
| 3 | 2 | 11249 |
| 1 | 3 | 10013 |
| 2 | 2 | 4899 |
| 5 | 2 | 3321 |
| 4 | 1 | 3286 |
- The inner query computes the total of each order: one row per order.
AS tnames that result so the outer query can use it like a table.- The outer query groups the order totals by customer and averages them.
- Two levels of grouping (lines → orders → customers) are exactly what derived tables are for.
Subquery in SELECT
Show each salary next to the department average:
SELECT e.first_name, e.dept_id, e.salary, (SELECT ROUND(AVG(x.salary)) FROM employees x WHERE x.dept_id = e.dept_id) AS dept_avgFROM employees eWHERE e.dept_id IN (1, 2)ORDER BY e.dept_id, e.salary DESC;| first_name | dept_id | salary | dept_avg |
|---|---|---|---|
| Asha | 1 | 125000 | 78400 |
| Rahul | 1 | 98000 | 78400 |
| Priya | 1 | 72000 | 78400 |
| Karan | 1 | 72000 | 78400 |
| Amit | 1 | 25000 | 78400 |
| Vikram | 2 | 105000 | 69000 |
| Neha | 2 | 54000 | 69000 |
| Arjun | 2 | 48000 | 69000 |
- For each outer row, the subquery calculates the average salary of that row’s department.
- Because it refers to
e.dept_idfrom the outer query, it is a correlated subquery. - The same result is usually written more efficiently with a window function:
AVG(salary) OVER (PARTITION BY dept_id).
Subquery in HAVING
Departments whose average salary is above the company average:
SELECT dept_id, ROUND(AVG(salary)) AS avg_salaryFROM employeesWHERE dept_id IS NOT NULLGROUP BY dept_idHAVING AVG(salary) > (SELECT AVG(salary) FROM employees);- The subquery returns the company average, 88,333.3333, which includes the CEO’s 250,000.
- Each department’s average is compared with it: Engineering 78,400, Sales 69,000, Marketing 66,500, HR 78,000.
- None is higher, so the result is an Empty set. One very high salary pulls the company average above every department: always check for outliers before trusting an average.
Subquery or JOIN?
Many subqueries can be rewritten as joins, and MySQL 8 often converts them internally anyway. Choose for clarity:
- “rows that have / do not have a match” reads well as
IN,EXISTSorNOT EXISTS; - when you need columns from the other table, use a join;
- when a query has several steps, a CTE is easier to read than nested subqueries.
Exercise: Cheaper than average
List products that cost less than the average product price.
| product_name | price |
|---|---|
| Gel Pens | 199 |
| Notebook Pack | 249 |
| Wireless Mouse | 799 |
| Desk Lamp | 1499 |
| USB-C Hub | 1999 |
| Monitor Arm | 2799 |
| Mechanical Keyboard | 3499 |
Hint 1
WHERE price < (SELECT AVG(price) FROM products).
SELECT product_name, priceFROM productsWHERE price < (SELECT AVG(price) FROM products)ORDER BY price;| product_name | price |
|---|---|
| Gel Pens | 199 |
| Notebook Pack | 249 |
| Wireless Mouse | 799 |
| Desk Lamp | 1499 |
| USB-C Hub | 1999 |
| Monitor Arm | 2799 |
| Mechanical Keyboard | 3499 |
Exercise: Orders from Mumbai customers
List the orders of customers who live in Mumbai, using a subquery.
| order_id | customer_id | order_date |
|---|---|---|
| 1001 | 1 | 2025-01-05 |
| 1003 | 1 | 2025-02-02 |
| 1008 | 1 | 2025-04-28 |
Hint 1
customer_id IN (SELECT customer_id FROM customers WHERE city = 'Mumbai').
SELECT order_id, customer_id, order_dateFROM ordersWHERE customer_id IN (SELECT customer_id FROM customers WHERE city = 'Mumbai');| order_id | customer_id | order_date |
|---|---|---|
| 1001 | 1 | 2025-01-05 |
| 1003 | 1 | 2025-02-02 |
| 1008 | 1 | 2025-04-28 |
Quick quiz
1. Which runs first in WHERE salary > (SELECT AVG(salary) FROM employees)?
A non-correlated subquery is evaluated once, first.
2. What must a subquery in FROM have?
MySQL requires every derived table to have a name.
3. A scalar subquery returns two rows. What happens?
A scalar subquery must return at most one value.
Subquery FAQ
What is a subquery in SQL?
A subquery (inner query) is a SELECT nested inside another statement, used to supply a value, a list or a table to the outer query.
What is the difference between a subquery and a join?
A join combines columns of tables row by row. A subquery computes a separate result that the outer query uses. Many problems can be solved either way.
What is a derived table?
A subquery in the FROM clause. It behaves like a temporary table for the duration of the query and must have an alias.
Can a subquery return multiple columns?
Yes, in FROM, or in comparisons such as WHERE (a, b) IN (SELECT x, y FROM ...).