1Filtering Data with WHERE Clauses
▶
When you query a database, you rarely want every single row in a table. Most of the time you need only the records that satisfy some criterion — customers from a particular region, orders above a certain dollar threshold, employees hired after a specific date. The WHERE clause is the fundamental SQL mechanism that lets you express exactly those criteria. It acts as a gatekeeper: before any row is returned to you, SQL evaluates your condition against that row, and only rows for which the condition resolves to TRUE make it into the result set. Rows that produce FALSE or the special three-valued-logic result NULL are silently discarded.
The WHERE clause appears in a fixed position within a SELECT statement — after the FROM clause (and any JOIN clauses) but before GROUP BY, HAVING, and ORDER BY. This ordering matters because the database engine processes clauses in a defined logical sequence: it first identifies the source rows (FROM), then filters them (WHERE), then groups and aggregates (GROUP BY / HAVING), and finally sorts (ORDER BY). The general skeleton of a filtered query looks like this:
SELECT column1, column2, ...
FROM table_name
WHERE condition
ORDER BY column1;
A concrete first example: to retrieve every employee who works in the Sales department, you write:
SELECT *
FROM employees
WHERE department = 'Sales';
SQL reads each row in employees, tests whether department equals the string 'Sales', and returns the row only when that test is true. Everything else — the syntax, the operators, the wildcards — builds on top of this single foundational idea.
Comparison Operators in WHERE Conditions
The building block of any WHERE condition is a comparison expression. SQL provides a standard set of comparison operators that work on numbers, strings, and dates alike:
| Operator | Meaning | Example |
|---|---|---|
= |
Equal to | status = 'active' |
<> or != |
Not equal to | country <> 'US' |
> |
Greater than | price > 100 |
< |
Less than | quantity < 5 |
>= |
Greater than or equal to | score >= 90 |
<= |
Less than or equal to | age <= 65 |
For example, to find all orders where the total exceeds five hundred dollars:
SELECT order_id, customer_id, total_amount
FROM orders
WHERE total_amount > 500;
Only rows with a total_amount strictly greater than 500 are returned. If you want to include orders of exactly 500, change > to >=.
A particularly convenient shorthand for range comparisons is the BETWEEN operator. Instead of writing WHERE salary >= 40000 AND salary <= 80000, you can write:
SELECT employee_id, first_name, salary
FROM employees
WHERE salary BETWEEN 40000 AND 80000;
BETWEEN is inclusive on both ends, so it is exactly equivalent to the two-condition AND form above. It works equally well on dates — WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31' captures the full calendar year — and on string ranges, though string range comparisons depend on the database's collation rules and are therefore less common.
Logical Operators: AND, OR, and NOT
Real-world filtering rarely involves just one condition. SQL's logical operators let you combine conditions so that a row must satisfy several criteria simultaneously, at least one of several criteria, or the opposite of a criterion.
AND is the strictest combiner. Every condition joined by AND must be TRUE for the overall expression to be TRUE. If even one condition is FALSE, the whole expression is FALSE and the row is excluded.
-- Employees in Sales who were hired after 2020
SELECT *
FROM employees
WHERE department = 'Sales'
AND hire_date > '2020-01-01';
A row with department = 'Sales' but hire_date = '2019-06-15' will fail the second condition and be excluded, even though it satisfies the first.
OR is more permissive. At least one of the conditions joined by OR must be TRUE. If any single condition is TRUE, the row is included.
-- Employees in Sales or Marketing
SELECT *
FROM employees
WHERE department = 'Sales'
OR department = 'Marketing';
NOT inverts a condition. It converts TRUE to FALSE and FALSE to TRUE, effectively selecting everything the original condition would have excluded.
-- All employees who are NOT in the HR department
SELECT *
FROM employees
WHERE NOT department = 'HR';
This is logically identical to WHERE department <> 'HR', though NOT becomes more useful when negating complex or compound expressions.
One subtlety worth internalizing is operator precedence. SQL evaluates NOT first, then AND, then OR — the same priority hierarchy as multiplication before addition in arithmetic. Consider this condition without parentheses:
WHERE department = 'Sales' OR department = 'Marketing' AND region = 'North'
Because AND binds tighter than OR, SQL reads this as:
WHERE department = 'Sales' OR (department = 'Marketing' AND region = 'North')
That returns all Sales employees regardless of region, plus Marketing employees from the North. If you intended only North-region employees from both departments, you need explicit parentheses — covered in the final section below.
Pattern Matching with LIKE and Wildcards
Exact equality works perfectly when you know the full value you're searching for. But often you only know part of a string — a name starts with certain letters, a product code follows a format, a description contains a keyword. The LIKE operator handles these partial-match scenarios using two wildcard characters:
- Percent sign (
%): Matches zero or more of any character. Think of it as "anything here, including nothing." - Underscore (
_): Matches exactly one of any character. It reserves precisely one character's worth of space.
Some examples illustrate the difference clearly:
-- Last names beginning with 'Sm' (Smith, Smart, Smythe, Sm, ...)
WHERE last_name LIKE 'Sm%'
-- Last names ending with 'son' (Johnson, Peterson, Jackson, ...)
WHERE last_name LIKE '%son'
-- Last names containing 'ar' anywhere (Martin, Garcia, Parker, ...)
WHERE last_name LIKE '%ar%'
-- Three-character codes where first char is 'A' and last char is '1'
-- Matches AB1, AC1, AZ1, A91, etc. but NOT A1 or ABBC1
WHERE code LIKE 'A_1'
You can combine both wildcards in a single pattern. For instance, LIKE 'A_%_1' requires the string to start with A, end with 1, and have at least one character in between (minimum length of 3).
An important practical consideration is case sensitivity. Standard SQL specifies that LIKE is case-sensitive, but many databases diverge from this: MySQL with its default collation and SQL Server with case-insensitive collations both treat LIKE as case-insensitive. PostgreSQL's LIKE is case-sensitive, but PostgreSQL offers the non-standard ILIKE operator for explicit case-insensitive matching:
-- PostgreSQL: matches 'smith', 'Smith', 'SMITH', 'sMiTh', ...
WHERE last_name ILIKE 'sm%'
When you need to match a literal percent sign or underscore — not as a wildcard but as a data character — you escape it using the ESCAPE clause. For example, if product codes literally contain underscores:
WHERE product_code LIKE 'A\_1' ESCAPE '\'
The backslash tells SQL to treat the following _ as a literal character, not a wildcard.
Filtering with IN and NOT IN
When you want to test a column against a list of specific values, chaining multiple OR conditions works but becomes verbose. The IN operator offers a cleaner, more readable alternative:
-- The verbose OR approach
WHERE category = 'Electronics' OR category = 'Furniture' OR category = 'Clothing'
-- The equivalent IN approach
WHERE category IN ('Electronics', 'Furniture', 'Clothing')
Both forms produce identical results, but IN scales gracefully to long lists without the clutter. For example:
SELECT product_id, product_name, category
FROM products
WHERE category IN ('Electronics', 'Furniture', 'Clothing');
NOT IN is the mirror image — it excludes all rows where the column matches any value in the list:
SELECT order_id, customer_id, status
FROM orders
WHERE status NOT IN ('cancelled', 'returned');
This returns every order that is neither cancelled nor returned — active, pending, shipped, delivered, and any other statuses pass through the filter.
A critically important caution about NOT IN: if any value in the list is NULL, NOT IN returns no rows at all. This stems from SQL's three-valued logic. When SQL evaluates value NOT IN (..., NULL, ...), it eventually computes value <> NULL, which produces NULL (not FALSE), and NULL conditions exclude rows. If your list might contain NULLs — especially when using a subquery — use NOT EXISTS instead, which handles NULLs predictably.
The most powerful application of IN is pairing it with a subquery. Instead of supplying a literal list, you supply a nested SELECT that produces the list dynamically:
-- Orders placed by VIP customers (IDs determined by a subquery)
SELECT order_id, order_date, total_amount
FROM orders
WHERE customer_id IN (
SELECT customer_id
FROM vip_customers
);
The subquery runs first and returns a set of customer_id values. The outer query then uses IN to keep only orders whose customer_id appears in that set. This pattern is extremely common and is one of the foundational techniques for working across related tables without a full JOIN.
Handling NULL Values with IS NULL and IS NOT NULL
NULL in SQL represents the absence of a value — it is not zero, not an empty string, not false. It is the explicit signal that no data exists for that field. This concept has a crucial implication: you cannot compare a column to NULL using the = operator.
-- This does NOT work as expected:
WHERE manager_id = NULL -- always returns no rows!
-- This is the correct syntax:
WHERE manager_id IS NULL
The reason = NULL fails is that any comparison involving NULL produces NULL (not TRUE or FALSE), and a WHERE clause only passes rows where the condition is TRUE. So manager_id = NULL always evaluates to NULL, and no rows are ever returned — a silent, maddening bug if you don't know to look for it.
IS NULL correctly identifies rows where a column holds no value:
-- Find top-level employees who have no manager
SELECT employee_id, first_name, last_name
FROM employees
WHERE manager_id IS NULL;
IS NOT NULL filters rows that do have a value in the column:
-- Find customers who have provided a phone number
SELECT customer_id, first_name, phone_number
FROM customers
WHERE phone_number IS NOT NULL;
NULL's interaction with AND and OR can produce unexpected results that trip up even experienced developers. The key rules are:
TRUE AND NULL→ NULL (the AND could still be false depending on the NULL, so the result is indeterminate)FALSE AND NULL→ FALSE (doesn't matter what NULL is; FALSE AND anything is FALSE)TRUE OR NULL→ TRUE (doesn't matter what NULL is; TRUE OR anything is TRUE)FALSE OR NULL→ NULL (indeterminate — the OR might still be true if NULL were true)
Practically speaking, this means a condition like WHERE discount_pct > 0 AND discount_pct < 50 will silently exclude rows where discount_pct is NULL, because the comparisons yield NULL rather than FALSE. This is usually the desired behavior, but you must be aware of it when debugging queries that seem to miss rows.
Combining Multiple Conditions Effectively
In practice, WHERE clauses often involve several conditions layered together. Writing them carefully — with good use of parentheses, logical clarity, and incremental testing — is as much a skill as knowing the syntax itself.
Use parentheses to enforce your intended grouping. As mentioned earlier, AND binds tighter than OR. Whenever you mix AND and OR in the same WHERE clause, use parentheses to make your intent unambiguous:
-- Without parentheses (ambiguous, relies on precedence):
WHERE status = 'active' OR status = 'pending' AND region = 'North'
-- With parentheses (clear intent — active or pending, but only from North):
WHERE (status = 'active' OR status = 'pending') AND region = 'North'
-- Alternative grouping (active from anywhere, OR pending from North):
WHERE status = 'active' OR (status = 'pending' AND region = 'North')
Parentheses cost nothing in performance and save enormous confusion. Make it a habit to add them any time you combine AND and OR.
Avoid redundant or contradictory conditions. Redundant conditions add noise without changing results:
-- Redundant: age > 30 already implies age > 25
WHERE age > 30 AND age > 25 -- simplify to: WHERE age > 30
-- Contradictory: no age can simultaneously be > 50 and < 20
WHERE age > 50 AND age < 20 -- always returns zero rows
Linters and code reviews can catch these, but developing the habit of reading your own conditions carefully is the best defense.
Build complex WHERE clauses incrementally. When you're constructing a complicated filter, don't write all the conditions at once and then try to debug a query that returns wrong results. Instead, start with one condition, verify the result set looks right, then add the next condition and verify again:
-- Step 1: start broad
SELECT * FROM orders WHERE region = 'North';
-- Step 2: add a second filter
SELECT * FROM orders WHERE region = 'North' AND status = 'active';
-- Step 3: add a third filter
SELECT * FROM orders WHERE region = 'North' AND status = 'active' AND total_amount > 1000;
At each step you can confirm the row count shrinks in the way you expect and spot any condition that's doing something unexpected before the complexity grows.
A final best practice worth noting is to be explicit about data types in your comparisons. Comparing a numeric column to a quoted string literal (WHERE employee_id = '42' instead of WHERE employee_id = 42) may work due to implicit type conversion, but it can prevent the database from using indexes efficiently and can produce subtle bugs when the conversion rules differ across database engines. Always match the literal's type to the column's type.
Together, comparison operators, logical operators, LIKE, IN, NULL handling, and thoughtful condition grouping form a complete toolkit for expressing virtually any filtering requirement. Mastering these building blocks means you can confidently isolate exactly the rows your analysis or application needs, no matter how complex the criteria.