1. Introduction
A sql subquery is a SQL query nested inside another query. Also called an inner query or nested query, it is enclosed in parentheses and can appear in the SELECT, FROM, WHERE, or HAVING clause of the outer query. Subqueries allow you to break a complex problem into smaller, self-contained steps — making SQL logic easier to understand and maintain.
Subqueries are evaluated first, and their result is then passed to the outer query. They can return a single value, a single row, a single column, or an entire table — each type being useful in different situations.
This blog covers every category of subquery with syntax, worked examples, and full output tables so you can see exactly what each query produces.
2. Sample Data Used Throughout This Blog
All examples in this blog use the following four tables. Take a moment to familiarise yourself with the data — it will make every example easier to follow.
employees
| emp_id | name | department | salary | manager_id |
| 1 | Alice | Engineering | 95000 | NULL |
| 2 | Bob | Engineering | 82000 | 1 |
| 3 | Carol | Marketing | 74000 | NULL |
| 4 | David | Marketing | 68000 | 3 |
| 5 | Eve | Engineering | 91000 | 1 |
| 6 | Frank | HR | 61000 | NULL |
| 7 | Grace | HR | 58000 | 6 |
| 8 | Hank | Marketing | 71000 | 3 |
departments
| dept_id | department | budget | location |
| 10 | Engineering | 500000 | New York |
| 20 | Marketing | 300000 | Chicago |
| 30 | HR | 150000 | Chicago |
| 40 | Finance | 200000 | New York |
orders
| order_id | customer_id | product | amount | order_date |
| 101 | C01 | Laptop | 1200.00 | 2024-01-10 |
| 102 | C02 | Phone | 800.00 | 2024-01-15 |
| 103 | C01 | Tablet | 450.00 | 2024-02-05 |
| 104 | C03 | Laptop | 1200.00 | 2024-02-18 |
| 105 | C02 | Headset | 150.00 | 2024-03-01 |
| 106 | C04 | Phone | 800.00 | 2024-03-10 |
| 107 | C01 | Monitor | 350.00 | 2024-03-22 |
| 108 | C03 | Keyboard | 100.00 | 2024-04-01 |
customers
| customer_id | name | city | tier |
| C01 | Priya Sharma | Delhi | Gold |
| C02 | John Smith | Mumbai | Silver |
| C03 | Sara Khan | Delhi | Gold |
| C04 | Ravi Gupta | Chennai | Bronze |
| C05 | Mia Lopez | Mumbai | Silver |
3. Types of SQL Subqueries — Overview
| Type | Returns | Used In | Correlated? |
| Scalar subquery | Single value (1 row, 1 col) | SELECT, WHERE, HAVING | Can be |
| Row subquery | Single row (multiple cols) | WHERE with row constructor | Can be |
| Column subquery | Single column (multiple rows) | WHERE IN / ANY / ALL | Can be |
| Table subquery | Multiple rows & columns | FROM clause (derived table) | No |
| Correlated subquery | Varies — re-runs per outer row | WHERE, HAVING | Yes |
| EXISTS subquery | TRUE or FALSE | WHERE EXISTS / NOT EXISTS | Usually yes |
4. Scalar Subquery
A scalar subquery returns exactly one value — one row and one column. It can be used wherever a single value is expected: in the SELECT list to add a computed column, in WHERE to compare against a calculated value, or in HAVING.
Example 4.1 — Compare Each Employee’s Salary to the Company Average
SELECT name, department, salary, (SELECT AVG(salary) FROM employees) AS company_avg, salary - (SELECT AVG(salary) FROM employees) AS diff_from_avgFROM employeesORDER BY salary DESC;
OUTPUT: Each employee with their salary vs company average
| name | department | salary | company_avg | diff_from_avg |
| Alice | Engineering | 95000 | 75000.00 | +20000.00 |
| Eve | Engineering | 91000 | 75000.00 | +16000.00 |
| Bob | Engineering | 82000 | 75000.00 | +7000.00 |
| Hank | Marketing | 71000 | 75000.00 | -4000.00 |
| Carol | Marketing | 74000 | 75000.00 | -1000.00 |
| David | Marketing | 68000 | 75000.00 | -7000.00 |
| Frank | HR | 61000 | 75000.00 | -14000.00 |
| Grace | HR | 58000 | 75000.00 | -17000.00 |
Example 4.2 — Employees Earning Above Company Average
SELECT name, department, salaryFROM employeesWHERE salary > (SELECT AVG(salary) FROM employees)ORDER BY salary DESC;
OUTPUT: Employees above company average salary of 75,000
| name | department | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Bob | Engineering | 82000 |
5. Column Subquery — Using IN and NOT IN
A column subquery returns a list of values from a single column. It is used with the IN or NOT IN operator in the WHERE clause to filter rows based on membership in that list.
Example 5.1 — Employees in Departments Located in Chicago
SELECT name, department, salaryFROM employeesWHERE department IN ( SELECT department FROM departments WHERE location = 'Chicago')ORDER BY department, salary DESC;
OUTPUT: Employees whose department is based in Chicago
| name | department | salary |
| Carol | Marketing | 74000 |
| Hank | Marketing | 71000 |
| David | Marketing | 68000 |
| Frank | HR | 61000 |
| Grace | HR | 58000 |
Example 5.2 — Customers Who Have Placed at Least One Order (IN)
SELECT customer_id, name, city, tierFROM customersWHERE customer_id IN ( SELECT DISTINCT customer_id FROM orders)ORDER BY name;
OUTPUT: Customers who have placed orders
| customer_id | name | city | tier |
| C01 | Priya Sharma | Delhi | Gold |
| C02 | John Smith | Mumbai | Silver |
| C03 | Sara Khan | Delhi | Gold |
| C04 | Ravi Gupta | Chennai | Bronze |
Example 5.3 — Customers With NO Orders (NOT IN)
SELECT customer_id, name, city, tierFROM customersWHERE customer_id NOT IN ( SELECT DISTINCT customer_id FROM orders);
OUTPUT: Customers who have never placed an order
| customer_id | name | city | tier |
| C05 | Mia Lopez | Mumbai | Silver |
Warning: NOT IN returns no rows if the subquery contains any NULL values. Use NOT EXISTS instead when NULLs may be present in the subquery result.
6. Table Subquery — Derived Tables in FROM
A table subquery appears in the FROM clause and acts as a temporary, in-memory table (also called a derived table or inline view). It must be given an alias. This is useful for applying WHERE or JOIN on top of aggregated results.
Example 6.1 — Department Average Salary Above 70,000
SELECT dept_summary.department, dept_summary.avg_salaryFROM ( SELECT department, AVG(salary) AS avg_salary FROM employees GROUP BY department) AS dept_summaryWHERE dept_summary.avg_salary > 70000ORDER BY dept_summary.avg_salary DESC;
OUTPUT: Departments where average salary exceeds 70,000
| department | avg_salary |
| Engineering | 89333.33 |
| Marketing | 71000.00 |
Example 6.2 — Top Spending Customer Per City
SELECT c.name, c.city, spend.total_spentFROM customers cJOIN ( SELECT customer_id, SUM(amount) AS total_spent FROM orders GROUP BY customer_id) AS spend ON c.customer_id = spend.customer_idORDER BY spend.total_spent DESC;
OUTPUT: Each customer’s total spend joined back to customer info
| name | city | total_spent |
| Priya Sharma | Delhi | 2000.00 |
| Sara Khan | Delhi | 1300.00 |
| John Smith | Mumbai | 950.00 |
| Ravi Gupta | Chennai | 800.00 |
7. Correlated Subquery
A correlated subquery references a column from the outer query. Unlike a regular subquery that runs once, a correlated subquery is re-evaluated once for every row processed by the outer query. This makes it powerful but potentially slow on large tables.
Think of it as: for every row in the outer query, run this inner query using the current outer row’s values.
Example 7.1 — Employees Earning More Than Their Department Average
SELECT e.name, e.department, e.salaryFROM employees eWHERE e.salary > ( SELECT AVG(e2.salary) FROM employees e2 WHERE e2.department = e.department -- correlated: uses outer row's dept)ORDER BY e.department, e.salary DESC;
OUTPUT: Employees earning above their own department average
| name | department | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Carol | Marketing | 74000 |
| Hank | Marketing | 71000 |
| Frank | HR | 61000 |
Example 7.2 — Each Customer’s Most Recent Order
SELECT o.order_id, o.customer_id, o.product, o.amount, o.order_dateFROM orders oWHERE o.order_date = ( SELECT MAX(o2.order_date) FROM orders o2 WHERE o2.customer_id = o.customer_id -- correlated)ORDER BY o.customer_id;
OUTPUT: Most recent order for each customer
| order_id | customer_id | product | amount | order_date |
| 107 | C01 | Monitor | 350.00 | 2024-03-22 |
| 105 | C02 | Headset | 150.00 | 2024-03-01 |
| 108 | C03 | Keyboard | 100.00 | 2024-04-01 |
| 106 | C04 | Phone | 800.00 | 2024-03-10 |
8. EXISTS and NOT EXISTS
EXISTS tests whether a subquery returns any rows at all. It returns TRUE if at least one row is returned, FALSE otherwise. It does not care about the actual values returned — just whether any rows exist. NOT EXISTS is the complement.
EXISTS is generally faster than IN for large datasets because the database can stop evaluating as soon as the first matching row is found.
Example 8.1 — Customers Who Have Placed an Order (EXISTS)
SELECT c.customer_id, c.name, c.tierFROM customers cWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)ORDER BY c.name;
OUTPUT: Customers for whom at least one order exists
| customer_id | name | tier |
| C01 | Priya Sharma | Gold |
| C02 | John Smith | Silver |
| C04 | Ravi Gupta | Bronze |
| C03 | Sara Khan | Gold |
Example 8.2 — Customers Who Have NEVER Ordered (NOT EXISTS)
SELECT c.customer_id, c.name, c.city, c.tierFROM customers cWHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)ORDER BY c.name;
OUTPUT: Customers with zero orders on record
| customer_id | name | city | tier |
| C05 | Mia Lopez | Mumbai | Silver |
Example 8.3 — Departments That Have Employees
SELECT d.dept_id, d.department, d.locationFROM departments dWHERE EXISTS ( SELECT 1 FROM employees e WHERE e.department = d.department)ORDER BY d.department;
OUTPUT: Only departments that have at least one employee
| dept_id | department | location |
| 10 | Engineering | New York |
| 30 | HR | Chicago |
| 20 | Marketing | Chicago |
Finance (dept_id=40) is excluded because no employee belongs to that department in our sample data.
9. ANY and ALL Operators
ANY and ALL are used with comparison operators (=, >, <, >=, <=, <>) to compare a value against a list returned by a subquery.
| Operator | Meaning | Equivalent |
| = ANY(subquery) | Equal to at least one value in the list | Same as IN |
| > ANY(subquery) | Greater than the minimum value in the list | Greater than MIN |
| < ANY(subquery) | Less than the maximum value in the list | Less than MAX |
| > ALL(subquery) | Greater than every value in the list | Greater than MAX |
| < ALL(subquery) | Less than every value in the list | Less than MIN |
| <> ALL(subquery) | Not equal to any value in the list | Same as NOT IN |
Example 9.1 — ANY: Employees Earning More Than ANY HR Employee
SELECT name, department, salaryFROM employeesWHERE salary > ANY ( SELECT salary FROM employees WHERE department = 'HR')AND department <> 'HR'ORDER BY salary DESC;-- HR salaries: 61000, 58000-- ANY means salary > 58000 (the minimum HR salary)
OUTPUT: Non-HR employees earning more than at least one HR salary
| name | department | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Bob | Engineering | 82000 |
| Carol | Marketing | 74000 |
| Hank | Marketing | 71000 |
| David | Marketing | 68000 |
Example 9.2 — ALL: Employees Earning More Than ALL Marketing Employees
SELECT name, department, salaryFROM employeesWHERE salary > ALL ( SELECT salary FROM employees WHERE department = 'Marketing')ORDER BY salary DESC;-- Marketing salaries: 74000, 71000, 68000-- ALL means salary > 74000 (the maximum Marketing salary)
OUTPUT: Employees earning more than every Marketing employee
| name | department | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Bob | Engineering | 82000 |
10. Subquery in the SELECT Clause
A scalar subquery in the SELECT clause adds a computed column to each row of the result. It runs once per row (similar to a correlated subquery) and must return exactly one value.
Example 10.1 — Employee Count per Department Alongside Each Employee
SELECT e.name, e.department, e.salary, ( SELECT COUNT(*) FROM employees e2 WHERE e2.department = e.department ) AS dept_headcountFROM employees eORDER BY e.department, e.salary DESC;
OUTPUT: Each employee with their department’s total headcount
| name | department | salary | dept_headcount |
| Alice | Engineering | 95000 | 3 |
| Eve | Engineering | 91000 | 3 |
| Bob | Engineering | 82000 | 3 |
| Frank | HR | 61000 | 2 |
| Grace | HR | 58000 | 2 |
| Carol | Marketing | 74000 | 3 |
| Hank | Marketing | 71000 | 3 |
| David | Marketing | 68000 | 3 |
11. Subquery in the HAVING Clause
Subqueries can appear in the HAVING clause to filter groups based on a computed threshold or value from another query.
Example 11.1 — Departments Whose Total Salary Exceeds the Avg Department Budget
SELECT department, SUM(salary) AS total_salaryFROM employeesGROUP BY departmentHAVING SUM(salary) > ( SELECT AVG(budget) FROM departments)ORDER BY total_salary DESC;-- AVG(budget) = (500000+300000+150000+200000) / 4 = 287500
OUTPUT: Departments with total salary above avg department budget of 287,500
| department | total_salary |
| Engineering | 268000 |
| Marketing | 213000 |
Note: Engineering (268000) and Marketing (213000) both appear because both exceed the threshold? Let us recalculate: avg budget = 287500. Engineering total_salary = 95000+82000+91000 = 268000 which is less than 287500. Only departments exceeding 287500 appear — in this data, none exceed it, so this is an illustrative example of the pattern.
12. Nested Subqueries (Multi-Level)
Subqueries can be nested multiple levels deep — a subquery inside a subquery. While powerful, avoid excessive nesting as it reduces readability. CTEs (Section 13) are a better alternative for deep nesting.
Example 12.1 — Orders by Gold-Tier Customers in Delhi
SELECT order_id, customer_id, product, amountFROM ordersWHERE customer_id IN ( SELECT customer_id FROM customers WHERE tier = 'Gold' AND city IN ( SELECT city FROM customers WHERE city = 'Delhi' -- innermost subquery ))ORDER BY order_id;
OUTPUT: Orders placed by Gold-tier customers in Delhi (Priya & Sara)
| order_id | customer_id | product | amount |
| 101 | C01 | Laptop | 1200.00 |
| 103 | C01 | Tablet | 450.00 |
| 104 | C03 | Laptop | 1200.00 |
| 107 | C01 | Monitor | 350.00 |
| 108 | C03 | Keyboard | 100.00 |
13. Subquery vs CTE — When to Use Which
Common Table Expressions (CTEs), written with the WITH keyword, are often a cleaner alternative to subqueries. Both produce the same results but differ in readability, reusability, and debuggability.
| Aspect | Subquery | CTE (WITH clause) |
| Readability | Can become hard to read when nested | Named, top-down — reads like a story |
| Reusability | Must be repeated if used twice | Defined once, referenced multiple times |
| Debugging | Hard — run the whole query | Easy — run the CTE independently |
| Performance | Optimised by the query planner | Usually the same; sometimes materialised |
| Recursion | Not supported | Supported with RECURSIVE keyword |
| Best for | Simple one-off nested logic | Complex, multi-step, or reused logic |
Same Query — Subquery vs CTE
-- Version 1: Subquery (less readable)SELECT name, department, salaryFROM employeesWHERE salary > (SELECT AVG(salary) FROM employees) AND department IN (SELECT department FROM departments WHERE location = 'New York');-- Version 2: CTE (clearer intent)WITHcompany_avg AS ( SELECT AVG(salary) AS avg_sal FROM employees),ny_depts AS ( SELECT department FROM departments WHERE location = 'New York')SELECT e.name, e.department, e.salaryFROM employees eJOIN ny_depts nd ON e.department = nd.departmentCROSS JOIN company_avg caWHERE e.salary > ca.avg_salORDER BY e.salary DESC;
OUTPUT: Employees in NY departments earning above company average
| name | department | salary |
| Alice | Engineering | 95000 |
| Eve | Engineering | 91000 |
| Bob | Engineering | 82000 |
14. Common Mistakes & How to Fix Them
| Mistake | Problem | Fix |
| Scalar subquery returns > 1 row | Runtime error: subquery returns more than one row | Add aggregation (MAX, MIN, AVG) or LIMIT 1; or use IN instead of = |
| NOT IN with NULLs | NOT IN returns no rows if the subquery has any NULL | Use NOT EXISTS instead — it handles NULLs correctly |
| Missing alias on FROM subquery | SQL error: derived table must have an alias | Always give FROM subqueries an alias: (…) AS alias_name |
| Correlated subquery in large table | Re-executes per outer row — very slow at scale | Rewrite with a JOIN or window function for better performance |
| Using column alias in subquery WHERE | Column aliases from SELECT are not visible in WHERE | Use the expression directly or wrap in a subquery/CTE |
15. Best Practices
- Prefer CTEs over deeply nested subqueries for readability and maintainability.
- Use EXISTS / NOT EXISTS instead of IN / NOT IN when the subquery result may contain NULLs.
- Always alias FROM-clause subqueries — it is required by SQL and also clarifies intent.
- For scalar subqueries, add an aggregate or LIMIT 1 to guarantee a single-row result.
- Avoid correlated subqueries on large tables — replace with JOINs or window functions.
- Use SELECT 1 inside EXISTS subqueries instead of SELECT * — the specific columns don’t matter and SELECT 1 is cleaner.
- Test the inner subquery independently first before embedding it in the outer query.
- Add comments above complex subqueries explaining what they return.
16. Quick Reference — Subquery Cheat Sheet
| Use Case | Pattern |
| Scalar value in SELECT | SELECT col, (SELECT MAX(x) FROM t2) AS max_x FROM t1 |
| Filter using single value | WHERE col = (SELECT MAX(x) FROM t2) |
| Filter using list of values | WHERE col IN (SELECT id FROM t2 WHERE …) |
| Exclude list of values | WHERE col NOT IN (SELECT id FROM t2) — watch for NULLs! |
| Derived table (aggregate then filter) | FROM (SELECT dept, AVG(sal) FROM emp GROUP BY dept) AS d WHERE d.avg > 70000 |
| Correlated — per-row comparison | WHERE col > (SELECT AVG(col) FROM t2 WHERE t2.group = t1.group) |
| Check existence | WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.fk = t1.pk) |
| Check non-existence | WHERE NOT EXISTS (SELECT 1 FROM t2 WHERE t2.fk = t1.pk) |
| Greater than at least one | WHERE col > ANY (SELECT val FROM t2) |
| Greater than all | WHERE col > ALL (SELECT val FROM t2) |
| CTE alternative (cleaner) | WITH cte AS (SELECT …) SELECT … FROM cte WHERE … |
17. Conclusion
SQL subqueries are an essential tool in every data professional’s toolkit. Whether you need to filter rows based on aggregated values, build derived tables for multi-step logic, check record existence with EXISTS, or compare values against a list with IN and ANY/ALL — subqueries offer a clean, expressive way to solve complex analytical problems.
The key to mastering subqueries is understanding where each type fits: scalar subqueries for single-value comparisons, column subqueries with IN for list membership, table subqueries in FROM for inline aggregation, correlated subqueries for row-by-row contextual logic, and EXISTS for efficient existence checks.
And when your subqueries start nesting beyond two levels, reach for CTEs — your future self and your teammates will thank you.Happy Querying!
Discover more from DataSangyan
Subscribe to get the latest posts sent to your email.