Filtering rows: WHERE, precedence, and sargability
WHERE decides which rows survive — and, quietly, whether the planner can use an index. Wrapping a column in a function or a leading wildcard kills the index and turns a 2 ms lookup into a full scan.
Two queries look identical to a reviewer. WHERE email = 'a@b.com' runs in 2 ms on 20 million users. WHERE lower(email) = 'a@b.com' runs in 1.9 seconds on the same table — a 900× regression hiding behind one harmless-looking function call. The schema is fine, the index exists, the data is the same. The difference is whether the predicate is sargable: shaped so the planner can actually use the index. WHERE is not just a filter; it’s the contract that decides what plans are even on the table.
Precedence: AND binds tighter than OR
WHERE evaluates a boolean expression per row. The trap is precedence: AND binds tighter than OR, exactly like * binds tighter than +. So this:
SELECT * FROM orders
WHERE status = 'paid' OR status = 'shipped' AND total > 100;does not mean “(paid or shipped) and total over 100”. It means status = 'paid' OR (status = 'shipped' AND total > 100) — every paid order comes back regardless of total, which is almost never what was intended. The fix is mechanical and non-negotiable in review: parenthesize any mixed AND/OR.
SELECT * FROM orders
WHERE (status = 'paid' OR status = 'shipped') AND total > 100;NOT binds tightest of the three. When you mix all three, the safe habit is: never trust precedence, always group with parentheses. A misplaced precedence bug doesn’t error — it silently returns the wrong rows, which is the worst kind of bug.
The convenience predicates: BETWEEN, IN, LIKE/ILIKE
- BETWEEN a AND b is inclusive on both ends:
total BETWEEN 100 AND 200istotal >= 100 AND total <= 200. The classic mistake is usingBETWEENon timestamps —created_at BETWEEN '2026-01-01' AND '2026-01-31'excludes almost all of Jan 31 because the upper bound is midnight. For time ranges prefer a half-open interval:created_at >= '2026-01-01' AND created_at < '2026-02-01'. - IN (…) is a tidy
ORchain:status IN ('paid','shipped'). (INwith a subquery andNULLhas a famous trap — that’s the next lesson.) - LIKE does pattern matching:
%= any run of characters,_= one character. ILIKE is the case-insensitive version (a Postgres extension).
-- users(id, email, display_name, created_at)
SELECT id, email FROM users
WHERE email LIKE 'support@%' -- anchored prefix: index-friendly
AND created_at >= '2026-01-01'
AND created_at < '2026-02-01'; -- half-open range, no BETWEEN trapSargability: the word that explains the 900× gap
A predicate is sargable (Search-ARGument-able) if the planner can use it to drive an index instead of scanning every row. The rule of thumb: the indexed column must appear bare on one side, compared to a constant. The moment you wrap the column in a function or transform it, the B-tree — which is sorted on the raw column values — becomes useless, because the index has no idea what lower(email) sorts like.
Two non-sargable patterns dominate production incidents:
- A function on the column.
WHERE lower(email) = 'a@b.com'can’t use the index onemail, because the index storesemail, notlower(email). The fix is an expression index that indexes exactly what you query:CREATE INDEX ON users (lower(email));. Nowlower(email) = …is sargable again. (Index internals — B-trees, leading-column rules, expression indexes — are taught in depth by thedatabasestrack’s indexes chapter; here you only need the predicate-shape consequence.) - A leading wildcard in LIKE.
WHERE email LIKE 'abc%'is sargable — the prefixabcpins a contiguous range in the sorted B-tree, so Postgres walks straight to it.WHERE email LIKE '%abc'is not — there’s no fixed prefix to seek on, so every value must be checked. Leading-wildcard search at scale needs a different tool (a trigram GIN index or full-text search), not a B-tree.
Together these two patterns account for the majority of “the index exists but isn’t used” bugs. When you see a query that should be fast but isn’t, checking whether the column appears bare in WHERE is the first thing to do — before adding new indexes.
▸Common mistake
The sneakiest non-sargable form is an implicit function. WHERE created_at::date = '2026-01-15' casts the column on every row, so the index on created_at is bypassed. Rewrite as a sargable range: created_at >= '2026-01-15' AND created_at < '2026-01-16'. Same logic, but now the bare column is compared to constants and the index drives the scan. Always be suspicious when a column has anything wrapped around it in WHERE.
Parameterize — for safety AND for plan reuse
Never build a WHERE value by string-concatenating user input. WHERE email = '" + input + "' is the textbook SQL-injection hole: an input of ' OR '1'='1 turns your filter into a tautology that returns the whole table. The fix is parameterized queries (bind parameters / prepared statements): the value travels separately from the SQL text, so it can never change the query’s structure.
-- Parameterized: the $1 placeholder is bound to the value by the driver,
-- never interpolated into the SQL string. Injection becomes impossible.
SELECT id, email FROM users WHERE email = $1;Parameterization also lets Postgres cache and reuse the parsed/planned statement across calls, which removes parse and plan overhead on hot paths. Safety and performance point the same way: bind, don’t concatenate.
WHERE status = 'paid' OR status = 'shipped' AND total > 100 — which rows come back?
There's a B-tree index on users(email). Which predicate can use it for an index scan?
Fill in the blank: a predicate the planner can drive an index with — a bare column compared to a constant — is called _______. Wrapping the column in a function destroys this property.
- 01What does 'sargable' mean and what's the one-line rule to keep a predicate sargable?
- 02Why does WHERE lower(email) = 'a@b.com' ignore an index on email, and how do you fix it?
- 03Give the precedence of NOT, AND, OR and the practical rule for mixing them.
WHERE is the selection stage from the previous lesson, and it does two jobs at once: it decides which rows survive, and — through its shape — it decides which plans the planner can choose. Parenthesize any mixed AND/OR, because AND binds tighter than OR and a precedence slip silently returns wrong rows. Use BETWEEN/IN/LIKE/ILIKE for readability, but reach for half-open ranges (>= … < …) on timestamps to dodge the inclusive-BETWEEN boundary bug. The senior idea is sargability: keep the indexed column bare and compared to a constant so the B-tree can drive the scan; wrapping it in lower(), casting it, or leading a LIKE with % makes the predicate non-sargable and forces a full scan — fixed with an expression index or a trigram/full-text index (the databases indexes chapter goes deep). Finally, always parameterize: bind values instead of concatenating, which closes the SQL-injection door and lets Postgres reuse the plan. Now when you see a query that’s slower than it should be, ask one question first: is the indexed column sitting bare in WHERE, or is something wrapped around it? That single check catches most index-killing bugs before you reach for EXPLAIN.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.