SQL Where
The WHERE clause is how you tell SQL which rows you actually want. Without it, a query returns every row in a table; with it, the database engine tests each row against a condition and keeps only the ones that pass. It’s one of the first clauses every SQL learner needs, and it underlies almost every real-world query, from simple lookups to complex reporting.
Overview / How WHERE Works
WHERE is a row-filtering clause used in SELECT, UPDATE, and DELETE statements. It appears after FROM (and any JOINs) and before GROUP BY, HAVING, or ORDER BY. The database engine evaluates the WHERE condition once per row, using the row’s own column values, and the condition must reduce to TRUE, FALSE, or UNKNOWN (SQL’s three-valued logic). Only rows where the condition evaluates to TRUE are kept — rows that evaluate to FALSE or UNKNOWN are discarded.
Conceptually, you can imagine the engine scanning every row of the table one at a time and asking “does this row satisfy the condition?” In practice, a good query optimizer avoids scanning every row when it can. If a WHERE condition references a column that has an index (for example a primary key or a column with a CREATE INDEX statement on it), the engine can use that index to jump directly to matching rows instead of reading the whole table — this is called an index seek, versus a full table scan. Conditions built from simple equality or range comparisons on indexed columns are usually the fastest to filter on; conditions that wrap a column in a function (like WHERE UPPER(name) = 'ALICE') or use a leading wildcard in LIKE (like '%son') typically prevent the engine from using an index efficiently, forcing a full scan.
It’s important to understand that WHERE filters individual rows before any grouping happens. If you want to filter on the result of an aggregate function (like COUNT() or AVG()) applied to a group, you need HAVING instead — more on that in Common Mistakes below.
Syntax
SELECT column1, column2, ...
FROM table_name
WHERE condition;
| Part | Meaning |
|---|---|
condition |
A boolean expression evaluated per row, e.g. salary > 60000. |
| Comparison operators | =, != or <>, >, <, >=, <= |
| Logical operators | AND, OR, NOT — combine multiple conditions |
BETWEEN a AND b |
True if the value is within an inclusive range |
IN (v1, v2, ...) |
True if the value matches any item in a list |
LIKE pattern |
Pattern matching with % (any characters) and _ (one character) |
IS NULL / IS NOT NULL |
Tests for missing values — never use = NULL |
Examples
Example 1: A simple equality filter
CREATE TABLE employees (
id INTEGER,
name TEXT,
department TEXT,
salary REAL,
hire_date TEXT
);
INSERT INTO employees (id, name, department, salary, hire_date) VALUES
(1, 'Alice Johnson', 'Engineering', 95000, '2019-03-15'),
(2, 'Bob Smith', 'Sales', 62000, '2021-07-01'),
(3, 'Carol Davis', 'Engineering', 88000, '2020-01-10'),
(4, 'David Lee', 'Marketing', 54000, '2022-05-23');
SELECT * FROM employees WHERE department = 'Engineering';
Result:
| id | name | department | salary | hire_date |
|---|---|---|---|---|
| 1 | Alice Johnson | Engineering | 95000 | 2019-03-15 |
| 3 | Carol Davis | Engineering | 88000 | 2020-01-10 |
The engine checks every row’s department value against the literal string 'Engineering' and keeps only the two rows that match. Bob and David are excluded because their department is 'Sales' and 'Marketing' respectively.
Example 2: Combining conditions with AND
SELECT name, salary FROM employees
WHERE salary > 60000 AND department != 'Marketing';
Result:
| name | salary |
|---|---|
| Alice Johnson | 95000 |
| Bob Smith | 62000 |
| Carol Davis | 88000 |
Both conditions must be true for a row to survive. David Lee is excluded on two counts: his salary (54000) isn’t above 60000, and his department is Marketing. With AND, a row needs to pass every condition; with OR, passing just one condition is enough.
Example 3: IN, a date range, and ORDER BY
SELECT name, department, hire_date FROM employees
WHERE department IN ('Engineering', 'Marketing')
AND hire_date >= '2020-01-01'
ORDER BY hire_date;
Result:
| name | department | hire_date |
|---|---|---|
| Carol Davis | Engineering | 2020-01-10 |
| David Lee | Marketing | 2022-05-23 |
IN is shorthand for a chain of OR-connected equality checks — here it’s equivalent to (department = 'Engineering' OR department = 'Marketing'). Because dates are stored as 'YYYY-MM-DD' text, they sort and compare correctly as strings. Alice Johnson is excluded even though she’s in Engineering, because her hire date (2019-03-15) is before the cutoff.
How It Works Step by Step (Under the Hood)
SQL is declarative — you describe what you want, not how to get it — but the engine still processes a query in a fixed logical order, regardless of the order you type the clauses in:
- FROM — identify the source table(s) and perform any joins.
- WHERE — evaluate the filter condition against each individual row and discard rows that don’t satisfy it.
- GROUP BY — bucket the remaining rows into groups, if grouping is requested.
- HAVING — filter those groups based on aggregate conditions.
- SELECT — compute the requested columns/expressions for what’s left.
- ORDER BY — sort the final result set.
- LIMIT — trim to a requested number of rows.
Because WHERE runs at step 2, before any grouping or aggregation, it has no visibility into aggregate results and cannot reference column aliases defined in the SELECT list (those don’t exist yet). It also can’t reference window function results, since those are computed even later in the pipeline. This ordering is also why WHERE is generally more efficient than filtering after aggregation — it shrinks the row set as early as possible, before the more expensive grouping and sorting work happens.
Common Mistakes
Mistake 1: Using an aggregate function inside WHERE
CREATE TABLE employees (
id INTEGER, name TEXT, department TEXT, salary REAL, hire_date TEXT
);
INSERT INTO employees (id, name, department, salary, hire_date) VALUES
(1, 'Alice Johnson', 'Engineering', 95000, '2019-03-15'),
(2, 'Bob Smith', 'Sales', 62000, '2021-07-01'),
(3, 'Carol Davis', 'Engineering', 88000, '2020-01-10'),
(4, 'David Lee', 'Marketing', 54000, '2022-05-23');
SELECT department, AVG(salary)
FROM employees
WHERE AVG(salary) > 70000
GROUP BY department;
This fails because WHERE runs before grouping/aggregation exists (step 2 above), so AVG(salary) has nothing to average yet — most databases, including SQLite, reject this with a “misuse of aggregate function” error. Aggregate conditions belong in HAVING, which runs after grouping:
CREATE TABLE employees (
id INTEGER, name TEXT, department TEXT, salary REAL, hire_date TEXT
);
INSERT INTO employees (id, name, department, salary, hire_date) VALUES
(1, 'Alice Johnson', 'Engineering', 95000, '2019-03-15'),
(2, 'Bob Smith', 'Sales', 62000, '2021-07-01'),
(3, 'Carol Davis', 'Engineering', 88000, '2020-01-10'),
(4, 'David Lee', 'Marketing', 54000, '2022-05-23');
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 70000;
Result: only Engineering | 91500.0 is returned — Sales averages 62000 and Marketing averages 54000, both below the 70000 threshold.
Mistake 2: Comparing to NULL with =
CREATE TABLE contacts (id INTEGER, name TEXT, phone TEXT);
INSERT INTO contacts (id, name, phone) VALUES
(1, 'Amy', '555-0101'),
(2, 'Ben', NULL),
(3, 'Cara', '555-0103');
SELECT * FROM contacts WHERE phone = NULL;
Result: zero rows — even Ben, whose phone really is NULL. In SQL, NULL means “unknown,” and any comparison involving an unknown value (including NULL = NULL) evaluates to UNKNOWN, not TRUE, so the row is dropped. The fix is to use IS NULL, which is a dedicated null-check rather than a comparison:
CREATE TABLE contacts (id INTEGER, name TEXT, phone TEXT);
INSERT INTO contacts (id, name, phone) VALUES
(1, 'Amy', '555-0101'),
(2, 'Ben', NULL),
(3, 'Cara', '555-0103');
SELECT * FROM contacts WHERE phone IS NULL;
Result: 2 | Ben | NULL — exactly the row we wanted.
Best Practices
- Use
IS NULL/IS NOT NULLfor missing values — never= NULLor!= NULL. - Put filter logic in
WHERE, notHAVING, whenever it doesn’t depend on an aggregate — it lets the engine discard rows earlier and often use an index. - Wrap conditions in parentheses when mixing
ANDandOR, sinceANDbinds tighter thanORand mixing them without parentheses is a common source of subtle bugs. - Avoid wrapping indexed columns in functions (e.g.
WHERE UPPER(name) = 'ALICE') — it disables index usage; instead normalize data at insert time or use a functional/expression index if your database supports one. - Prefer
BETWEENor explicit>=/<pairs for ranges instead of chains ofOR— it’s clearer and easier for the optimizer to reason about. - Use
IN (...)instead of longORchains when checking one column against many possible values. - Be careful with
LIKEpatterns that start with%(e.g.'%smith') — they generally can’t use a standard index and force a full scan.
Practice Exercises
- Using the
employeestable from the examples above, write a query that returns thenameandsalaryof everyone earning less than 90000 who is not in the Sales department. - Write a query against
employeesthat returns everyone hired in 2020 or later, ordered bysalarydescending. (Hint: comparehire_dateas text against'2020-01-01'.) - Given the
contactstable from Mistake 2, write one query that returns everyone whosephoneis not missing, sorted byname.
Summary
WHEREfilters individual rows based on a condition, and runs right afterFROM/joins in the logical query order — beforeGROUP BY,HAVING, andORDER BY.- Rows are kept only when the condition evaluates to
TRUE;FALSEandUNKNOWN(common withNULL) rows are dropped. - Combine conditions with
AND/OR/NOT, and useBETWEEN,IN, andLIKEfor ranges, lists, and patterns. - Use
IS NULL/IS NOT NULLfor null checks —= NULLsilently returns no matches. - Aggregate conditions (on
COUNT,AVG, etc.) belong inHAVING, notWHERE, because aggregation happens afterWHEREruns. - Indexed, function-free, leading-wildcard-free conditions let the engine use an index seek instead of a full table scan, which matters a lot on large tables.
