MVP Factory
ai startup development

PostgreSQL query planner: fix silent p99 regressions

KW
Krystian Wiewiór · · 4 min read

SEO Title: PostgreSQL Query Planner: Fix Silent p99 Regressions Meta Description: Stale pg_statistic data and cross-column correlations silently destroy your p99 latency. Learn how to diagnose and fix query plan regressions with extended statistics and pg_hint_plan.


TL;DR

Your PostgreSQL query planner is making decisions based on statistics that are often wrong, stale, or completely blind to column correlations. The result: catastrophic plan regressions that show up as p99 spikes — not errors, not crashes — just silent, creeping latency that’s nearly impossible to attribute. This post walks through the internals of pg_statistic, the cost model, and three fixes that have saved production systems I’ve worked on.


How the planner actually works (and where it goes wrong)

The PostgreSQL query planner is a cost-based optimizer. It generates candidate execution plans, estimates the cost of each using a statistical model, and picks the cheapest one. The critical word is estimates. Everything hinges on pg_statistic — a system catalog table populated by ANALYZE that stores column-level histograms, most-common values (MCVs), and null fractions.

What most teams miss: the planner never sees your actual data at query time. It sees a snapshot of statistics that may be hours or weeks old on a high-write table.

The cost model inputs

FactorSourceRisk
Row count estimatepg_class.reltuplesStale after bulk inserts
Selectivity per columnpg_statistic MCVs + histogramLow statistics_target = coarse buckets
Multi-column selectivityAssumed independent by defaultCorrelated columns = wildly wrong estimates
Join row estimatesMultiplied selectivitiesErrors compound across joins

When the planner estimates 50 rows but gets 50,000, it picks a Nested Loop over a Hash Join — and your query goes from 8ms to 4 seconds.


The three root causes

1. Stale statistics after high-write workloads

autovacuum triggers ANALYZE based on a threshold: autovacuum_analyze_threshold + autovacuum_analyze_scale_factor * reltuples. On a 10M-row table with the default 0.2 scale factor, you need 2 million row changes before autovacuum analyzes. That’s an enormous window for plan drift.

-- Check how stale your stats are
SELECT schemaname, relname, last_analyze, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
ORDER BY n_mod_since_analyze DESC
LIMIT 20;

2. Low default_statistics_target

The default is 100 — PostgreSQL stores 100 histogram buckets and 100 MCVs per column. For a column with high cardinality or skewed distribution, that’s not enough. The planner interpolates between sparse buckets and gets selectivity estimates that are off by orders of magnitude.

-- Increase statistics target for a critical column
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;

3. Cross-column correlations

This is the silent killer. The planner assumes column independence by default. If you filter on (country = 'US' AND city = 'Seattle'), it multiplies the selectivities: P(country) × P(city). But city is highly correlated with country, so the actual selectivity is just P(city) — and the planner dramatically underestimates the row count, triggering wrong join strategies.


The fix stack

Extended statistics (PostgreSQL 10+)

-- Create multivariate statistics for correlated columns
CREATE STATISTICS orders_country_city (dependencies, ndistinct)
ON country, city FROM orders;

ANALYZE orders;

-- Verify the planner is using it
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE country = 'US' AND city = 'Seattle';

Look for Statistics objects: orders_country_city in the EXPLAIN output. The row estimate improvement is often 10–100x on correlated columns.

Manual ANALYZE tuning

For tables with time-series skew or write-heavy windows, schedule explicit ANALYZE via cron or a job queue — don’t rely solely on autovacuum.

-- Target specific tables in your maintenance window
ANALYZE VERBOSE orders, shipments, events;

Plan pinning via pg_hint_plan

When you have a known-good plan and can’t wait for statistics to converge, pg_hint_plan lets you override the planner directly in application code or via query rewriting rules.

/*+ HashJoin(o s) SeqScan(o) */
SELECT o.id, s.status
FROM orders o
JOIN shipments s ON s.order_id = o.id
WHERE o.created_at > now() - interval '7 days';

This is a last resort, not a first resort — but in production incidents, it’s invaluable. Document every hint and set a calendar reminder to remove it once statistics are properly calibrated.


Where to start

Run the pg_stat_user_tables query above. Any table with n_mod_since_analyze > 100k and no recent last_analyze is a plan regression waiting to happen.

From there, look for correlated column pairs — (status, type), (country, region), (user_id, tenant_id) are common. One CREATE STATISTICS and an ANALYZE can close the estimation gap entirely.

Finally, raise default_statistics_target to 200–500 on high-cardinality filter columns. The storage cost is negligible; the plan quality improvement on skewed distributions is not.


Tags: backend architecture microservices api saas


Share: Twitter LinkedIn