What is the difference between a scalar subquery and a table subquery?
A scalar subquery returns exactly one value: one row, one column. It can be used anywhere a single value is allowed — in the SELECT list, compared in a WHERE clause, inside an arithmetic expression, or in a GROUP BY. A table subquery returns a set of rows and can only be used where a set is expected: in the FROM clause as a derived table, or on the right-hand side of IN, EXISTS, ANY or ALL.
The distinction is enforced by the engine. If a scalar subquery returns more than one row, the query fails with an error — in SQLite you get "subquery returns more than one row", in PostgreSQL "more than one row returned by a subquery used as an expression". If a scalar subquery returns no rows, the result is NULL, and the error does not occur. That asymmetry is worth remembering: too many rows is fatal, too few is fine.
Table subqueries have the opposite flexibility. A derived table in the FROM clause needs an alias and behaves like a table: you can select from it, join to it, group by its columns and reference its aliases in the outer query. An IN subquery takes any number of rows and simply matches against them.
There is also an intermediate form: a row subquery that returns one row with several columns, written as (SELECT a, b FROM t WHERE ...). This is legal in a FROM clause and in a comparison against a row constructor, and it is the shape to reach for when you want to compare two columns together rather than one.
Choosing between them is mostly a question of what the value means. If the answer is a single number — this customer's total, the maximum date, a count — it is scalar. If the answer is a set — these are the ids that qualify — it is a table subquery. If it is a set of columns that you want to treat like a table, it is a derived table.
Here is the same thing as runnable code:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name VARCHAR(120) NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount DECIMAL(10, 2) NOT NULL
);
INSERT INTO customers (id, name) VALUES
(1, 'Ada Lovelace'),
(2, 'Grace Hopper'),
(3, 'Alan Turing');
INSERT INTO orders (id, customer_id, amount) VALUES
(1, 1, 120.00),
(2, 1, 340.50),
(3, 2, 75.25);
-- A scalar subquery: exactly one value.
SELECT ROUND(AVG(amount), 2) AS average_order FROM orders;
-- A table subquery in the FROM clause: a derived table with an alias.
SELECT t.customer_id, t.order_count
FROM (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) AS t
WHERE t.order_count > 1
ORDER BY t.customer_id;