open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 01 · 02

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.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

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 200 is total >= 100 AND total <= 200. The classic mistake is using BETWEEN on 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 OR chain: status IN ('paid','shipped'). (IN with a subquery and NULL has 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 trap

Sargability: 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:

  1. A function on the column. WHERE lower(email) = 'a@b.com' can’t use the index on email, because the index stores email, not lower(email). The fix is an expression index that indexes exactly what you query: CREATE INDEX ON users (lower(email));. Now lower(email) = … is sargable again. (Index internals — B-trees, leading-column rules, expression indexes — are taught in depth by the databases track’s indexes chapter; here you only need the predicate-shape consequence.)
  2. A leading wildcard in LIKE. WHERE email LIKE 'abc%' is sargable — the prefix abc pins 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.

Quiz

WHERE status = 'paid' OR status = 'shipped' AND total > 100 — which rows come back?

Quiz

There's a B-tree index on users(email). Which predicate can use it for an index scan?

Complete the analogy

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.

Recall before you leave
  1. 01
    What does 'sargable' mean and what's the one-line rule to keep a predicate sargable?
  2. 02
    Why does WHERE lower(email) = 'a@b.com' ignore an index on email, and how do you fix it?
  3. 03
    Give the precedence of NOT, AND, OR and the practical rule for mixing them.
Recap

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.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.