When should I write a JOIN and when should I write a subquery?
Use a JOIN when you are combining columns from two tables into one row of output — the row comes from the combination of the tables. Use a subquery when you are using one query's result as a filter, a value or a set for another query. The subquery produces a value that the outer query consumes; the JOIN produces a wider row.
That framing is more useful than any rule about performance, because the two are not actually different capabilities. A correlated subquery in the SELECT list can be rewritten as a LEFT JOIN with a GROUP BY, and a JOIN with a WHERE clause on the joined table can often be rewritten as a filtered subquery. The optimizer frequently turns one into the other internally.
So the choice is mostly about expressing intent. A JOIN says "these rows are related"; a subquery says "use this computed set to decide which rows matter". If the report is "customers and their order totals", a JOIN with a GROUP BY says exactly that. If it is "customers who have placed more than three orders", the filter is a threshold on an aggregate over a set, which reads naturally as a subquery or as a HAVING clause.
There are situations where one is genuinely better. A subquery in the SELECT list keeps the outer row structure intact — you get one row per customer with a computed column, without collapsing anything. A LEFT JOIN with GROUP BY gives the same result but changes the row grain, and that difference matters when you add more columns later. Conversely, a JOIN to a small lookup table is clearer than a correlated scalar subquery that fetches the same value once per row.
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);
-- JOIN: the two tables are combined, then filtered by a threshold.
SELECT c.name
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name
HAVING SUM(o.amount) > 300;
-- Subquery: the threshold is computed first, then used to pick rows.
SELECT name
FROM customers
WHERE id IN (
SELECT customer_id
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 300
);Both forms return Ada Lovelace, whose orders total 460.50. The JOIN version says "these customers are related to orders, and the relationship's total clears the threshold". The subquery version says "these customers are the ones the qualifying set points at". Same result, different mental model — and the subquery version is the one you reach for when the threshold set feeds several other queries.