SQL (PostgreSQL, MySQL, SQLite) Module 6 – Subqueries and set operations
Subqueries in SELECT, WHERE and FROM
Use a subquery as one value in SELECT and WHERE or as a derived table in FROM, and see how PostgreSQL, MySQL and SQLite treat extra rows and names.
What you will learn
- Use a scalar subquery as a single value in SELECT and WHERE
- Use a derived table (a subquery in FROM) and name it
- Predict what each engine does when a scalar subquery returns several rows or none
- Aggregate in derived tables before joining, so that totals are not multiplied
Before you start
What you’ll learn · Before you start
What you will learn
- Use a scalar subquery as a single value in SELECT and WHERE
- Use a derived table (a subquery in FROM) and name it
- Predict what each engine does when a scalar subquery returns several rows or none
- Aggregate in derived tables before joining, so that totals are not multiplied
Before you start
On this page
A subquery is a SELECT written in parentheses inside another statement. What it gives the outer query depends
on where it stands. In SELECT or WHERE, a scalar subquery returns a single value, one row with one column,
which the outer query uses like a constant: price_paise > (SELECT avg(price_paise) FROM products) compares every
product with the average. In FROM, a subquery is a derived table: a result with its own name that the outer
query reads like a table, which lets you filter, join or aggregate a result that is itself an aggregate. Subqueries
let one statement do what would otherwise take two queries and a value copied between them, and the engines run the
inner query for you.
Press Run on any example to try it: the queries execute in sql.js 1.14.2, a build of SQLite 3.49.1 for the browser, and what PostgreSQL 18 and MySQL 8.4 do instead is taken from their manuals.
Three places for a subquery
Subqueries in SELECT, WHERE and FROM differ in what they return, and so in what the outer query may do with them:
Where a subquery can go, and what it returns
Text description of the diagram
The diagram shows one outer query with three places where a subquery can be written, in the order they appear in the query text.
1. In SELECT, a scalar subquery such as (SELECT avg(price_paise) FROM products) returns one value, which is shown on every row of the result. 2. In FROM, a derived table such as (SELECT city, count(*) ... GROUP BY city) AS per_city returns rows, which the outer query reads as if they were a table. 3. In WHERE, a scalar subquery such as price_paise > (SELECT avg(...) FROM products) returns one value that each row is compared with.
A dashed box below, joined by an arrow labelled "also", adds a fourth use that the next lesson covers: in WHERE with IN or EXISTS, a subquery returns a list of values or a yes or no answer.
| Criterion | Scalar subquery | Derived table | IN or EXISTS subquery |
|---|---|---|---|
| Written in | SELECT, WHERE, HAVING, ORDER BY | FROM, or a JOIN | WHERE or HAVING |
| Returns | One value: one row, one column | Rows and columns, like a table | True or false for each outer row |
| Needs a name | No | Yes in MySQL; good practice everywhere | No |
| Typical use | Compare with or show an overall figure | Aggregate first, then filter, join or aggregate again | Keep rows that have, or lack, a match |
The third kind, a subquery that tests membership, is the subject of the next lesson. This one covers the first two.
A scalar subquery is one value
The two queries below use the same subquery, once in WHERE and once in SELECT:
-- Nine products of a grocery shop, with prices in paise.
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
price_paise INTEGER NOT NULL
);
INSERT INTO products VALUES
(1, 'Masala tea 250 g', 'drinks', 18000), (2, 'Filter coffee 500 g', 'drinks', 42000),
(3, 'Green tea 100 g', 'drinks', 24000), (4, 'Basmati rice 5 kg', 'grains', 65000),
(5, 'Wheat atta 10 kg', 'grains', 48000), (6, 'Red lentils 1 kg', 'grains', 14000),
(7, 'Ghee 1 l', 'dairy', 62000), (8, 'Paneer 200 g', 'dairy', 9000),
(9, 'Curd 400 g', 'dairy', 5000);
-- 1. A scalar subquery in WHERE: the products that cost more than the average product.
SELECT name, price_paise / 100.0 AS rupees
FROM products
WHERE price_paise > (SELECT avg(price_paise) FROM products)
ORDER BY price_paise DESC;
-- 2. The same subquery in SELECT, where it supplies one value for every row.
SELECT name,
price_paise / 100.0 AS rupees,
round((SELECT avg(price_paise) FROM products) / 100.0, 2) AS average_rupees,
round((price_paise - (SELECT avg(price_paise) FROM products)) / 100.0, 2) AS difference
FROM products
ORDER BY difference DESC
LIMIT 4; Output
┌─────────────────────┬────────┐ │ name │ rupees │ ├─────────────────────┼────────┤ │ Basmati rice 5 kg │ 650.0 │ │ Ghee 1 l │ 620.0 │ │ Wheat atta 10 kg │ 480.0 │ │ Filter coffee 500 g │ 420.0 │ └─────────────────────┴────────┘ ┌─────────────────────┬────────┬────────────────┬────────────┐ │ name │ rupees │ average_rupees │ difference │ ├─────────────────────┼────────┼────────────────┼────────────┤ │ Basmati rice 5 kg │ 650.0 │ 318.89 │ 331.11 │ │ Ghee 1 l │ 620.0 │ 318.89 │ 301.11 │ │ Wheat atta 10 kg │ 480.0 │ 318.89 │ 161.11 │ │ Filter coffee 500 g │ 420.0 │ 318.89 │ 101.11 │ └─────────────────────┴────────┴────────────────┴────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < scalar_values.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The average of the nine prices is 318.89 rupees, and four products cost more. In the second query the subquery supplies that average to every row, so each product can show its distance from it. Scalar subqueries in SELECT lists work like a column whose value is the same on every row. Because this subquery does not refer to the outer row, the engine computes it once and reuses the result; SQLite’s manual says so in those words.
A derived table is a result you can query
A derived table turns a result into something the outer query can treat as a table. That is how you filter on an
aggregate without HAVING, and how you aggregate twice:
-- Fourteen orders from five cities, with amounts in paise.
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
city TEXT NOT NULL,
amount_paise INTEGER NOT NULL
);
INSERT INTO orders VALUES
(1, 'Pune', 45000), (2, 'Kochi', 12000), (3, 'Pune', 30000), (4, 'Jaipur', 8000),
(5, 'Pune', 99000), (6, 'Kochi', 15000), (7, 'Indore', 52000), (8, 'Kochi', 7000),
(9, 'Jaipur', 26000), (10, 'Pune', 18000), (11, 'Indore', 21000), (12, 'Guwahati', 64000),
(13, 'Jaipur', 33000), (14, 'Kochi', 40000);
-- 1. A derived table: revenue per city, computed by the inner query and read like a table by the outer one.
SELECT city, orders, rupees
FROM (SELECT city, count(*) AS orders, sum(amount_paise) / 100.0 AS rupees
FROM orders
GROUP BY city) AS per_city
WHERE orders >= 3
ORDER BY rupees DESC;
-- 2. An aggregate of aggregates needs two steps: the average and the best of the five city totals.
SELECT count(*) AS cities, round(avg(rupees), 2) AS average_city, max(rupees) AS best_city
FROM (SELECT city, sum(amount_paise) / 100.0 AS rupees
FROM orders
GROUP BY city) AS per_city;
-- 3. In one step it is an error: aggregates cannot be nested.
SELECT avg(sum(amount_paise)) FROM orders GROUP BY city; Output (exit status 1)
┌────────┬────────┬────────┐ │ city │ orders │ rupees │ ├────────┼────────┼────────┤ │ Pune │ 4 │ 1920.0 │ │ Kochi │ 4 │ 740.0 │ │ Jaipur │ 3 │ 670.0 │ └────────┴────────┴────────┘ ┌────────┬──────────────┬───────────┐ │ cities │ average_city │ best_city │ ├────────┼──────────────┼───────────┤ │ 5 │ 940.0 │ 1920.0 │ └────────┴──────────────┴───────────┘
Printed as an error (standard error)
Parse error near line 28: misuse of aggregate function sum()
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < derived_table.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The inner query of the first statement adds up the orders of each city, and the outer query sees a small table,
per_city, with five rows and the columns city, orders and rupees. Its WHERE keeps the three cities with at
least three orders. The second statement needs two steps by nature: the average of the city totals, 940 rupees, is
an aggregate over aggregates. Written in one step, as the third statement tries, it fails in every engine, because
aggregates cannot be nested; SQLite calls it a misuse of sum(), PostgreSQL says that aggregate function calls cannot
be nested, and MySQL reports error 1111.
The name after the parentheses, per_city, is the derived table’s alias. Give one every time: MySQL refuses a
derived table without it.
When the one-value promise breaks
A scalar subquery promises one row. The data does not always keep that promise, and the engines react differently:
- When it returns two or more rows, PostgreSQL and MySQL stop with an error, while SQLite quietly uses the first row it finds.
- When it returns no rows, the value is NULL in every engine, and a comparison with NULL keeps nothing.
- Joining two tables that both have several rows per customer multiplies the rows, so totals must be aggregated in derived tables first.
- Names differ too: MySQL needs an alias for every derived table, and SQLite rejects a column list after it.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Your turn
Exercise · Medium · SQL
List the products priced above their category's average
The table products has the columns id, name, category and price_paise (the price in paise, 100 paise to the rupee).
Write a query that returns the products that cost more than the average price of their own category, with two columns:
name: the product's name;above_by_rupees: how much more than its category's average the product costs, in rupees, rounded to two decimal places.
A product that costs exactly its category's average is not listed, and the order of the rows does not matter. Compare each product with its own category, not with the average of the whole shop.
Starter code · above_average.sql
-- Products priced above the average of their own category: name, above_by_rupees.
SELECT name,
(price_paise - (SELECT avg(price_paise) FROM products)) / 100.0 AS above_by_rupees
FROM products
WHERE price_paise > (SELECT avg(price_paise) FROM products); The sample tests · tests.yaml
# Sample tests: each one builds a small products table, runs your query on it and compares the rows it returns (in any
# order).
tests:
- name: compares each product with its own category
setup: |
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price_paise INTEGER NOT NULL);
INSERT INTO products VALUES
(1, 'Masala tea', 'drinks', 20000), (2, 'Filter coffee', 'drinks', 40000),
(3, 'Basmati rice', 'grains', 90000), (4, 'Wheat atta', 'grains', 50000), (5, 'Red lentils', 'grains', 10000);
expected:
columns: [name, above_by_rupees]
rows:
- ['Filter coffee', 100]
- ['Basmati rice', 400]
- name: leaves out a product that costs exactly the average
setup: |
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price_paise INTEGER NOT NULL);
INSERT INTO products VALUES
(1, 'Ghee', 'dairy', 60000), (2, 'Paneer', 'dairy', 30000), (3, 'Curd', 'dairy', 0),
(4, 'Butter', 'dairy', 30000), (5, 'Green tea', 'drinks', 25000);
expected:
columns: [name, above_by_rupees]
rows:
- ['Ghee', 300]
- name: rounds to the paisa when the average is not a whole number
setup: |
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price_paise INTEGER NOT NULL);
INSERT INTO products VALUES
(1, 'Jaggery', 'sweets', 10000), (2, 'Sugar', 'sweets', 12000), (3, 'Honey', 'sweets', 25000);
expected:
columns: [name, above_by_rupees]
rows:
- ['Honey', 93.33] A hint
Compute the averages first, in a derived table with one row per category: (SELECT category, avg(price_paise) AS avg_paise FROM products GROUP BY category) AS c. Join it to products on the category, keep the rows whose price is above c.avg_paise, and turn the difference into rupees with round(... / 100.0, 2).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Two rows or none: a scalar subquery in three engines Pro: opens with a Pro, Premium or Ultimate pass
A scalar subquery that breaks its promise is one of the few places where the three engines give three different results for the same text. This part reproduces both broken cases and compares four ways to keep the promise.
In this part:
- Two rows: an error in PostgreSQL and MySQL, a silent first row in SQLite
- No rows: NULL, and the filter that keeps nothing
- Four ways to keep the promise, compared
2 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Derived tables that stop double counting, with numbers Pro: opens with a Pro, Premium or Ultimate pass
The most common reason for a derived table in real reports is to stop a join from multiplying rows before they are added up. This part reproduces the multiplication on four customers, measures how wrong each total becomes, and fixes it with one derived table per child table.
In this part:
- Orders and payments joined: totals two and three times too high
- Aggregate first, then join: one row per customer on each side
- Checking the result: totals that must match
2 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Derived tables that run in PostgreSQL, MySQL and SQLite Pro: opens with a Pro, Premium or Ultimate pass
The three engines agree on what a derived table means but not on how to write one. This part covers names, column lists, row values and inline VALUES tables, and reads SQLite’s plans to show how it runs each kind of subquery.
In this part:
- Names and column lists: what each engine accepts
- Row values: two columns compared with one subquery
- How SQLite runs a subquery, read from its plans
3 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What is a scalar subquery, and what does it return when its query finds no row?
A scalar subquery is a
SELECTin parentheses that returns exactly one column and at most one row, so it can stand wherever a single value can: in theSELECTlist, inWHERE,HAVINGorORDER BY, or as an argument of a function, as inprice > (SELECT avg(price) FROM products). When its query finds no row, the value is NULL in PostgreSQL, MySQL and SQLite, and any comparison with that NULL is not true, so a filter built on it quietly keeps nothing; wrap it incoalesce()when the empty case needs a value. An aggregate such ascount(*)is different: over no rows it still returns one row, with 0. When the query finds two or more rows, PostgreSQL and MySQL raise an error, while SQLite uses the first row. -
Fresher to mid
Write a query that returns the second-highest distinct order amount of an orders table as a single value, and NULL when there is no second amount. Then explain what changes when two orders share the highest amount.
-
Mid to senior
A nightly report ran on SQLite for a year. After the move to PostgreSQL it fails with "more than one row returned by a subquery used as an expression", although the data was copied unchanged. What was the report doing in SQLite, and how do you fix it?
-
Fresher to senior
A customer statement joins customers, orders and payments and groups by customer. For some customers the ordered amount is exactly two or three times too high, and one customer with no payments is missing. Explain the cause and write a correct query.
Key takeaways
- A scalar subquery returns one value and can stand wherever a value can, in
SELECT,WHEREorORDER BY; a subquery that does not refer to the outer row is computed once. - A derived table is a subquery in
FROMwith a name; it makes two-step questions, such as an average of totals or a filter on an aggregate, into one statement. - Two rows from a scalar subquery are an error in PostgreSQL and MySQL and a silent first row in SQLite; no rows give NULL everywhere.
- Name every derived table, so that the same query runs in MySQL, PostgreSQL and SQLite.
References
- PostgreSQL 18: Value Expressions (scalar subqueries) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Table Expressions (subqueries in FROM) (The PostgreSQL Global Development Group)
- PostgreSQL 16 Release Notes (subqueries in FROM may omit aliases) (The PostgreSQL Global Development Group)
- SQLite: SQL Language Expressions (subquery expressions) (SQLite)
- SQLite: Row Values (SQLite)
- SQLite: The SQLite Query Optimizer Overview (subquery flattening and co-routines) (SQLite)
- MySQL 8.4 Reference Manual: The Subquery as Scalar Operand (Oracle Corporation)
- MySQL 8.4 Reference Manual: Derived Tables (Oracle Corporation)
- MySQL 8.4 Reference Manual: Subquery Errors (Oracle Corporation)
- MySQL 8.4 Reference Manual: VALUES Statement (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress