SQL WHERE: AND/OR Precedence Bug Sent 40k Wrong Customers
AND has higher precedence than OR - missing parentheses emailed 40k customers wrongly.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- WHERE filters individual rows after FROM but before GROUP BY, SELECT, and ORDER BY
- AND requires both conditions true; OR requires either true — use parentheses to control precedence
- BETWEEN is inclusive on both ends: BETWEEN 10 AND 20 includes 10 and 20
- IN is shorthand for multiple OR conditions; LIKE uses % for any sequence and _ for a single character
- NULL comparison requires IS NULL / IS NOT NULL — WHERE col = NULL always returns zero rows
- Biggest mistake: WHERE country = 'USA' OR country = 'UK' AND revenue > 1000 — AND binds tighter than OR, producing wrong logic without parentheses
Imagine you have a massive filing cabinet with thousands of customer folders. The WHERE clause is like telling your assistant: 'Only bring me folders where the customer lives in New York AND spent more than $500.' Instead of dumping every single folder on your desk, you get exactly the ones you need. That's all WHERE does — it filters rows from a table so you only see the data that matches your conditions.
Every real-world database holds thousands, sometimes millions of rows. A table of online orders might have five million records going back a decade. Without a way to pinpoint just the rows you care about, querying a database would be like searching for a specific email by reading your entire inbox from the beginning. The WHERE clause is the most fundamental filtering tool in SQL — and it's why databases are actually useful in practice, not just in theory.
Before WHERE existed as a concept, you'd have to pull every row into your application and filter it in code. That means your server drags across the network, your app chews through memory, and your users wait. WHERE pushes the filtering down to the database engine itself — the place best equipped to do it fast, using indexes. It solves the problem of unnecessary data transfer and processing at its source.
By the end of this article you'll know how to write WHERE clauses from scratch, combine multiple conditions using AND, OR, and NOT, use powerful operators like BETWEEN, LIKE, and IN, and avoid the three beginner mistakes that silently break your queries. You'll also walk away with the answers to the interview questions that trip up even developers who've been writing SQL for a year.
What the WHERE Clause Actually Does (And Why It Belongs After FROM)
Every SQL SELECT statement follows a logical order: you tell the database WHAT columns you want (SELECT), then WHERE to look for rows (FROM), and then WHICH rows to keep (WHERE). The WHERE clause acts as a gatekeeper — the database evaluates every row in the table against your condition, and only the rows that pass get returned.
Think of it like a bouncer at a club checking IDs. Every single person in the queue gets checked. If your condition says 'age >= 21', only people who meet that rule get in. Everyone else is turned away quietly — they don't throw an error, they just don't appear in your results.
The clause uses standard comparison operators you already know from maths: = (equals), != or <> (not equals), > (greater than), < (less than), >= (greater than or equal to), and <= (less than or equal to). You attach the condition to a column name, and the database tests each row against it.
One thing that trips beginners up: WHERE is evaluated BEFORE SELECT. The database finds matching rows first, then decides which columns to show. This matters when you start writing more advanced queries.
Combining Conditions with AND, OR, and NOT — The Logic Operators
One condition is useful. Multiple conditions are powerful. SQL gives you three logic operators to combine conditions: AND, OR, and NOT.
AND means BOTH conditions must be true. Think of it as a strict filter — like finding customers who live in the USA AND spent more than $300. Both boxes must be ticked.
OR means AT LEAST ONE condition must be true. Think of a wider net — customers from the USA OR customers from Canada. Either one qualifies.
NOT flips a condition on its head. WHERE NOT country = 'USA' is the same as WHERE country != 'USA'. It's most useful with operators like IN and LIKE, which we'll cover shortly.
Here's the critical thing beginners miss: AND has higher precedence than OR, just like multiplication beats addition in maths. So WHERE country = 'USA' OR country = 'Canada' AND total_amount > 300 does NOT mean what you think. The AND runs first, binding only the Canada condition to the amount check. Always use parentheses to make your logic explicit and readable.
BETWEEN, IN, and LIKE — The Power Operators That Replace Messy Conditions
Once you've got AND, OR, and NOT down, SQL gives you three shortcut operators that make common filtering patterns much cleaner to write and read.
BETWEEN filters rows within a range — it's shorthand for >= and <= combined. WHERE total_amount BETWEEN 100 AND 500 is identical to WHERE total_amount >= 100 AND total_amount <= 500. Both ends are inclusive, meaning 100 and 500 themselves are included.
IN filters against a list of specific values — it's shorthand for chaining multiple OR equals conditions. WHERE country IN ('USA', 'Canada', 'India') beats writing three separate OR conditions, especially when that list grows to ten items.
LIKE is for pattern matching on text. The % symbol means 'any sequence of characters', and the _ symbol means 'exactly one character'. WHERE customer_name LIKE 'A%' finds every name starting with A. WHERE customer_name LIKE '_a%' finds names where the second character is 'a'.
NOT BETWEEN, NOT IN, and NOT LIKE all work as inverses. They're your go-to tools for exclusion filtering.
Filtering NULL Values — The Special Case That Breaks Beginners
NULL in SQL doesn't mean zero. It doesn't mean an empty string. It means unknown or absent — there is no value there at all. This distinction matters enormously when filtering.
Here's the trap: you cannot use = to check for NULL. Writing WHERE phone_number = NULL will never return any rows — not because there are none, but because NULL = NULL evaluates to NULL (unknown), not TRUE. The database sees 'I don't know if this equals nothing' and skips the row.
The correct operators are IS NULL and IS NOT NULL. These are the only two ways to reliably filter on the presence or absence of a value.
This also affects AND and OR. If any part of a compound condition involves NULL, the result can be NULL rather than TRUE or FALSE, causing rows to silently vanish from your results. When you're debugging a query that returns fewer rows than expected, checking for unexpected NULLs in your filter columns is always a smart first step.
Real-world scenario: a customers table might have a phone_number column where some customers haven't provided a number. IS NULL helps you find them so you can prompt them to update their profile.
The WHERE Clause Execution Order — Why Filtering Before JOIN Saves Hours
Your junior thinks WHERE runs on the final result set. In production, that misconception kills performance. The WHERE clause filters rows before JOINs and GROUP BY execute, not after. This is critical for reducing the amount of data entering memory-bound operations. If you filter after a JOIN, you force the engine to join millions of rows only to discard most. Filter early with WHERE. Push predicates into JOIN conditions when you can—that's called 'predicate pushdown' and it's how senior engineers keep queries sub-second. Why does this matter? Because a query that runs in 200ms with early filtering can blow up to 20 seconds when filters are misplaced. Always put your most selective filter first in WHERE. The optimizer might reorder it, but it's a good habit. Test with EXPLAIN to see the actual execution plan. If you see a full table scan before a filter, you've broken the order.
Correlated Subqueries in WHERE — The Silent Serial Execution Trap
You've seen it: a WHERE clause with a subquery that runs once per row. That's a correlated subquery, and it's the fastest way to turn a 50ms query into a 5-minute one. The engine executes the outer query first, then for each row, runs the inner subquery. This is serial—no parallelism. Why do juniors write them? Because they look clean. 'SELECT * FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE status = ''active'')' is actually fine if the subquery is uncorrelated—the engine caches it. But the moment you reference the outer table inside that subquery, you've created a nested loop where each iteration scans the same rows. Replace it with a JOIN or a window function. In production, one correlated subquery in a WHERE clause can spike CPU to 100% and lock your table for writes. Always test with a small dataset first. If you see 'SubPlan' in the EXPLAIN output, you're doing it wrong. The fix is almost always a JOIN with a DISTINCT or LATERAL join.
JSON/JSONB Querying in WHERE Clauses
Modern PostgreSQL databases often store semi-structured data in JSON or JSONB columns. Filtering such data in the WHERE clause requires special operators. The -> operator extracts a JSON object field as JSON, while ->> extracts it as text. For JSONB, the @> operator checks if a JSONB document contains a key/value pair. For example, to find customers whose metadata includes a 'vip' status: SELECT * FROM customers WHERE metadata @> '{"status": "vip"}'. You can also use ? for key existence: WHERE metadata ? 'email_verified'. For nested paths, use #>> to extract by path: WHERE metadata #>> '{address, city}' = 'New York'. These operators are indexable with GIN indexes on JSONB columns, drastically improving performance. Always use JSONB over JSON for queryable columns, as JSONB supports indexing and more operators. Avoid extracting fields in WHERE clauses using functions like json_extract_path_text() as they prevent index usage. Instead, use the native operators. Also beware of NULL handling: a missing key returns NULL, not false, so combine with IS NOT NULL if needed. Proper JSON/JSONB filtering can reduce application-side parsing and enable complex conditions directly in SQL.
json_extract_path_text() that disable index scans.LATERAL JOIN for Row-by-Row Filtering
The LATERAL JOIN allows a subquery to reference columns from preceding tables in the FROM clause, enabling row-by-row filtering that is impossible with regular joins. For example, to find the most recent order for each customer: SELECT c., o.order_date FROM customers c LEFT JOIN LATERAL (SELECT order_date FROM orders WHERE customer_id = c.id ORDER BY order_date DESC LIMIT 1) o ON true. The LATERAL subquery executes once per customer row, making it powerful for top-N-per-group queries. It can also be used in WHERE clauses indirectly by filtering the outer query based on LATERAL results. For instance, to get customers whose last order was in 2023: SELECT c. FROM customers c WHERE EXISTS (SELECT 1 FROM LATERAL (SELECT order_date FROM orders WHERE customer_id = c.id ORDER BY order_date DESC LIMIT 1) o WHERE o.order_date >= '2023-01-01'). LATERAL joins are more efficient than correlated subqueries in SELECT because they can use indexes on the inner table. However, they are still row-by-row, so ensure proper indexing on the join key (e.g., orders.customer_id). Use LATERAL when you need to compute a value per row that depends on the outer row, especially for complex aggregations or filtering on derived data.
Full-Text Search WHERE Conditions: tsvector, MATCH, CONTAINS
Full-text search in PostgreSQL uses tsvector and tsquery types for efficient text matching. Instead of LIKE '%word%', which cannot use indexes, use to_tsvector('english', column) @@ to_tsquery('english', 'word'). For example, to find articles containing 'database' and 'tutorial': SELECT FROM articles WHERE to_tsvector('english', title) @@ to_tsquery('english', 'database & tutorial'). You can also use plainto_tsquery for simpler input: @@ plainto_tsquery('english', 'database tutorial'). For prefix matching, use : syntax: to_tsquery('english', 'datab:*'). To improve performance, create a GIN index on the tsvector column: CREATE INDEX idx_articles_tsv ON articles USING GIN (to_tsvector('english', title)). Some databases like SQL Server use CONTAINS and FREETEXT, but PostgreSQL uses the @@ operator. For phrase searches, use phraseto_tsquery. Full-text search is language-aware, handling stemming and stop words. Avoid using LIKE for text search in production; always use full-text search with indexes for scalability. Combine with WHERE conditions on other columns for precise filtering.
Operator Precedence Bug Emailed the Wrong 40,000 Customers
- AND has higher precedence than OR in SQL — always use parentheses when mixing the two
- Test WHERE clause logic with COUNT(*) before running any data modification or bulk send
- Peer-review all marketing queries against a user count expectation before execution
| File | Command / Code | Purpose |
|---|---|---|
| basic_where_filter.sql | SELECT order_id, customer_name, country, total_amount | What the WHERE Clause Actually Does (And Why It Belongs Afte |
| and_or_not_conditions.sql | SELECT order_id, customer_name, country, total_amount | Combining Conditions with AND, OR, and NOT |
| between_in_like_operators.sql | SELECT order_id, customer_name, total_amount | BETWEEN, IN, and LIKE |
| filtering_null_values.sql | SELECT customer_id, customer_name, phone_number | Filtering NULL Values |
| ExecutionOrderExample.sql | SELECT o.order_id, c.name | The WHERE Clause Execution Order |
| CorrelatedSubqueryFix.sql | SELECT * | Correlated Subqueries in WHERE |
| json_filtering.sql | SELECT * FROM customers | JSON/JSONB Querying in WHERE Clauses |
| lateral_filtering.sql | SELECT c.*, o.last_order_date | LATERAL JOIN for Row-by-Row Filtering |
| fulltext_search.sql | SELECT * FROM articles | Full-Text Search WHERE Conditions |
Key takeaways
Interview Questions on This Topic
What is the difference between WHERE and HAVING in SQL?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
That's SQL Basics. Mark it forged?
7 min read · try the examples if you haven't