The WHERE Clause¶
SELECT tells SQL which columns to return. WHERE tells it which rows matter. Every business question you answer with data starts with filtering to the right subset — without correct filtering you're either analysing too much data or not enough.
Learning Objectives¶
- Filter rows using comparison operators
- Combine conditions with
AND,OR,NOT - Handle NULL values correctly
- Use
BETWEEN,IN, andLIKEfor common filtering patterns - Identify and fix silent bugs caused by AND/OR precedence and NULL comparisons
Basic Filtering¶
-- Business question: which orders are worth more than £100?
SELECT
order_id,
customer_id,
order_date,
quantity * unit_price AS order_total
FROM orders
WHERE quantity * unit_price > 100;
Sample output:
| order_id | customer_id | order_date | order_total |
|---|---|---|---|
| O002 | C003 | 2024-01-07 | 389.00 |
| O001 | C001 | 2024-01-05 | 298.00 |
| O007 | C003 | 2024-01-15 | 249.95 |
Comparison Operators¶
| Operator | Meaning | Example |
|---|---|---|
= |
Equal to | WHERE status = 'completed' |
<> or != |
Not equal to | WHERE status <> 'cancelled' |
> |
Greater than | WHERE quantity * unit_price > 100 |
< |
Less than | WHERE quantity < 5 |
>= |
Greater than or equal | WHERE order_date >= '2024-01-01' |
<= |
Less than or equal | WHERE unit_price <= 50.00 |
String comparison is case-sensitive in PostgreSQL
WHERE status = 'Completed' will NOT match 'completed' in PostgreSQL. MySQL with utf8mb4_unicode_ci collation is case-insensitive by default. Use LOWER(status) = 'completed' to be safe across databases.
Date literals must be quoted as strings
WHERE order_date >= 2024-01-01 is parsed as an arithmetic expression (2024 minus 1 minus 1 = 2022), not a date. Always quote date literals: WHERE order_date >= '2024-01-01'.
Combining Conditions¶
AND — both conditions must be true¶
-- Orders placed this year that are worth more than £100
SELECT order_id, customer_id, order_date, quantity * unit_price AS order_total
FROM orders
WHERE order_date >= '2024-01-01'
AND quantity * unit_price > 100;
OR — at least one must be true¶
-- Orders that are either pending or still being processed
SELECT order_id, status
FROM orders
WHERE status = 'pending'
OR status = 'processing';
NOT — negate a condition¶
-- All orders that are not cancelled
SELECT order_id, status, order_date
FROM orders
WHERE NOT status = 'cancelled';
-- Equivalent to: WHERE status <> 'cancelled'
Parentheses — always use them when mixing AND and OR¶
AND binds more tightly than OR. Without parentheses, you get wrong results without any error message.
-- Correct: high-value orders that are completed OR pending
SELECT order_id, quantity * unit_price AS order_total, status
FROM orders
WHERE quantity * unit_price > 500
AND (status = 'completed' OR status = 'pending');
-- WRONG: reads as (order_total > 500 AND status = 'completed') OR (status = 'pending')
-- Returns ALL pending orders regardless of value — not what was asked
WHERE quantity * unit_price > 500 AND status = 'completed' OR status = 'pending';
Sample output (correct query):
| order_id | order_total | status |
|---|---|---|
| O002 | 389.00 | completed |
| O008 | 389.00 | pending |
Missing parentheses with AND/OR is one of the most common SQL bugs
A query that runs without error can still return completely wrong data. Always parenthesise when mixing AND and OR. When in doubt, add the parentheses.
Filtering Ranges — BETWEEN¶
BETWEEN is inclusive on both ends — it includes the boundary values.
-- Orders with a gross total between £100 and £400 (inclusive)
SELECT order_id, customer_id, quantity * unit_price AS order_total
FROM orders
WHERE quantity * unit_price BETWEEN 100 AND 400;
-- Same as: WHERE quantity * unit_price >= 100 AND quantity * unit_price <= 400
-- Orders placed in January 2024
SELECT order_id, order_date, status
FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31';
Sample output:
| order_id | order_date | status |
|---|---|---|
| O001 | 2024-01-05 | completed |
| O002 | 2024-01-07 | completed |
| O003 | 2024-01-09 | pending |
| O004 | 2024-01-10 | completed |
BETWEEN with datetime columns in MySQL
If order_date stores datetime values (not just dates), BETWEEN '2024-01-01' AND '2024-01-31' may miss rows from January 31 after midnight because it compares against '2024-01-31 00:00:00'. Use order_date < '2024-02-01' instead.
Filtering Lists — IN¶
-- Without IN — verbose and error-prone
WHERE status = 'pending' OR status = 'processing' OR status = 'on_hold';
-- With IN — clean and readable
WHERE status IN ('pending', 'processing', 'on_hold');
-- Exclude a list
WHERE status NOT IN ('cancelled', 'refunded');
Business use: find orders for specific products
SELECT order_id, product_id, order_date
FROM orders
WHERE product_id IN ('P001', 'P003', 'P005')
AND status = 'completed';
NOT IN with NULLs returns zero rows
If the list passed to NOT IN contains a NULL value, the entire condition evaluates to UNKNOWN and returns nothing — even rows that should clearly pass the filter.
-- If any product_id in the subquery is NULL, this returns ZERO rows
WHERE product_id NOT IN (SELECT product_id FROM discontinued_products);
-- Safe alternative: filter NULLs out explicitly
WHERE product_id NOT IN (
SELECT product_id FROM discontinued_products WHERE product_id IS NOT NULL
);
-- Even safer: use NOT EXISTS
WHERE NOT EXISTS (
SELECT 1 FROM discontinued_products dp WHERE dp.product_id = orders.product_id
);
For mid-level analysts
IN with a subquery runs the subquery once and caches the result (non-correlated). On very large result sets, EXISTS is often faster because it short-circuits on the first match. Test both with EXPLAIN on your specific data.
Pattern Matching — LIKE¶
% matches any sequence of characters (including zero characters). _ matches exactly one character.
-- Customers with Gmail addresses
SELECT customer_id, email
FROM customers
WHERE email LIKE '%@gmail.com';
-- Products starting with "Wireless"
SELECT product_id, product_name
FROM products
WHERE product_name LIKE 'Wireless%';
-- Find any order_id that is exactly 4 characters
WHERE order_id LIKE '____'; -- four underscores = exactly 4 chars
Sample output:
| customer_id | first_name | |
|---|---|---|
| C001 | Priya | priya@gmail.com |
| C003 | Sarah | sarah@gmail.com |
Leading wildcard forces a full table scan
LIKE '%keyword' or LIKE '%keyword%' cannot use a standard B-tree index on that column. The database must check every row. On tables with millions of rows, this is extremely slow.
For mid-level analysts
LIKE 'keyword%' (trailing wildcard only) can use an index. For suffix or substring search on large tables, use full-text search: MATCH(email) AGAINST('gmail.com') in MySQL, or to_tsvector + to_tsquery in PostgreSQL.
For senior analysts
If you need case-insensitive LIKE, MySQL's default collation handles it automatically. PostgreSQL requires ILIKE for case-insensitive matching or a functional index on LOWER(column).
Handling NULL Values¶
NULL means "unknown" or "not applicable." It is not zero. It is not an empty string. It propagates through all operations.
-- WRONG: always evaluates to UNKNOWN, returns zero rows
WHERE delivery_date = NULL;
WHERE delivery_date != NULL;
-- CORRECT
WHERE delivery_date IS NULL; -- orders not yet delivered
WHERE delivery_date IS NOT NULL; -- orders that have been delivered
Business use: find orders awaiting delivery
SELECT order_id, customer_id, order_date, status
FROM orders
WHERE delivery_date IS NULL
AND status NOT IN ('cancelled', 'refunded')
ORDER BY order_date ASC;
Sample output:
| order_id | customer_id | order_date | status |
|---|---|---|---|
| O003 | C002 | 2024-01-09 | pending |
| O007 | C003 | 2024-01-15 | pending |
| O008 | C002 | 2024-01-18 | pending |
NULL comparisons always evaluate to UNKNOWN
NULL = NULL is UNKNOWN, not TRUE. NULL <> NULL is also UNKNOWN. Any comparison involving NULL produces UNKNOWN, which behaves like FALSE in a WHERE clause — those rows are excluded. The only way to check for NULL is IS NULL or IS NOT NULL.
For senior analysts
COALESCE(column, default) substitutes a value for NULL. COALESCE(delivery_date, '9999-12-31') lets you sort undelivered orders to the bottom without breaking the sort logic. NULLIF(value, 0) returns NULL when the value equals 0 — useful to prevent division-by-zero: SUM(revenue) / NULLIF(COUNT(orders), 0).
A Real Business Question¶
"Show me all pending orders placed in January 2024 with a gross total over £50."
SELECT
o.order_id,
o.customer_id,
o.order_date,
o.quantity * o.unit_price AS gross_total,
o.status
FROM orders o
WHERE o.status = 'pending'
AND o.order_date BETWEEN '2024-01-01' AND '2024-01-31'
AND o.quantity * o.unit_price > 50
ORDER BY o.quantity * o.unit_price DESC;
Sample output:
| order_id | customer_id | order_date | gross_total | status |
|---|---|---|---|---|
| O008 | C002 | 2024-01-18 | 389.00 | pending |
| O007 | C003 | 2024-01-15 | 249.95 | pending |
| O003 | C002 | 2024-01-09 | 149.97 | pending |
Practice Exercises¶
Warm-up
1. Retrieve all orders where status = 'completed'.
2. Find customers in city = 'Mumbai'.
3. Find orders where the gross total (quantity * unit_price) is greater than £200.
Main
4. Find all orders placed in Q4 2023 (October–December) with a gross total between £100 and £500.
5. Find customers whose email ends in @company.com OR who are from country = 'India'. Use parentheses correctly.
6. Find products in category IN ('Electronics', 'Furniture') with unit_price > 100.
7. Find undelivered orders (delivery_date IS NULL) that are neither cancelled nor refunded. Sort by order_date ascending.
Stretch
8. Customers whose first_name starts with 'R' and who signed up in 2022 or 2023.
9. Orders where discount_pct IS NOT NULL and the discount is greater than 10%. Calculate the gross and net total for each.
10. Explain in plain English what this returns — then verify with your data:
Interview Questions¶
[Beginner] What is the difference between WHERE status = NULL and WHERE status IS NULL?
Show answer
WHERE status = NULL never returns any rows because NULL = NULL evaluates to UNKNOWN (not TRUE) in SQL. Any comparison involving NULL produces UNKNOWN, which filters out the row.
WHERE status IS NULL is the correct syntax — it specifically checks whether the value is NULL.
-- Always returns zero rows, regardless of data
WHERE status = NULL;
-- Returns rows where status has no value
WHERE status IS NULL;
This is one of the most common beginner mistakes in SQL. The rule: never use = with NULL. Always use IS NULL or IS NOT NULL.
[Beginner] What does LIKE '%2024%' match?
Show answer
It matches any string that contains the substring 2024 anywhere in the value. The % wildcard matches any sequence of zero or more characters.
Examples of strings it would match:
- 'order-2024-01' — contains 2024
- '2024-annual-report' — starts with 2024
- 'report_2024' — ends with 2024
- 'q1-2024-results-v2' — contains 2024 in the middle
It would NOT match '202' or '24-01' — the substring 2024 must be present exactly.
Note: leading % prevents index use. On large tables, this causes a full table scan.
[Mid-level] Why is NOT IN dangerous when the list might contain NULL values?
Show answer
NOT IN (a, b, NULL) expands internally to: column <> a AND column <> b AND column <> NULL.
The third condition column <> NULL always evaluates to UNKNOWN. Since WHERE requires TRUE (not UNKNOWN) to include a row, and UNKNOWN AND anything = UNKNOWN, the entire condition returns UNKNOWN for every row — so zero rows are returned.
This is especially dangerous with subqueries:
-- If ANY row in the subquery has a NULL product_id, this returns ZERO rows
WHERE product_id NOT IN (SELECT product_id FROM discontinued_products);
-- Safe: filter NULLs out of the subquery
WHERE product_id NOT IN (
SELECT product_id FROM discontinued_products WHERE product_id IS NOT NULL
);
-- Best: use NOT EXISTS — correctly handles NULLs
WHERE NOT EXISTS (
SELECT 1 FROM discontinued_products dp
WHERE dp.product_id = orders.product_id
);
[Mid-level] Write two equivalent queries to filter Q1 2024 orders — one using BETWEEN, one using comparison operators.
Show answer
-- Using BETWEEN (inclusive on both ends)
SELECT order_id, order_date
FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-03-31';
-- Using comparison operators
SELECT order_id, order_date
FROM orders
WHERE order_date >= '2024-01-01'
AND order_date <= '2024-03-31';
Both return identical results. BETWEEN is slightly more readable for range filters. The comparison approach gives more control — useful when you need exclusive boundaries or when the column is a datetime and you want < '2024-04-01' instead of <= '2024-03-31 23:59:59'.
[Mid-level] A stakeholder asks for "all orders that are high-value (gross total over £1000) OR from VIP customers in London." Write the WHERE clause and explain why parentheses matter.
Show answer
-- CORRECT: parentheses make the OR explicit
WHERE quantity * unit_price > 1000
OR (c.segment = 'VIP' AND c.city = 'London')
Without the parentheses around the second condition:
-- WRONG: AND binds before OR, so this means:
-- (order_total > 1000 OR segment = 'VIP') AND city = 'London'
WHERE quantity * unit_price > 1000 OR c.segment = 'VIP' AND c.city = 'London'
The wrong version restricts high-value orders to London only — not what was asked. The AND/OR precedence rule: AND always binds tighter than OR. Parenthesise every OR condition to make intent explicit and prevent silent bugs.
[Senior] A query with WHERE email LIKE '%@gmail.com' on a 50-million-row table takes 30 seconds. How would you diagnose and fix it?
Show answer
Diagnosis:
Run EXPLAIN (MySQL) or EXPLAIN ANALYZE (PostgreSQL). A leading wildcard ('%...') forces a full table scan — no B-tree index can help because the database doesn't know which prefix to look for. You'll see "Full Table Scan" or "Seq Scan" in the plan.
Fix options (ranked by effectiveness):
-
Store the email domain separately — add a
email_domain VARCHAR(100)column, populate it withSUBSTRING_INDEX(email, '@', -1), index it, and queryWHERE email_domain = 'gmail.com'. This is the cleanest long-term solution. -
Full-text search index:
- MySQL:
ALTER TABLE customers ADD FULLTEXT INDEX (email); SELECT ... WHERE MATCH(email) AGAINST ('gmail.com') -
PostgreSQL:
CREATE INDEX ON customers USING GIN (to_tsvector('english', email)); -
Reverse the string — store
email_reversedasREVERSE(email)and index it. QueryWHERE email_reversed LIKE 'moc.liamg@%'. The wildcard is now trailing, so the index works. -
Accept the cost — if this query runs in a nightly batch job (not in real-time), a 30-second scan may be acceptable. Only optimise based on actual usage patterns.
Previous: 01-select-statement | Next: 03-order-by