A search endpoint uses WHERE (:city IS NULL OR city = :city) for each optional filter — what does that cost, and how do you fix it?
answer
- one plan must fit every parameter combination
- a parameter-dependent OR yields no range
- COALESCE(:p, col) hides a NULL bug
- send only the predicates that exist
- allowlist columns, bind values
basics
~20 sOne statement covering every combination of supplied and omitted filters forces a single plan that must be valid when any parameter is NULL, so no index can be committed to and the engine typically scans. Build the statement from the filters actually supplied, binding values as parameters.
solid answer
~60 sEach `(:p IS NULL OR col = :p)` is a disjunction whose truth depends on a parameter, so no index range can be derived from it in a plan that must also be correct when the parameter is absent. Add three or four such filters and the statement has no usable access path for any combination — the engine reads the table and evaluates the whole expression per row. Whatever plan is produced is then reused for callers whose filters are completely different. The fix is to stop pretending one statement covers all cases: assemble the `WHERE` clause from the filters the caller actually supplied, with every value still bound as a parameter. Each variant is a distinct statement with its own indexable predicates and its own plan. To keep the number of variants bounded, allowlist the filterable columns and, where the domain allows, require one selective anchor filter — a tenant, an account, a date range — so every variant starts from an index. Some engines offer a per-execution recompile option as a mitigation, but that is dialect-specific.
code
sql · 8 lines-- Anti-pattern: one statement for every combination of optional filters
SELECT *
FROM customers
WHERE (:city IS NULL OR city = :city)
AND (:status IS NULL OR status = :status);
-- Also wrong: silently drops rows whose city is NULL when :city is NULL
SELECT * FROM customers WHERE city = COALESCE(:city, city);go deeper
Know that a predicate like (:p IS NULL OR col = :p) cannot be served by an index, and that sending only the filters the caller actually supplied is the usual fix.
Explain why one plan must be valid for every parameter combination and therefore commits to no index, and show the COALESCE spelling's NULL bug in the unfiltered case.
Diagnose it from production symptoms — one statement dominating time, latency independent of filter selectivity — and design the fix: assembled predicates, allowlisted columns, bound values, a mandatory selective anchor.
Decide the endpoint's contract. Whether arbitrary filter combinations over a large table are a supported capability at all, versus a required anchor filter or a dedicated search system, is a product and capacity decision, not a query tweak.
## The shape and why it is tempting A search screen has six optional filters. Writing six statements looks like duplication, so the "clever" single statement appears: ```sql SELECT * FROM customers WHERE (:city IS NULL OR city = :city) AND (:status IS NULL OR status = :status) AND (:country IS NULL OR country = :country); ``` It is genuinely one statement, it is parameterised, and it returns the right rows. The problem is entirely about access paths. ## Why no index gets used An index seek needs a value range derived from the predicate. `city = :city` supplies one. `(:city IS NULL OR city = :city)` does not: the row qualifies either when it matches or when the parameter is absent, and "the parameter is absent" says nothing about `city` at all. The engine must produce a plan that is correct for every parameter combination, and the only access path correct for all of them is one that examines the rows and evaluates the expression. With one such filter you might still get lucky through another predicate; with all filters optional there is nothing left to anchor on. There is a second-order effect that senior candidates should name: the single statement text means one shared plan across callers with wildly different filter combinations. A caller who supplied a highly selective filter is served by whatever plan was appropriate for someone else's filter set. ## The COALESCE variant is worse The compact spelling looks smarter and quietly loses rows: ```sql WHERE city = COALESCE(:city, city) -- silently drops NULL cities ``` When `:city` is NULL the predicate becomes `city = city`, which is **unknown** for any row whose `city` is NULL, and `WHERE` keeps only rows that are true. So the "no filter supplied" case drops every row with a missing city — a correctness bug that survives review because the expression reads like a default. It also wraps the column in a function, so even the supplied-parameter case has nothing bare to seek on. ## The fix: build the statement from the supplied filters Send only the predicates that exist: ```sql -- caller supplied city and status only SELECT * FROM customers WHERE city = ? AND status = ?; ``` Each predicate is a bare-column equality, so an index on `(city, status)` — or on either column — can be used, and the optimizer can estimate row counts for the filters actually present. Two disciplines make this safe and sustainable: **Allowlist the columns and operators.** The statement is assembled from a fixed map of filter name to column and comparison; only the *values* are ever bound as parameters. Nothing user-supplied reaches the SQL text. **Bound the combinatorics.** Six independent optional filters imply sixty-four possible statements. In practice the distribution is heavily skewed and a handful of combinations serve most traffic; keep the filter list short, and prefer a design where one selective filter is mandatory. A search that must be able to scan the whole table with no filter at all is a requirement worth challenging. ## Other mitigations and their limits **A required anchor.** Many real endpoints already have one: a tenant id, an owner, a date window. If every variant carries it, every variant can start from a composite index leading with that column, and the optional filters become residual conditions on a small row set. This is often the cheapest fix in a multi-tenant system. **Separate statements for the common cases.** Hand-write the three or four combinations that dominate traffic and let the generic form serve the tail. Less elegant, entirely predictable. **Engine features.** Some engines offer a statement-level option to re-optimise per execution rather than reuse a shared plan, which addresses the shared-plan half of the problem but not the unsargable-predicate half. The spelling is dialect-specific, so treat it as a local mitigation rather than the portable answer. **Union of branches.** Splitting the disjunction into `UNION` branches, as you would for an ordinary `OR` across columns, does not scale here: the branch count grows with the number of optional filters and the statement becomes unmaintainable. ## Diagnosing it in production The fingerprint is distinctive: one statement dominating database time, latency that barely varies with how selective the caller's filters were, and plans showing a full scan with a long list of filter conditions. Compare a hand-written statement for one common filter combination against the generic one on the same inputs; if the hand-written one is orders of magnitude faster, the generic predicate is the cause and the row estimates in the generic plan will usually be visibly wrong. ## How to answer State the mechanism first (a parameter-dependent disjunction yields no seekable range, and one plan must serve every combination), then the correctness trap in the `COALESCE` spelling, then the fix — assemble from supplied filters, allowlist the columns, bind the values, bound the variants, prefer a mandatory selective anchor. Mention per-execution recompilation as an engine-specific mitigation rather than the design.
- Why does WHERE city = COALESCE(:city, city) lose rows when no filter is supplied?With `:city` NULL the predicate reduces to `city = city`, which is unknown for any row whose city is NULL, and `WHERE` keeps only rows evaluating to true. So the unfiltered case silently omits every row with a missing city. It also wraps the column in an expression, so even the supplied case has no bare column for an index to seek.
- How do you keep dynamically assembled filters safe?Only structure comes from code, never from input: a fixed map from filter name to column and comparison operator, so an unrecognised filter is rejected rather than concatenated. Values are always bound as parameters. The assembled text then varies over a small, known set of shapes that you can review, log and test.
- When is the single all-optional statement acceptable?When the table is small enough that a scan is genuinely cheap, or when the endpoint is administrative and rarely called. The pattern's cost is proportional to the rows it must examine, so on a few thousand rows the simplicity wins. Reassess the moment the table grows or the endpoint moves onto a user-facing path.
saying these in an interview costs you the question
- Claims the optimizer strips the IS NULL branch automatically
- Believes COALESCE(:p, col) is a safe default filter
- Adds indexes on every filterable column and expects a fix
- Builds the WHERE clause by concatenating caller-supplied column names
- Says one plan for all combinations is fine because it is parameterised