Use NOT EXISTS with a correlated subquery: SELECT p.* FROM products p WHERE NOT EXISTS (SELECT 1 FROM sales s WHERE s.product_id = p.id). A LEFT JOIN that keeps only the rows where s.product_id IS NULL gives the same answer. Avoid NOT IN (SELECT …) on a column that can hold NULL: a single NULL in the subquery makes it return no rows at all.
"Which products have never sold?", "which students have no enrolment?", "which ids are in this table but not that one?" are all the same query, often called an anti-join: keep the rows of one table that have no partner in another. Standard SQL gives you four ways to write it, and PostgreSQL, MySQL, SQL Server and SQLite support all of them, but they don't behave the same once NULL shows up. The examples use a products table and a sales table and run on SQLite; each one builds its own tables, so hit Run, then change the data and run it again. If you need a refresher on how outer joins fill in NULL, see inner join vs outer join.
1NOT EXISTS with a correlated subqueryRecommended
NOT EXISTS (subquery) is true when the subquery returns no rows. Make the subquery refer to the outer row (s.product_id = p.id) and it is checked once per product: if no sale points at that product, the product is kept. What the subquery selects doesn't matter, so SELECT 1 is the usual way to write it. Any extra rule about which sales count, such as a date range, goes inside the subquery.
Output
The first query returns Monitor and Headset, the two products with no row in sales. The second keeps every product with no sale in March: Headset, Keyboard, Monitor and Webcam. Only Mouse sold in March. NOT EXISTS only asks whether a matching row exists, so a NULL in sales.product_id can't change its answer, and a product with several sales is still tested once. That is why it is the safe default.
2LEFT JOIN … WHERE … IS NULL (the anti-join)
A LEFT JOIN keeps every row from the left table. When a product has no sale, the columns from sales come back as NULL, so a WHERE s.product_id IS NULL filter keeps exactly the unmatched products. Test a column that can never be NULL in a real match; the join column is the best choice, because a row only matches when it equals p.id.
Output
The first query shows what the join builds: one row per sale, so Mouse appears twice, and a NULL sale_id for Monitor and Headset. The IS NULL filter then leaves those two. Keep any condition on sales in the ON clause, not in WHERE: a WHERE test on a sales column throws away the NULL rows you are looking for. This form is handy when you also want columns from a third joined table, but NOT EXISTS says what you mean more directly. Testing the join column also helps the planner: PostgreSQL 18 runs this query as a Hash Anti Join, the same plan it picks for NOT EXISTS, while WHERE s.id IS NULL gets an ordinary join followed by a filter.
3NOT IN, and the NULL that empties it
WHERE id NOT IN (SELECT product_id FROM sales) reads naturally and works, until the subquery returns a NULL. The query below runs it twice: once on clean data, then again after one sale is stored with an unknown product.
Output
Only one table prints. The first query lists Monitor and Headset; the second runs without an error and returns no rows, and this lane prints nothing for an empty result. That missing second table is the bug: there are still two unsold products, but NOT IN can no longer say so. No error, no warning, just an empty report, which is why this one is easy to ship.
4Why NOT IN breaks, and how to fix it
x NOT IN (a, b, c) means x <> a AND x <> b AND x <> c. Comparing anything with NULL gives NULL (unknown), and an AND that contains an unknown can never be true, so WHERE drops every row. Filter the NULLs out inside the subquery, or switch to NOT EXISTS.
Output
3 NOT IN (1, 2, 4) is 1 (true), but 3 NOT IN (1, 2, 4, NULL) is NULL: SQL can't rule out that the unknown value is 3. With WHERE product_id IS NOT NULL in the subquery the result is back to Monitor and Headset. If the column is declared NOT NULL, plain NOT IN is safe, but the filter costs nothing and keeps the query correct if the schema changes.
5EXCEPT: compare two lists of ids
EXCEPT takes the rows of the first query and removes every row that also appears in the second. It is the clearest way to compare two id lists in either direction, and unlike NOT IN it treats two NULLs as equal. Both queries must return the same number of columns, and the result has no duplicates (SELECT NULL EXCEPT SELECT NULL returns no rows). Oracle calls the same operator MINUS.
Output
Products minus sales gives ids 3 and 5; the NULL sale doesn't get in the way. Sales minus products finds the orphans: the NULL product and id 9, which has no row in products. Because EXCEPT only returns the compared columns, the last query feeds it into IN to get the names back. IN is safe here because a NULL can only stop a NOT IN. MySQL supports EXCEPT from 8.0.31; on older MySQL use NOT EXISTS. To combine results instead of subtracting them, see UNION vs UNION ALL.
6The positive case: EXISTS vs IN
To find rows that do have a match, drop the NOT. EXISTS and IN return the same rows, and a NULL in the subquery is harmless for IN, so pick whichever reads better. A plain JOIN is different: it returns one row per match.
Output
EXISTS and IN both return Keyboard, Mouse and Webcam, each once. The JOIN returns Mouse twice because it sold twice. Use EXISTS or IN when you only want to know whether a match exists, and a JOIN when you need columns from the matching rows. IN also takes a literal list, as in WHERE id IN (1, 2, 4), which EXISTS can't.
7Which should you use?
All four run on PostgreSQL, MySQL (EXCEPT from 8.0.31), SQL Server and SQLite. The real difference is how they treat NULL and what they can return.
| Method | NULL in the other table | Returns | Best for |
|---|---|---|---|
| NOT EXISTS (correlated subquery) | Safe | Any columns of the outer table | The default anti-join; extra conditions on the match |
| LEFT JOIN … WHERE s.product_id IS NULL | Safe | Any columns of the outer table | Queries that already join several tables |
| NOT IN (SELECT …) | One NULL returns no rows | Any columns of the outer table | Literal lists, or subqueries filtered with IS NOT NULL |
| EXCEPT | NULLs compare as equal | Only the compared columns, deduplicated | Comparing two id lists, both directions |
Frequently asked questions
How do I select rows from one table that are not in another table?
Use NOT EXISTS with a correlated subquery: SELECT p.* FROM products p WHERE NOT EXISTS (SELECT 1 FROM sales s WHERE s.product_id = p.id). SELECT p.* FROM products p LEFT JOIN sales s ON s.product_id = p.id WHERE s.product_id IS NULL returns the same rows. Both work in PostgreSQL, MySQL, SQL Server and SQLite.
What is the difference between NOT IN and NOT EXISTS?
They differ when the subquery returns a NULL. NOT EXISTS only checks whether a matching row exists, so NULLs make no difference. NOT IN compares the value with every item in the list, and 3 NOT IN (1, 2, 4, NULL) is NULL, not true, so one NULL in the subquery makes the whole query return no rows. Without NULLs they return the same rows.
Why does my NOT IN query return no rows?
The subquery almost certainly returns at least one NULL. Add WHERE column IS NOT NULL inside the subquery, or rewrite the query with NOT EXISTS. You can check with SELECT COUNT(*) FROM sales WHERE product_id IS NULL.
Is NOT EXISTS faster than LEFT JOIN … IS NULL?
Often they run the same way. PostgreSQL 18 plans NOT EXISTS as a Hash Anti Join, and plans the LEFT JOIN form the same way when the IS NULL test is on the join column (s.product_id); testing another column such as s.id gave an ordinary join plus a filter. An index on the column you match on matters more than the form. Compare the plans with EXPLAIN (or the execution plan in SQL Server) on your own data, and pick NOT EXISTS for clarity when they are the same.
Should I use EXISTS or IN?
For the positive case they return the same rows and NULLs are harmless, so choose whichever reads better: IN for a literal list or a simple subquery, EXISTS when the match needs several conditions. For the negative case, prefer NOT EXISTS over NOT IN, because of NULLs.
Does MySQL support EXCEPT?
Yes, since MySQL 8.0.31, and it works on MySQL 8.4. On older MySQL versions, write the comparison with NOT EXISTS or LEFT JOIN … WHERE … IS NULL instead. Oracle uses MINUS for the same operation.
Run it yourself
Open any of these queries in the full SQL editor to change the data, run it and share it.
SQLite playground