SQL Tutorial › Subqueries & CTEs › Subqueries

SQL Subqueries

IntermediateLesson 40 of 6912 min read8 queries run on MySQL 8.4

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.

Advertisement
In this lesson
  • 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

WhereReturnsExample use
WHERE / HAVINGone value, or a listabove-average salaries, customers who ordered
FROM (derived table)a whole tableaggregate first, then filter or join
SELECT listone value per rowthe company average next to each salary

The inner query is always written in parentheses.

Sample data

employees · 12 rows (some columns)
emp_idfirst_namedept_idsalary
100MeeraNULL250000
101Asha1125000
102Rahul198000
103Priya172000
104Karan172000
105Vikram2105000
106Neha254000
107Arjun248000
108Kavya388000
109Rohan345000
110Sneha478000
111Amit125000

Scalar subquery: one value

Employees who earn more than the company average:

SQL
SELECT first_name, salaryFROM employeesWHERE salary > (SELECT AVG(salary) FROM employees)ORDER BY salary DESC;
ResultMySQL 8.4
first_namesalary
Meera250000
Asha125000
Vikram105000
Rahul98000
4 rows in set
How this query works, step by step
  1. MySQL runs the inner query first: AVG(salary) is 88,333.3333.
  2. The outer query becomes WHERE salary > 88333.3333.
  3. 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.

MySQL
SELECT first_name FROM employees WHERE salary > AVG(salary);
ResultMySQL 8.4
ERROR 1111 (HY000): Invalid use of group function

The highest-paid person in Engineering:

SQL
SELECT first_name, salaryFROM employeesWHERE dept_id = 1  AND salary = (SELECT MAX(salary) FROM employees WHERE dept_id = 1);
ResultMySQL 8.4
first_namesalary
Asha125000
1 row in set

IN subquery: a list of values

Customers who have placed at least one delivered order:

SQL
SELECT customer_id, customer_nameFROM customersWHERE customer_id IN (  SELECT customer_id FROM orders WHERE status = 'Delivered')ORDER BY customer_id;
ResultMySQL 8.4
customer_idcustomer_name
1Ananya Sharma
2Rohit Kumar
3Fatima Khan
4David Fernandes
5Ishita Bose
5 rows in set
How this query works, step by step
  1. The inner query lists the customer_id of every delivered order: 1, 2, 1, 4, 2, 3, 5.
  2. IN checks each customer against that list; repeats in the list do not matter.
  3. Five customers match.

NOT IN asks the opposite question. Products that have never been ordered:

SQL
SELECT product_nameFROM productsWHERE product_id NOT IN (SELECT product_id FROM order_items);
ResultMySQL 8.4
product_name
Monitor Arm
1 row in set
Watch out

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:

SQL
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;
ResultMySQL 8.4
customer_idordersavg_order_value
3211249
1310013
224899
523321
413286
5 rows in set
How this query works, step by step
  1. The inner query computes the total of each order: one row per order.
  2. AS t names that result so the outer query can use it like a table.
  3. The outer query groups the order totals by customer and averages them.
  4. 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:

SQL
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;
ResultMySQL 8.4
first_namedept_idsalarydept_avg
Asha112500078400
Rahul19800078400
Priya17200078400
Karan17200078400
Amit12500078400
Vikram210500069000
Neha25400069000
Arjun24800069000
8 rows in set
How this query works, step by step
  1. For each outer row, the subquery calculates the average salary of that row’s department.
  2. Because it refers to e.dept_id from the outer query, it is a correlated subquery.
  3. 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:

SQL
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);
ResultMySQL 8.4
Empty set
How this query works, step by step
  1. The subquery returns the company average, 88,333.3333, which includes the CEO’s 250,000.
  2. Each department’s average is compared with it: Engineering 78,400, Sales 69,000, Marketing 66,500, HR 78,000.
  3. 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, EXISTS or NOT 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.

Expected resultMySQL 8.4
product_nameprice
Gel Pens199
Notebook Pack249
Wireless Mouse799
Desk Lamp1499
USB-C Hub1999
Monitor Arm2799
Mechanical Keyboard3499
7 rows in set
Hint 1

WHERE price < (SELECT AVG(price) FROM products).

Exercise: Orders from Mumbai customers

List the orders of customers who live in Mumbai, using a subquery.

Expected resultMySQL 8.4
order_idcustomer_idorder_date
100112025-01-05
100312025-02-02
100812025-04-28
3 rows in set
Hint 1

customer_id IN (SELECT customer_id FROM customers WHERE city = 'Mumbai').

Quick quiz

1. Which runs first in WHERE salary > (SELECT AVG(salary) FROM employees)?

2. What must a subquery in FROM have?

3. A scalar subquery returns two rows. What happens?

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 ...).