SQL 46 🛢️ Subquery Operators: EXISTS and NOT EXISTS
A subquery can return a value, a list of values, or a set of rows. The EXISTS operator is different from all of these. It does not return a value. It returns a boolean: true if the subquery produces at least one row, false if it produces none. This distinction matters because EXISTS is not about what the subquery returns—it is about whether it returns anything at all. The columns in the SELECT list are irrelevant. The only thing that matters is whether a row exists that satisfies the subquery’s conditions.
This chapter covers EXISTS and NOT EXISTS in full. You will learn the syntax, how the correlated form works, why EXISTS is often faster than IN, how it handles nulls differently from NOT IN, and when to reach for it over a join. The operator is one of the most important tools in SQL for writing queries that are both correct and efficient.
Key point: EXISTS evaluates to true if the subquery returns at least one row, and false otherwise. The subquery is typically correlated—it references a column from the outer query—so it is evaluated once per outer row. The database can stop scanning as soon as it finds one matching row, which makes EXISTS efficient even on large tables.
Why EXISTS and NOT EXISTS exist
The membership problem. IN checks whether a value appears in a list. It works well when the list is small or when the subquery returns a small number of rows. But IN has two weaknesses. First, it compares a single column, not a row. Second, it cannot short-circuit—the database must evaluate the entire subquery before checking membership. EXISTS addresses both: it checks for the presence of a row, and it can stop as soon as one is found.
The null problem. NOT IN has a well-known trap. If the subquery returns even a single NULL, the entire predicate evaluates to NULL, and no rows are returned. This is not a bug; it is the semantics of three-valued logic. NOT EXISTS does not have this problem because it checks for the absence of rows, not the absence of values. A NULL in the subquery’s comparison column is simply a row that does not match, and NOT EXISTS correctly returns true when no matching rows exist.
The multi-column problem. EXISTS can check for the existence of a row that satisfies multiple conditions, not just a single column value. The subquery can have a WHERE clause with any number of conditions, and EXISTS returns true if any row satisfies all of them. This makes it suitable for complex existence checks that IN cannot express with a single column.
The performance problem. On large tables, EXISTS is often faster than IN because of short-circuit evaluation. The database can stop scanning the subquery as soon as it finds one matching row. IN must evaluate the entire subquery and build a list of values before checking membership. When the subquery returns many rows, this difference is significant.
The readability problem. EXISTS expresses the intent directly: “does a row exist that satisfies this condition?” IN expresses a different intent: “is this value in this list?” When the question is about existence, EXISTS is the clearer formulation.
a. Basic syntax and correlated subqueries
The syntax of EXISTS is WHERE EXISTS (subquery). The subquery is a complete SELECT statement. The conventional form selects the literal 1 because the columns do not matter.
SELECT customer_id, name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
The subquery references c.customer_id from the outer query. This makes it correlated. For each customer row, the database runs the subquery to check whether any order exists for that customer. If at least one order exists, EXISTS returns true, and the customer row is included in the result.
The subquery can be uncorrelated, in which case EXISTS returns the same boolean for every outer row.
SELECT customer_id, name
FROM customers
WHERE EXISTS (
SELECT 1
FROM orders
WHERE amount > 10000
);
If any order with an amount greater than 10,000 exists, every customer is returned. This is rarely useful, but it illustrates that EXISTS does not require correlation.
The SELECT 1 is a convention, not a requirement. SELECT *, SELECT column, and SELECT 1 all behave identically because EXISTS only checks for row presence. Some databases optimize SELECT 1 slightly better, but the difference is negligible. The convention signals to the reader that the columns are irrelevant.
The NOT EXISTS form returns rows for which the subquery produces no rows.
SELECT customer_id, name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
This returns customers who have never placed an order. It is the null-safe equivalent of NOT IN.
b. EXISTS versus IN and joins
The three ways to check for the existence of related rows—EXISTS, IN, and JOIN—produce similar results but differ in semantics and performance.
IN compares a column against a list of values. The subquery must return a single column.
SELECT customer_id, name
FROM customers
WHERE customer_id IN (
SELECT customer_id
FROM orders
);
This works, but it requires the subquery to return exactly one column. If the subquery returns multiple columns, IN is a syntax error. IN also does not short-circuit; the database must evaluate the entire subquery.
EXISTS checks for the presence of a row. The subquery can select any columns and can have any number of conditions in its WHERE clause.
SELECT customer_id, name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > 1000
);
This returns customers who have at least one order over $1,000. The subquery has two conditions, which IN cannot express without a derived table.
JOIN combines rows from two tables. An inner join returns only customers who have orders, but it can produce duplicate rows when a customer has multiple orders.
SELECT DISTINCT c.customer_id, c.name
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id;
The DISTINCT is necessary because a customer with three orders appears three times in the join. EXISTS does not have this problem because it does not produce the joined rows—it only checks whether they exist.
The choice among these depends on the situation. Use EXISTS when the question is “does a related row exist?” Use IN when comparing a single column against a list. Use JOIN when you need columns from both tables in the result.
c. NOT EXISTS and the null safety advantage
The most important practical difference between NOT EXISTS and NOT IN is how they handle nulls.
Consider a query that finds customers with no orders. The NOT IN version:
SELECT customer_id, name
FROM customers
WHERE customer_id NOT IN (
SELECT customer_id
FROM orders
);
If the orders.customer_id column contains a single NULL—perhaps an order that was not associated with a customer—the subquery returns a list that includes NULL. The NOT IN predicate then evaluates customer_id <> NULL for each customer, which is NULL, not true. The entire predicate becomes NULL, and no rows are returned. The query silently produces the wrong result.
The NOT EXISTS version does not have this problem:
SELECT customer_id, name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
For each customer, the subquery checks whether any order exists with that customer’s ID. If orders.customer_id contains NULL, the comparison o.customer_id = c.customer_id evaluates to NULL for that row, which is treated as not a match. The subquery returns no rows for that order, and NOT EXISTS correctly returns true for customers with no matching orders.
This is why NOT EXISTS is the recommended form for “rows in one table with no match in another.” It is null-safe, it short-circuits, and it expresses the intent directly.
The NOT IN form remains appropriate when the subquery is guaranteed to return no nulls—for example, when the column is declared NOT NULL and the query is known to be correct. But the guarantee is easy to break when the schema changes, and the failure mode is silent. NOT EXISTS is the safer default.
Complete Example Session
-- ============================================
-- PART 1: SAMPLE DATA
-- ============================================
CREATE TABLE customers (
customer_id INT,
name VARCHAR(50)
);
CREATE TABLE orders (
order_id INT,
customer_id INT,
amount DECIMAL(10,2)
);
INSERT INTO customers VALUES
(1, 'Alice'),
(2, 'Bob'),
(3, 'Carol'),
(4, 'Dave');
INSERT INTO orders VALUES
(100, 1, 250.00),
(101, 1, 400.00),
(102, 2, 150.00),
(103, 3, 1200.00);
-- ============================================
-- PART 2: BASIC EXISTS
-- ============================================
SELECT customer_id, name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
);
-- Result: Alice, Bob, Carol (customers with orders)
-- ============================================
-- PART 3: NOT EXISTS
-- ============================================
SELECT customer_id, name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
);
-- Result: Dave (customer with no orders)
-- ============================================
-- PART 4: EXISTS WITH ADDITIONAL CONDITION
-- ============================================
SELECT customer_id, name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > 1000
);
-- Result: Carol (only customer with an order over 1000)
-- ============================================
-- PART 5: COMPARISON WITH IN
-- ============================================
SELECT customer_id, name
FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders
);
-- Same result: Alice, Bob, Carol
-- ============================================
-- PART 6: COMPARISON WITH JOIN
-- ============================================
SELECT DISTINCT c.customer_id, c.name
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id;
-- Same result: Alice, Bob, Carol
-- DISTINCT required because Alice has two orders
-- ============================================
-- PART 7: THE NULL TRAP WITH NOT IN
-- ============================================
INSERT INTO orders VALUES (104, NULL, 50.00);
SELECT customer_id, name
FROM customers
WHERE customer_id NOT IN (
SELECT customer_id FROM orders
);
-- Result: empty! The NULL in orders makes all comparisons NULL
-- ============================================
-- PART 8: NOT EXISTS IS NULL-SAFE
-- ============================================
SELECT customer_id, name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
);
-- Result: Dave (correct, despite NULL in orders)
-- ============================================
-- PART 9: EXISTS WITH MULTIPLE CONDITIONS
-- ============================================
SELECT customer_id, name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount BETWEEN 200 AND 500
);
-- Result: Alice (has orders of 250 and 400)
-- ============================================
-- PART 10: EXISTS IN UPDATE AND DELETE
-- ============================================
DELETE FROM customers
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = customers.customer_id
);
-- Deletes customers with no orders
The ten parts covered basic EXISTS, NOT EXISTS, EXISTS with additional conditions, comparison with IN, comparison with JOIN, the NOT IN null trap, the NOT EXISTS fix, EXISTS with multiple conditions, and EXISTS in DELETE.
Quick Reference
EXISTS and NOT EXISTS
| Operator | Returns True When |
|---|---|
EXISTS | Subquery returns at least one row |
NOT EXISTS | Subquery returns no rows |
EXISTS vs IN vs JOIN
| Aspect | EXISTS | IN | JOIN |
|---|---|---|---|
| Purpose | Existence check | Membership check | Combine rows |
| Subquery columns | Any | Exactly one | Any |
| Multiple conditions | Yes | No (directly) | Yes |
| Short-circuit | Yes | No | No |
| Duplicate rows | No | No | Yes |
| Null-safe | Yes | NOT IN is not | Yes |
Null Behavior
| Form | With NULL in Subquery |
|---|---|
IN | NULL is ignored |
NOT IN | Returns no rows |
EXISTS | Unaffected |
NOT EXISTS | Unaffected |
Common Patterns
| Pattern | Query |
|---|---|
| Has related rows | WHERE EXISTS (SELECT 1 ...) |
| Has no related rows | WHERE NOT EXISTS (SELECT 1 ...) |
| Related rows with condition | WHERE EXISTS (SELECT 1 ... WHERE ...) |
| Delete orphaned rows | DELETE ... WHERE NOT EXISTS (...) |
Best Practices
✅ Do This:
-- Use EXISTS for existence checks
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id) -- ✅
-- Use NOT EXISTS instead of NOT IN
WHERE NOT EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id) -- ✅
-- Use SELECT 1 as a convention
WHERE EXISTS (SELECT 1 FROM orders o WHERE ...) -- ✅
-- Add conditions to the subquery, not the outer query
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > 1000) -- ✅
-- Index the correlation column
CREATE INDEX idx_orders_customer ON orders(customer_id); -- ✅
❌ Don’t Do This:
-- Don't use NOT IN with a nullable subquery
WHERE customer_id NOT IN (SELECT customer_id FROM orders) -- ❌
-- Don't use JOIN when EXISTS is the intent
SELECT DISTINCT c.id FROM customers c
JOIN orders o ON o.customer_id = c.id -- ⚠️
-- Don't forget the correlation
WHERE EXISTS (SELECT 1 FROM orders o) -- ❌ always true
-- Don't use EXISTS when you need columns from both tables
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id) -- ⚠️ no order columns
-- Don't skip the index on the correlation column
-- (slow on large tables) -- ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
NOT IN returns no rows | NULL in subquery | Use NOT EXISTS |
EXISTS always true | Missing correlation | Add WHERE linking to outer |
| Slow performance | No index on correlation column | Create index |
| Duplicate rows from join | Using JOIN for existence | Use EXISTS |
SELECT * in EXISTS | Unnecessary columns | Use SELECT 1 |
| Missing rows | Correlation column mismatch | Verify the join condition |
Real-World Examples
1. Customers With Orders
SELECT customer_id FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id);
2. Customers Without Orders
SELECT customer_id FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id);
3. Customers With Large Orders
SELECT customer_id FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
AND o.amount > 1000);
4. Products Never Ordered
SELECT product_id FROM products p
WHERE NOT EXISTS (SELECT 1 FROM order_items oi
WHERE oi.product_id = p.product_id);
5. Users With Recent Activity
SELECT user_id FROM users u
WHERE EXISTS (SELECT 1 FROM activity a
WHERE a.user_id = u.user_id
AND a.created_at > CURRENT_DATE - 30);
6. Departments With No Employees
SELECT department_id FROM departments d
WHERE NOT EXISTS (SELECT 1 FROM employees e
WHERE e.department_id = d.department_id);
7. Orders With Special Items
SELECT order_id FROM orders o
WHERE EXISTS (SELECT 1 FROM order_items oi
WHERE oi.order_id = o.order_id
AND oi.is_special = true);
8. Delete Orphaned Records
DELETE FROM sessions s
WHERE NOT EXISTS (SELECT 1 FROM users u
WHERE u.user_id = s.user_id);
9. Update With Existence Check
UPDATE customers c
SET vip = true
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
GROUP BY o.customer_id
HAVING SUM(o.amount) > 10000);
10. Multi-Condition Existence
SELECT user_id FROM users u
WHERE EXISTS (SELECT 1 FROM logins l
WHERE l.user_id = u.user_id
AND l.success = true
AND l.ip_address NOT LIKE '10.%');
Visual
EXISTS Evaluation Flow
┌─────────────────────────────────────────────────────────────┐
│ EXISTS EVALUATION │
│ │
│ For each outer row: │
│ │ │
│ ▼ │
│ Run subquery with outer row's value │
│ │ │
│ ▼ │
│ Did the subquery return at least one row? │
│ │ │
│ ├── YES ──▶ EXISTS = true, include outer row │
│ │ (stop scanning, short-circuit) │
│ │ │
│ └── NO ──▶ EXISTS = false, exclude outer row │
│ │
│ NOT EXISTS is the inverse: include when no rows. │
│ │
└─────────────────────────────────────────────────────────────┘
EXISTS vs IN vs JOIN
┌─────────────────────────────────────────────────────────────┐
│ EXISTS │
│ │
│ SELECT c.id FROM customers c │
│ WHERE EXISTS (SELECT 1 FROM orders o │
│ WHERE o.customer_id = c.id); │
│ │
│ For each customer: does an order exist? │
│ Stops at first match. No duplicate rows. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ IN │
│ │
│ SELECT c.id FROM customers c │
│ WHERE c.id IN (SELECT customer_id FROM orders); │
│ │
│ Build list of all order customer_ids. │
│ Check each customer against the list. │
│ Single column only. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ JOIN │
│ │
│ SELECT DISTINCT c.id FROM customers c │
│ JOIN orders o ON o.customer_id = c.id; │
│ │
│ Produce all matching rows. │
│ DISTINCT required for duplicates. │
│ Can select columns from both tables. │
│ │
└─────────────────────────────────────────────────────────────┘
The NOT IN Null Trap
┌─────────────────────────────────────────────────────────────┐
│ NOT IN WITH NULL │
│ │
│ customers: [1, 2, 3] │
│ orders.customer_id: [1, NULL] │
│ │
│ WHERE customer_id NOT IN (SELECT customer_id FROM orders) │
│ │
│ For customer 2: │
│ 2 <> 1 → TRUE │
│ 2 <> NULL → NULL │
│ TRUE AND NULL = NULL │
│ → customer 2 not returned │
│ │
│ Result: empty │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ NOT EXISTS WITH NULL │
│ │
│ WHERE NOT EXISTS (SELECT 1 FROM orders o │
│ WHERE o.customer_id = c.customer_id) │
│ │
│ For customer 2: │
│ Subquery: any order with customer_id = 2? │
│ NULL does not equal 2, so no match │
│ → NOT EXISTS is TRUE │
│ → customer 2 returned │
│ │
│ Result: [2, 3] │
│ │
└─────────────────────────────────────────────────────────────┘
Short-Circuit Performance
┌─────────────────────────────────────────────────────────────┐
│ EXISTS SHORT-CIRCUITS │
│ │
│ Customer 1 has 1000 orders. │
│ │
│ Subquery scans orders for customer_id = 1: │
│ First match found → STOP │
│ Does not scan remaining 999 orders │
│ │
│ Total work: 1 index lookup + 1 row read │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ IN DOES NOT SHORT-CIRCUIT │
│ │
│ Builds the full list of customer_ids from orders. │
│ Scans all 1000 orders. │
│ Then checks each customer against the list. │
│ │
│ Total work: 1000 row reads + list construction │
│ │
│ With an index on orders.customer_id, EXISTS is faster. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
EXISTS | True if subquery returns at least one row |
NOT EXISTS | True if subquery returns no rows |
| Subquery columns | Any (SELECT 1 convention) |
| Correlation | Typically correlates on outer column |
| Short-circuit | Yes, stops at first match |
| Null-safe | Yes, unlike NOT IN |
| Multiple conditions | Yes, in subquery WHERE |
| Duplicate rows | No |
| Performance | Fast with index on correlation column |
| Use case | Existence checks, anti-joins |
Key takeaways:
EXISTSreturns a boolean, not a value. It checks whether the subquery produces at least one row. The columns selected do not matter;SELECT 1is a convention that signals this.EXISTSis typically correlated. The subquery references a column from the outer query, so it is evaluated once per outer row. The correlation is what makes it useful for per-row existence checks.EXISTSshort-circuits. The database stops scanning the subquery as soon as it finds one matching row. This makes it efficient on large tables, provided the correlation column is indexed.NOT EXISTSis null-safe;NOT INis not. If the subquery returns anyNULL,NOT INreturns no rows.NOT EXISTShandles nulls correctly and is the preferred form for anti-joins.EXISTScan express conditions thatINcannot. The subquery can have any number of conditions in itsWHEREclause.INcompares a single column against a list of values.EXISTSdoes not produce duplicate rows. A join produces one row per matching pair;EXISTSproduces one row per outer row that has at least one match.- Use
EXISTSwhen the question is existence. UseINwhen comparing a single column against a list. UseJOINwhen you need columns from both tables in the result.
Remember: EXISTS and NOT EXISTS are the operators for existence checks. They answer the question “does a related row exist?” without returning the related rows themselves. This makes them suitable for filtering, for anti-joins, and for conditions that involve multiple columns. NOT EXISTS is the safe alternative to NOT IN because it does not have the null trap. Index the correlation column, use SELECT 1 as a convention, and reach for EXISTS when the intent is existence.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!