Partial Indexes and Prepared Statement Parameters #

A partial index on WHERE status = 'queued' makes the job-poller query instantaneous in psql. From the application the same query takes 900 ms and shows a sequential scan. Nothing differs but the literal: the application sends WHERE status = $1, and a generic plan cannot know that $1 will be 'queued'.

Predicate matching is covered in partial index predicate matching rules. This page covers the interaction with parameters, which is where most “the index is not used in production” reports come from.

The Planner Condition #

To use a partial index, the planner must prove that every row the query wants satisfies the index predicate. The proof is made from the query’s qualifications at planning time:

So the same statement can use the partial index for its first five executions and stop using it when the generic plan takes over, exactly as described in prepared statement plan pinning. The symptom is a query that degrades after warm-up, and is fast again after a reconnect.

The same applies to a partial index’s predicate on any expression: an IN list, a range, or deleted_at IS NULL all need a provable implication from the query’s own conditions.

Can the planner prove the predicate? Three cases. With a literal in the query the implication is proven and the partial index is usable. With a parameter in a custom plan the bound value is substituted and the proof succeeds. With a parameter in a generic plan the value is unknown and the partial index cannot be used. does the query imply WHERE status = 'queued'? literal in the SQL proven partial index usable parameter, custom plan value substituted partial index usable parameter, generic plan unknown value index not usable the switch to a generic plan happens silently after five executions

Annotated EXPLAIN Evidence #

CREATE INDEX CONCURRENTLY jobs_queued_run_at_idx
  ON jobs (run_at) WHERE status = 'queued';

With a literal:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM jobs WHERE status = 'queued' AND run_at <= now() ORDER BY run_at LIMIT 10;

Limit  (actual time=0.02..0.03 rows=10 loops=1)
  ->  Index Scan using jobs_queued_run_at_idx on jobs  (actual rows=10 loops=1)
        Index Cond: (run_at <= now())
        Buffers: shared hit=4
Execution Time: 0.05 ms

With a parameter, generic plan:

PREPARE poll (text) AS
  SELECT id FROM jobs WHERE status = $1 AND run_at <= now() ORDER BY run_at LIMIT 10;
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS) EXECUTE poll ('queued');
Limit  (actual time=884.2..884.2 rows=10 loops=1)
  ->  Sort  (actual time=884.2..884.2 rows=10 loops=1)
        Sort Key: run_at
        Sort Method: top-N heapsort  Memory: 26kB
        ->  Seq Scan on jobs  (actual rows=3120 loops=1)
              Filter: ((status = $1) AND (run_at <= now()))
              Rows Removed by Filter: 41204110
        Buffers: shared hit=402110 read=18204
Execution Time: 884.6 ms
-- the partial index is invisible to this plan: $1 might be any status

And with a custom plan, the same prepared statement:

SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE) EXECUTE poll ('queued');

Limit → Index Scan using jobs_queued_run_at_idx on jobs   Execution Time: 0.06 ms
-- bound value substituted at planning time; the proof succeeds
Three plans for one query Three fragments are annotated. With a literal the partial index is used directly. With a generic plan the filter shows a parameter and the plan falls back to a sequential scan. With a custom plan for the same prepared statement the partial index is used again. Index Scan using jobs_queued_run_at_idx literal proves the predicate Filter: ((status = $1) AND …) → Seq Scan generic plan: no proof possible force_custom_plan → Index Scan bound value substituted a statement can flip between these after five executions

Step-by-Step Resolution #

  1. Confirm the pattern. Fast with a literal, slow from the application, and $n visible in the plan’s Filter.

  2. Check whether the statement is prepared and generic:

    SELECT name, generic_plans, custom_plans FROM pg_prepared_statements;
  3. Pick a fix by how the predicate is used.

    • The predicate is a constant of the workload — the poller always wants 'queued'. Write the literal into the SQL instead of parameterising it. This is the simplest and most robust fix.

    • The application cannot inline it — force custom plans for that statement or role:

      ALTER ROLE worker SET plan_cache_mode = 'force_custom_plan';
    • Several predicate values are used — create a full index that does not depend on a predicate, for example (status, run_at), and accept the larger index. The planner can then use it for any parameter value.

  4. Verify from the application’s connection, not from psql, using auto_explain.

  5. Re-check after deploys. ORMs and drivers change whether statements are prepared; the same query can flip behaviour with a driver upgrade, as covered in PgBouncer protocol-level prepared statements.

Same index, four outcomes With a literal predicate the poll takes 0.05 milliseconds. With a parameter under a custom plan, 0.06 milliseconds. With a parameter under a generic plan, 884 milliseconds. With a full index on status and run_at under a generic plan, 0.08 milliseconds. literal predicate 0.05 ms parameter, custom plan 0.06 ms parameter, generic plan 884 ms full index, generic plan 0.08 ms a full index costs more storage but is parameter-proof

Before and After #

-- BEFORE: parameterised status, generic plan
Seq Scan on jobs  Filter: ((status = $1) AND …)  Rows Removed by Filter: 41204110   Execution Time: 884.6 ms

-- AFTER: literal 'queued' in the poller's SQL
Index Scan using jobs_queued_run_at_idx  Index Cond: (run_at <= now())              Execution Time: 0.05 ms

Choosing between a partial index and a full one #

The partial index is smaller, cheaper to maintain, and — for a queue where 99.99% of rows are not queued — dramatically more efficient. Giving that up to satisfy a driver’s parameterisation is a real loss. But a full (status, run_at) index is parameter-proof and serves every status value, which matters when the same query shape is used for several states.

A middle path suits many applications: keep the partial index for the hot path and write that one query with a literal, since the poller genuinely only ever wants queued jobs, while leaving the general-purpose queries parameterised against a full index. Inlining a value that is a constant of the code — not user input — introduces no injection risk and costs nothing at the driver level.

Where several hot states exist, one partial index per state with literal-bearing queries is still viable, and keeps each index tiny. Beyond a handful of states, the maintenance burden outweighs the benefit and a full composite index is simpler.

Common Pitfalls #

Testing only in psql. Literals there hide the problem. Diagnostic signal: fast in psql, slow in the application. Fix: capture plans from the application role.

Assuming ORMs send literals. Most parameterise everything. Diagnostic signal: $n in logged plans. Fix: use the ORM’s raw SQL escape hatch for the hot query.

Forcing custom plans globally. Every statement re-plans, adding CPU. Diagnostic signal: planning time rising across the workload. Fix: scope to the role or statement that needs it.

Predicate not implied even with a literal. WHERE status IN ('queued','retry') does not imply status = 'queued'. Diagnostic signal: index unused despite a literal. Fix: match the predicate or broaden the index.

Frequently Asked Questions #

Why does my partial index work in psql but not from my application? #

psql sends literals, so the planner can prove the query implies the index predicate. Applications usually send parameters, and once the prepared statement switches to a generic plan the value is unknown, so the partial index cannot be proven usable.

Do custom plans use partial indexes with parameters? #

Yes. When a prepared statement is planned with bound values — as custom plans are — the value is substituted at planning time and the implication can be proven.

How do I keep using a partial index from an application? #

Inline the predicate value as a literal for that query, force custom plans for the statement or role, or replace the partial index with a full index that does not depend on a predicate.

Up: Partial Index Implementation