# 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.

Canonical URL: https://www.devobs.io/articles/qa-prepared-query-tenant-skew/
By: Maya Chen
Published: 2025-09-02T06:16:16.064Z
Updated: 2026-09-06T10:18:15.722Z
Section: Architecture

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](https://www.postgresql.org/docs/current/sql-prepare.html) and how to inspect plans in [Using EXPLAIN](https://www.postgresql.org/docs/current/using-explain.html).

## 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](https://www.postgresql.org/docs/current/sql-prepare.html)). 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](https://www.postgresql.org/docs/current/sql-prepare.html)).

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](https://www.postgresql.org/docs/current/using-explain.html)).

## 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](https://www.postgresql.org/docs/current/sql-prepare.html)).

## 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

## Source references

- <https://www.postgresql.org/docs/current/sql-prepare.html>
- <https://www.postgresql.org/docs/current/using-explain.html>
