SOFTWARE / SYSTEMS / AIEngineering news. Technical depth.
Architecture / 3 MIN READ

Why is the same prepared SQL query fast for one customer and slow for another?

In PostgreSQL, the same prepared statement can be fast for one tenant and slow for another when parameter skew makes a reused generic plan a poor fit.

Parameter skew plus PostgreSQL’s generic-plan reuse is a common explanation. The SQL text is identical, but one customer’s parameter may match a few rows while another matches a huge share of the table. PostgreSQL can execute a prepared statement with either a custom plan or a generic plan, and a generic plan that is acceptable on average can still be wrong for a specific tenant. PostgreSQL documents both behaviors in PostgreSQL PREPARE and how to inspect plans in Using EXPLAIN.

Is this really a prepared-plan problem?

Often, yes. PostgreSQL says a prepared statement can use either a generic or custom plan, and with plan_cache_mode=auto it tries custom plans first, then may switch to a generic one after the first five executions if the estimated cost looks close enough overall (PostgreSQL PREPARE). That tradeoff saves planning time, but it can hurt when tenant data is badly uneven.

A small tenant might be best served by an index scan on tenant_id, while a large tenant might make a broader scan cheaper. If PostgreSQL settles on one generic plan for both populations, one customer can see excellent latency and another can see a regression without any schema change.

How do you prove parameter skew is the cause?

Use EXPLAIN EXECUTE with representative tenant values from the fast and slow cases. PostgreSQL explicitly recommends EXPLAIN EXECUTE name(parameter_values) for prepared statements, and notes that a generic plan will show parameter symbols while a custom plan shows the supplied values (PostgreSQL PREPARE).

Worked example.

Prerequisite: a table such as orders(tenant_id, status, created_at) and an index on (tenant_id, status).

Prepare the statement once:

PREPARE q(text, text) AS SELECT * FROM orders WHERE tenant_id = $1 AND status = $2;

Then compare:

  • EXPLAIN EXECUTE q('tenant_small', 'open')
  • EXPLAIN EXECUTE q('tenant_large', 'open')

If the good path uses an index scan for the small tenant, but later executions show $1 and $2 and choose a broader scan, that is strong evidence the reused generic plan is the issue. PostgreSQL’s EXPLAIN guide is blunt here: choosing a plan that matches the query and data properties is “absolutely critical for good performance” (Using EXPLAIN).

What should you change before adding indexes or splitting databases?

Use this checklist:

  • Compare one fast tenant and one slow tenant with EXPLAIN (ANALYZE, BUFFERS) EXECUTE.
  • Check whether the slow case is using a generic plan.
  • Compare estimated rows with actual rows for the tenant filter.
  • Refresh statistics if the distribution changed recently.
  • Split the query path if small and large tenants need different access patterns.
  • Only then test a new index or physical tenant separation.

A new index is not the first answer if PostgreSQL is reusing the wrong generic plan. Better fixes are usually: better statistics, separating obviously different workloads into separate prepared statements, or selectively forcing custom planning for the affected statement while you validate the tradeoff with plan_cache_mode=force_custom_plan (PostgreSQL PREPARE).

When should you split one query into two?

Split it when the same predicate shape hides two very different row-count distributions. If one tenant returns tens of rows and another returns millions, that is not one workload. Treating them separately is cleaner than hoping a single average-case generic plan will serve both well.

Follow-up Q&A

Do prepared statements cause the slowdown by themselves? No. They often help. The problem is skewed parameter values interacting with plan reuse.

Should we shard by tenant now? Usually not. First confirm whether the latency difference disappears when planning stays custom or when query paths are separated.

Next step: capture EXPLAIN (ANALYZE, BUFFERS) EXECUTE for one fast tenant and one slow tenant from the same prepared statement, then compare scan type, row estimates, and whether the plan shows literal values or $n placeholders.

Reviewed: 2026-09-05

SOURCES & REVIEW

Sources are linked throughout this guide. Product capabilities can change; consult the linked documentation for your deployment.

Read our editorial approach ↗