WHERE Clause in MySQL
The WHERE clause filters rows before they are returned to the client (SELECT), updated (UPDATE),
or deleted (DELETE). It is evaluated against every candidate row, and only rows where the condition
evaluates to TRUE are included in the result. Rows evaluating to FALSE or NULL are excluded.
WHERE Clause Basics and Query Position
The WHERE clause appears after the FROM clause (and JOIN clauses) but before GROUP BY, HAVING, ORDER BY, and LIMIT. The logical execution order is:
- FROM / JOIN — identify the source rows
- WHERE — filter individual rows
- GROUP BY — group filtered rows
- HAVING — filter groups
- SELECT — project columns
- ORDER BY — sort the result
- LIMIT — restrict row count
SELECT department_id, AVG(salary) FROM employees WHERE hire_date > '2020-01-01' -- runs before GROUP BY GROUP BY department_id HAVING AVG(salary) > 60000 -- runs after GROUP BY ORDER BY AVG(salary) DESC LIMIT 10;
All Comparison Operators
-- Equality SELECT * FROM employees WHERE department_id = 3; -- Not equal (both forms are equivalent) SELECT * FROM products WHERE category_id != 5; SELECT * FROM products WHERE category_id <> 5; -- Less than, less than or equal SELECT * FROM orders WHERE total < 100; SELECT * FROM orders WHERE total <= 99.99; -- Greater than, greater than or equal SELECT * FROM orders WHERE total > 500; SELECT * FROM orders WHERE total >= 1000; -- NULL-safe equals operator (<=>) -- Returns TRUE when both sides are NULL, unlike regular = SELECT * FROM employees WHERE manager_id <=> NULL; -- Equivalent to: WHERE manager_id IS NULL
NULL Comparisons: IS NULL, IS NOT NULL, and <=>
NULL represents an unknown value. Any comparison with NULL using =, !=, <, or >
returns NULL (not TRUE or FALSE), so the row is silently excluded. Always use IS NULL
or IS NOT NULL to test for NULL values.
-- WRONG: always returns 0 rows (NULL = NULL evaluates to NULL, not TRUE) SELECT * FROM employees WHERE manager_id = NULL; -- CORRECT: IS NULL SELECT * FROM employees WHERE manager_id IS NULL; -- CORRECT: IS NOT NULL SELECT * FROM employees WHERE manager_id IS NOT NULL; -- NULL-safe equals: returns TRUE when both sides are NULL -- Useful in JOIN conditions or when comparing two nullable columns SELECT * FROM products p1 JOIN products p2 ON p1.supplier_id <=> p2.supplier_id; -- COALESCE: treat NULL as a default value in WHERE SELECT * FROM users WHERE COALESCE(notes, '') = ''; -- Matches rows where notes is NULL OR notes is an empty string
WHERE col != 'value' silently excludes NULL rows because NULL != 'value' evaluates to NULL, not TRUE. Add OR col IS NULL if you want to include NULL rows in your results.AND, OR, NOT — Operator Precedence
-- AND: both conditions must be TRUE SELECT * FROM employees WHERE department_id = 3 AND salary > 60000; -- OR: either condition may be TRUE SELECT * FROM products WHERE category_id = 1 OR category_id = 2; -- NOT: negate a condition SELECT * FROM users WHERE NOT is_suspended; SELECT * FROM orders WHERE NOT status = 'cancelled'; -- same as status != 'cancelled' -- Combining AND and OR SELECT * FROM orders WHERE status = 'pending' OR (status = 'processing' AND priority = 'high');
-- Ambiguous: parsed as (department_id=1 AND salary>50000) OR department_id=2 -- This returns ALL employees in dept 2, even those with low salaries SELECT * FROM employees WHERE department_id = 1 AND salary > 50000 OR department_id = 2; -- Clear intent with parentheses: only high earners in dept 1 OR dept 2 SELECT * FROM employees WHERE (department_id = 1 OR department_id = 2) AND salary > 50000;
Short-Circuit Evaluation
MySQL evaluates WHERE conditions left to right and may stop early (short-circuit) once the overall truth value is determined:
- In an AND chain, as soon as one condition is FALSE, the rest are skipped.
- In an OR chain, as soon as one condition is TRUE, the rest are skipped.
Place cheaper or more selective conditions first in AND chains to let MySQL skip expensive function calls or subqueries for rows that fail the early checks.
-- Place cheap indexed check first, expensive function call second SELECT * FROM orders WHERE status = 'active' -- indexed, cheap AND JSON_CONTAINS(metadata, '"premium"', '$.tags'); -- expensive JSON parse -- If status = 'active' is false, the JSON_CONTAINS is never evaluated for that row
SARGable vs Non-SARGable Predicates
A SARGable predicate (Search ARGument able) is one that MySQL can use to seek into an index. A non-SARGable predicate wraps the indexed column in a function or expression, preventing index use and forcing a full table scan.
-- NON-SARGable (bad): YEAR() wraps the column, index cannot be used EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2024; -- type: ALL rows: 500000 (full scan) -- SARGable (good): range condition on the column itself EXPLAIN SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; -- type: range key: idx_created_at rows: ~50000 (index range scan) -- NON-SARGable: function on indexed column SELECT * FROM users WHERE LOWER(email) = 'alice@example.com'; -- type: ALL (full scan) -- SARGable fix: rely on the column's _ci collation (case-insensitive by default) SELECT * FROM users WHERE email = 'alice@example.com'; -- type: ref (index used) -- NON-SARGable: arithmetic on indexed column SELECT * FROM products WHERE price * 1.1 > 100; -- type: ALL -- SARGable fix: move the arithmetic to the other side SELECT * FROM products WHERE price > 100 / 1.1; -- type: range (index used)
WHERE with BETWEEN, IN, LIKE, REGEXP
-- BETWEEN: inclusive range (>= AND <=)
SELECT * FROM orders WHERE total BETWEEN 100 AND 500;
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31 23:59:59';
-- IN: match any value from a list
SELECT * FROM employees WHERE department_id IN (1, 3, 7);
SELECT * FROM orders WHERE status IN ('pending', 'processing');
-- NOT IN: exclude values (careful if list may contain NULL!)
SELECT * FROM orders WHERE status NOT IN ('delivered', 'cancelled');
-- LIKE: pattern matching (prefix = index-friendly, leading % = slow)
SELECT * FROM customers WHERE last_name LIKE 'Smi%'; -- uses index
SELECT * FROM articles WHERE title LIKE '%tutorial%'; -- full scan
-- REGEXP: powerful pattern matching (always full scan)
SELECT * FROM users WHERE email REGEXP '^[a-z0-9]+@[a-z0-9]+\.com$';WHERE with Subqueries: EXISTS vs IN vs JOIN
-- EXISTS: returns TRUE if the subquery produces at least one row
-- Short-circuits on the first match -- efficient for large subquery results
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
AND o.total > 1000
);
-- NOT EXISTS: customers who have never ordered
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- IN with a subquery
SELECT * FROM products
WHERE category_id IN (
SELECT id FROM categories WHERE active = 1
);
-- Equivalent JOIN (often most readable and performant)
SELECT DISTINCT p.*
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE c.active = 1;
-- Scalar comparison
SELECT * FROM products
WHERE price > (SELECT AVG(price) FROM products);WHERE in UPDATE and DELETE Statements
-- UPDATE with WHERE UPDATE employees SET salary = salary * 1.05 WHERE performance_rating = 'excellent' AND hire_date < DATE_SUB(NOW(), INTERVAL 1 YEAR); -- DELETE with WHERE DELETE FROM logs WHERE level = 'debug' AND created_at < DATE_SUB(NOW(), INTERVAL 7 DAY); -- The same SARGability rules apply in UPDATE/DELETE: -- wrapping indexed columns in functions prevents index use -- and turns an O(log n) delete into an O(n) full-table scan
sql_safe_updates = 1 to make MySQL reject such statements unless they include a key-column filter.WHERE with Window Functions (Limitation)
Window functions (OVER) are computed after the WHERE clause. This means you cannot filter directly on a window function result in the WHERE clause — you must wrap the query in a subquery or CTE first.
-- This FAILS: window functions are not allowed in WHERE
SELECT *, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk
FROM employees
WHERE rnk <= 3; -- ERROR: Unknown column 'rnk' in 'where clause'
-- Fix: wrap in a subquery and filter in the outer WHERE
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk
FROM employees
) ranked
WHERE rnk <= 3;
-- Or use a CTE (MySQL 8.0+)
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk
FROM employees
)
SELECT * FROM ranked WHERE rnk <= 3;Index Usage in WHERE — Common Scenarios
WHERE Pattern | Index Used? | Scan Type | Notes |
|---|---|---|---|
WHERE id = 5 | Yes | const / eq_ref | Single PK or unique lookup — best possible |
WHERE price > 100 | Yes | range | Range on indexed column |
WHERE status IN (1,2,3) | Yes | range | IN on indexed column treated as range |
WHERE YEAR(created_at) = 2024 | No | ALL | Function wraps column — use range instead |
WHERE email LIKE "ali%" | Yes | range | Leading prefix — index usable |
WHERE email LIKE "%ali%" | No | ALL | Leading wildcard — full scan |
WHERE col IS NULL | Yes (if indexed) | ref | NULLs are indexed in MySQL |
WHERE a = 1 AND b = 2 | Yes (if composite index) | ref | Composite index on (a, b) works well |
WHERE b = 2 (no a filter) | No (composite only) | ALL | Composite index requires leading column |
Practical Filtering Patterns for Report Queries
-- Date range report (SARGable) SELECT DATE(created_at) AS day, COUNT(*) AS orders, SUM(total) AS revenue FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' GROUP BY DATE(created_at) ORDER BY day; -- Status + date combo (composite index on status, created_at recommended) SELECT * FROM orders WHERE status = 'pending' AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR); -- Paginated filtered list (cursor-based for performance) SELECT id, name, email, created_at FROM users WHERE is_active = 1 AND created_at < '2024-06-15' -- cursor from last page ORDER BY created_at DESC, id DESC LIMIT 25; -- Nullable column with a safe fallback SELECT * FROM employees WHERE COALESCE(department_id, 0) = 0; -- NULL department = unassigned -- Note: COALESCE wraps the column, preventing index use -- SARGable alternative: SELECT * FROM employees WHERE department_id IS NULL;
The NULL Trap in NOT IN
This is one of the most surprising NULL-related bugs. If a subquery used with NOT IN returns even a single NULL, the entire NOT IN condition evaluates to NULL — and no rows are returned.
-- Demonstrate the NOT IN / NULL trap CREATE TABLE a (x INT); CREATE TABLE b (x INT); INSERT INTO a VALUES (1), (2), (3); INSERT INTO b VALUES (1), (NULL); -- b contains a NULL! -- Expected: return rows from a not in b (should return 2 and 3) -- Actual: returns 0 rows because of the NULL in b SELECT x FROM a WHERE x NOT IN (SELECT x FROM b); -- Explanation: -- NOT IN (1, NULL) evaluates as: x != 1 AND x != NULL -- x != NULL is always NULL (unknown), so the whole condition is NULL -- Result: no rows pass the filter -- Fix: use NOT EXISTS instead SELECT x FROM a WHERE NOT EXISTS ( SELECT 1 FROM b WHERE b.x = a.x ); -- Returns: 2, 3 (correct, NULLs are handled safely)
WHERE with Composite Indexes
A composite index on (col_a, col_b, col_c) can be used to satisfy WHERE conditions
in a specific way: MySQL reads the index from left to right and can use a prefix of the index.
ALTER TABLE orders ADD INDEX idx_composite (status, customer_id, created_at); -- Uses full composite index (leftmost prefix matches) EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND customer_id = 42 AND created_at > '2024-01-01'; -- key: idx_composite type: range -- Uses partial composite index (status only) EXPLAIN SELECT * FROM orders WHERE status = 'pending'; -- key: idx_composite type: ref (uses leading column only -- still benefits) -- Uses partial (status + customer_id) EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND customer_id = 42; -- key: idx_composite type: ref -- Does NOT use the composite index (skips the leading column) EXPLAIN SELECT * FROM orders WHERE customer_id = 42; -- key: NULL type: ALL (index not usable without the leading column) -- Rule: the index can only be used if the WHERE includes the leftmost columns
WHERE in HAVING vs WHERE — When to Use Each
-- WHERE filters individual rows BEFORE aggregation -- HAVING filters groups AFTER aggregation -- Correct: filter on a non-aggregate column in WHERE SELECT department_id, COUNT(*) AS headcount FROM employees WHERE hire_date > '2020-01-01' -- filter rows before counting GROUP BY department_id; -- Correct: filter on an aggregate result in HAVING SELECT department_id, COUNT(*) AS headcount FROM employees GROUP BY department_id HAVING COUNT(*) > 5; -- filter groups after counting -- COMMON MISTAKE: using HAVING instead of WHERE for non-aggregate filters -- This works but is slower because all rows are aggregated before filtering SELECT department_id, COUNT(*) AS headcount FROM employees GROUP BY department_id HAVING department_id IN (1, 2, 3); -- should be in WHERE instead -- CORRECT: move non-aggregate filter to WHERE for better performance SELECT department_id, COUNT(*) AS headcount FROM employees WHERE department_id IN (1, 2, 3) -- filters rows before GROUP BY GROUP BY department_id;
Best Practices
Always add a WHERE clause to UPDATE and DELETE statements unless a full-table operation is truly intended.
Use parentheses liberally with AND/OR combinations to make operator precedence explicit.
Compare NULLs with IS NULL / IS NOT NULL — never with = or !=.
Avoid wrapping indexed columns in functions inside WHERE — use equivalent range expressions instead.
Prefer EXISTS over IN when the subquery may be large — EXISTS short-circuits on the first match.
Use NOT EXISTS instead of NOT IN when the subquery may return NULL values.
Write SARGable predicates: keep indexed columns bare on the left side of comparisons.
Use EXPLAIN to verify index usage on any WHERE clause that runs frequently.
Index columns used in WHERE clauses of hot queries, especially those combined with ORDER BY or LIMIT.
Move non-aggregate filters into WHERE (not HAVING) so MySQL can reduce the row set before grouping.