A subquery inside IN can generate a list dynamically according to the state of the tables. There is also a NULL trap: if even one NULL is included in a NOT IN list, the result becomes zero rows.
-- IN subquery: dynamically generate user_ids whose plan is premium WHERE user_id IN ( SELECT user_id FROM users WHERE plan = 'premium' ) -- NOT IN trap: one NULL in user_id makes the result zero rows WHERE user_id NOT IN ( SELECT user_id FROM orders WHERE user_id IS NOT NULL -- preventing NULL contamination is essential )
5 NOT IN (1, NULL, 3) → 5<>1 AND 5<>NULL AND 5<>3 → TRUE AND UNKNOWN AND TRUE → UNKNOWN → excluded. If even one NULL is included, every row is excluded.From the orders table, retrieve orders for users whose plan is 'premium' using an IN subquery. Return order_id, user_id, amount, status, ordered by amount descending.
| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 2 | Hanako Sato | free |
| 3 | Ichiro Suzuki | premium |
| 4 | Jiro Yamada | standard |
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 4 | 6000 | cancelled |
Expected output (amount descending):
| order_id | user_id | amount | status |
|---|---|---|---|
| 102 | 1 | 12000 | completed |
| 104 | 3 | 9500 | completed |
| 101 | 1 | 8000 | completed |
Note: the subquery dynamically generates [1, 3]. Order 103 (user 2: free) and order 105 (user 4: standard) are excluded.
SELECT order_id, user_id, amount, status FROM orders WHERE user_id IN ( -- IN: keep rows matching the subquery result list SELECT user_id -- must return exactly one column (multiple columns cause an error) FROM users WHERE plan = 'premium' -- dynamically generate the premium user_id list ) ORDER BY amount DESC; /* Execution order (SQL's logical evaluation order): 1. Evaluate the subquery first → SELECT user_id FROM users WHERE plan='premium' → result list: [1, 3] 2. FROM orders → read 5 rows 3. WHERE user_id IN (1, 3) → filter down to 3 rows 4. SELECT order_id, user_id, ... → select 4 columns 5. ORDER BY amount DESC → sort by amount descending */
LEGEND
① Main query: FROM orders
FROM ordersAll 5 rows in the orders table are the input for the outer main query.| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 4 | 6000 | cancelled |
IN is fixed before the outer query uses it. It is logically equivalent to a fixed IN (1, 3), but the important difference is that it changes dynamically with the state of the tables.col <> NULL becomes UNKNOWN and every row is excluded. Always add WHERE col IS NOT NULL inside a NOT IN subquery, or use NOT EXISTS (explained in Q8).WHERE user_id IN (SELECT user_id FROM users WHERE plan='premium') produces the same result as INNER JOIN users ON orders.user_id = users.user_id WHERE users.plan='premium'. When columns from the joined table do not need to appear in SELECT, an IN subquery often makes the intent clearer.SELECT user_id, name FROM users, causes a syntax error. SELECT only the single column that matches the IN comparison.WHERE col IS NOT NULL inside it, or switch to NOT EXISTS.WHERE EXISTS (subquery) keeps an outer row when the subquery returns even one row. Since it checks only “whether a row exists”, regardless of what the subquery returns, SELECT 1 is sufficient inside it.
SELECT * FROM users u WHERE EXISTS ( SELECT 1 -- the value does not matter; only row existence is checked FROM orders o WHERE o.user_id = u.user_id -- a “correlated subquery” that references an outer column );
From the users table, retrieve users with at least one order whose status is 'completed'. Return user_id, name, plan, ordered by user_id ascending.
| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 2 | Hanako Sato | free |
| 3 | Ichiro Suzuki | premium |
| 4 | Jiro Yamada | standard |
| 5 | Saburo Ito | free |
| order_id | user_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 1 | completed |
| 103 | 2 | pending |
| 104 | 3 | completed |
| 105 | 4 | cancelled |
Expected output (user_id ascending):
| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 3 | Ichiro Suzuki | premium |
Note: user 2 has only pending orders; user 4 has only cancelled orders; user 5 has no orders. All are excluded.
SELECT user_id, name, plan FROM users u WHERE EXISTS ( -- TRUE if the subquery returns even one row (short-circuit evaluation) SELECT 1 -- the returned value does not matter; only row existence is checked FROM orders o WHERE o.user_id = u.user_id -- correlated condition referencing the outer u.user_id (required) AND o.status = 'completed' ) ORDER BY user_id; /* Execution order (SQL's logical evaluation order): 1. FROM users u → process one row at a time 2. EXISTS(...) → run the subquery for each user 3. SELECT user_id, name, plan → select rows that pass 4. ORDER BY user_id → sort by user_id ascending */
LEGEND
① Main query: FROM users
FROM users uThese are all 5 rows in users, the target of the main query. The subquery is evaluated for each row.| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 2 | Hanako Sato | free |
| 3 | Ichiro Suzuki | premium |
| 4 | Jiro Yamada | standard |
| 5 | Saburo Ito | free |
SELECT 1 is conventional.WHERE o.user_id = u.user_id linking the inner query to the outer query, every user passes as long as orders contains even one row. The correlated condition is essential.WHERE EXISTS (SELECT 1 FROM orders WHERE status='completed') means “if at least one completed order exists in orders,” so every user passes. Always include the join condition o.user_id = u.user_id to the outer table.SELECT * can suggest that you intend to retrieve every column. Using SELECT 1 makes the intent—checking existence only—clear.WHERE NOT EXISTS (subquery) keeps an outer row when the subquery returns zero rows. It is the negation of EXISTS and is the pattern for checking that no corresponding row exists in another table.
SELECT * FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- correlated condition );
From the users table, retrieve users with no completed orders using NOT EXISTS. Return user_id, name, plan, ordered by user_id ascending.
| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 2 | Hanako Sato | free |
| 3 | Ichiro Suzuki | premium |
| 4 | Jiro Yamada | standard |
| 5 | Saburo Ito | free |
| order_id | user_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 1 | completed |
| 103 | 2 | pending |
| 104 | 3 | completed |
| 105 | 4 | cancelled |
Expected output (user_id ascending):
| user_id | name | plan |
|---|---|---|
| 2 | Hanako Sato | free |
| 4 | Jiro Yamada | standard |
| 5 | Saburo Ito | free |
Note: users 1 and 3 have completed orders and are excluded. User 2 has only pending orders; user 4 has only cancelled orders; user 5 has no orders, so all three pass.
SELECT user_id, name, plan FROM users u WHERE NOT EXISTS ( -- pass when the subquery returns zero rows (nothing exists) SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- correlated condition referencing the outer column (required) AND o.status = 'completed' ) ORDER BY user_id; /* Execution order (SQL's logical evaluation order): 1. FROM users u → process one row at a time 2. NOT EXISTS(...) → run the subquery for each user 3. SELECT user_id, name, plan → select rows that pass 4. ORDER BY user_id → sort by user_id ascending */
LEGEND
① Main query: FROM users
FROM users uThese are all 5 rows in users, the target of the main query. The NOT EXISTS subquery runs for each row.| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 2 | Hanako Sato | free |
| 3 | Ichiro Suzuki | premium |
| 4 | Jiro Yamada | standard |
| 5 | Saburo Ito | free |
LEFT JOIN orders o ON u.user_id=o.user_id AND o.status='completed' WHERE o.user_id IS NULL produces the same result. This pattern is common in queries generated by ORMs.WHERE user_id NOT IN (SELECT user_id FROM orders) returns zero rows. Simply changing it to NOT EXISTS makes it correct. This is one of the most common production bugs.WHERE o.user_id = u.user_id is omitted inside NOT EXISTS, the presence of even one row in orders excludes every user. The correlated condition is required.NOT EXISTS (SELECT 1 FROM orders WHERE user_id = u.user_id AND status='completed' AND ordered_at >= NOW() - INTERVAL '30 days'). NOT EXISTS supports flexible conditions and is a standard pattern for production batch queries.Inside EXISTS, you can JOIN multiple tables to perform a more complex existence check. This pattern uses one EXISTS to check a two-step relationship, such as “users who ordered a product in this category.”
WHERE EXISTS ( SELECT 1 FROM orders o JOIN products p ON o.product_id = p.product_id WHERE o.user_id = u.user_id -- correlation with the outer query AND p.category = 'Smartphone' -- category condition )
From the users table, retrieve users who have ever ordered at least one product in the 'PC' category. Return user_id, name, plan, ordered by user_id ascending.
| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 2 | Hanako Sato | free |
| 3 | Ichiro Suzuki | premium |
| 4 | Jiro Yamada | standard |
| order_id | user_id | product_id | status |
|---|---|---|---|
| 101 | 1 | 1 | completed |
| 102 | 1 | 2 | completed |
| 103 | 2 | 3 | pending |
| 104 | 3 | 4 | completed |
| 105 | 4 | 2 | cancelled |
| product_id | name | category |
|---|---|---|
| 1 | Smartphone X | Smartphone |
| 2 | Smartbook Pro | PC |
| 3 | Tablet Air | Tablet |
| 4 | Wireless Earbuds | Accessories |
Expected output (user_id ascending):
| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 4 | Jiro Yamada | standard |
Note: order 102 (user 1, product 2: PC) and order 105 (user 4, product 2: PC) match. Users 2 and 3 have no PC orders.
SELECT user_id, name, plan FROM users u WHERE EXISTS ( SELECT 1 FROM orders o JOIN products p ON o.product_id = p.product_id -- join to product information WHERE o.user_id = u.user_id -- correlated condition with the outer users AND p.category = 'PC' -- check whether a PC-category product was ordered ) ORDER BY user_id; /* Execution order (SQL's logical evaluation order): 1. FROM users u → process one row at a time 2. EXISTS(...) → check for a PC order for each user 3. SELECT user_id, name, plan → select rows that pass 4. ORDER BY user_id → sort by user_id ascending */
LEGEND
① Main query: FROM users
FROM users uThese are the users in the main query.| user_id | name | plan |
|---|---|---|
| 1 | Taro Tanaka | premium |
| 2 | Hanako Sato | free |
| 3 | Ichiro Suzuki | premium |
| 4 | Jiro Yamada | standard |
WHERE user_id IN (SELECT o.user_id FROM orders o JOIN products p ON o.product_id=p.product_id WHERE p.category='PC') with the same result. EXISTS is safer with respect to NULL, however.WHERE EXISTS (...) AND EXISTS (...), retrieves users who ordered both A and B. Reproducing this with IN subqueries requires a complicated INTERSECT, so EXISTS is more natural.WHERE user_id = u.user_id can make the join ambiguous. Always give the tables used inside EXISTS aliases and make the relationship explicit, as in o.user_id = u.user_id.AND NOT EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id=u.user_id AND o2.product_id=X) further narrows the result to “users who bought a PC but have not yet bought product X.” Combining predicates lets you express complex business conditions.Practical queries express complex conditions by combining multiple predicates. Learn the three-stage filter pattern of narrowing a numeric range with a comparison predicate, selecting specific categories with IN, and checking for an order with EXISTS.
WHERE p.price >= 50000 -- ① comparison predicate: price range AND p.category IN ('PC', 'Tablet') -- ② IN predicate: category selection AND EXISTS ( -- ③ EXISTS predicate: check for an order SELECT 1 FROM orders o WHERE o.product_id = p.product_id AND o.status = 'completed' )
From the products table, retrieve products where price is at least 50,000 and category is 'PC' or 'Tablet', and where at least one completed order exists. Return product_id, name, category, price, ordered by price descending.
| product_id | name | category | price | stock |
|---|---|---|---|---|
| 1 | Smartphone X | Smartphone | 89800 | 50 |
| 2 | Smartbook Pro | PC | 128000 | 0 |
| 3 | Tablet Air | Tablet | 64800 | 30 |
| 4 | Wireless Earbuds | Accessories | 12800 | 100 |
| 5 | USB Cable | Accessories | 980 | 200 |
| 6 | Gaming PC Pro | PC | 198000 | 5 |
| order_id | product_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 2 | completed |
| 103 | 3 | pending |
| 104 | 6 | completed |
| 105 | 2 | cancelled |
Expected output (price descending):
| product_id | name | category | price |
|---|---|---|---|
| 6 | Gaming PC Pro | PC | 198000 |
| 2 | Smartbook Pro | PC | 128000 |
Note: product 1 is excluded because it is in the Smartphone category; product 3 is a Tablet but has no completed order (only pending); products 4 and 5 are excluded because they are Accessories and below the price floor.
SELECT product_id, name, category, price FROM products p WHERE p.price >= 50000 -- ① comparison predicate: at least 50,000 (index-friendly) AND p.category IN ('PC', 'Tablet') -- ② IN predicate: target categories only AND EXISTS ( -- ③ EXISTS predicate: check for a completed order SELECT 1 FROM orders o WHERE o.product_id = p.product_id -- correlated condition with the outer product AND o.status = 'completed' -- check completed orders only ) ORDER BY p.price DESC; /* Execution order (SQL's logical evaluation order): 1. FROM products p → read 6 rows 2. WHERE p.price threshold → filter by price 3. AND p.category IN (...) → filter by category 4. AND EXISTS (...) → filter to products with completed orders 5. SELECT product_id, name, category, price → select 4 columns 6. ORDER BY p.price DESC → sort by price descending */
LEGEND
① Main query: FROM products
FROM products pThese are all 6 rows in products, the target of the main query. Three filters will be applied in sequence.| product_id | name | category | price |
|---|---|---|---|
| 1 | Smartphone X | Smartphone | 89800 |
| 2 | Smartbook Pro | PC | 128000 |
| 3 | Tablet Air | Tablet | 64800 |
| 4 | Wireless Earbuds | Accessories | 12800 |
| 5 | USB Cable | Accessories | 980 |
| 6 | Gaming PC Pro | PC | 198000 |
AND has higher precedence than OR. WHERE A OR B AND C is interpreted as WHERE A OR (B AND C). When combining multiple predicates, make a habit of grouping them explicitly with parentheses so the query behaves as intended.WHERE price >= 50000 AND category = 'PC' OR category = 'Tablet' is interpreted as WHERE (price >= 50000 AND category = 'PC') OR category = 'Tablet'. The Tablet price condition is then ineffective, producing unintended results. Use parentheses and write category IN ('PC', 'Tablet') to solve the problem.