Statistics Basics
PostgreSQL stores column histograms and distinct-value counts in the catalog. The planner multiplies selectivities to guess how many rows each plan node will process on PostgreSQL 18.4.
Recipe
Quick-reference recipe card - copy-paste ready.
SELECT schemaname, tablename, attname, n_distinct, most_common_vals, histogram_bounds
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';When to reach for this: EXPLAIN row estimates diverge from actual rows on filtered scans or joins.
Working Example
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL,
customer_id bigint NOT NULL
);
INSERT INTO orders (status, customer_id)
SELECT
CASE WHEN i % 50 = 0 THEN 'shipped' ELSE 'pending' END,
(i % 1000)::bigint
FROM generate_series(1, 100000) AS i;
ANALYZE orders;
EXPLAIN (ANALYZE)
SELECT count(*) FROM orders WHERE status = 'shipped';
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'customer_id');What this demonstrates:
most_common_valsfor skewed enumsn_distinctfor cardinality hints- Linking
pg_statscontent to plan row estimates
Deep Dive
How It Works
ANALYZEsamples rows (defaultdefault_statistics_target= 100) and builds per-column stats.- Equality selectivity uses
1/n_distinctor MCV frequency when the literal appears inmost_common_vals. - Range predicates use
histogram_boundsfor non-uniform distributions. - Multiple predicates on independent columns multiply selectivities unless extended stats say otherwise.
Key Catalog Views
| Source | Shows |
|---|---|
pg_stats | Histograms, MCV lists, null fraction |
pg_stat_user_tables | last_analyze, live/dead tuples |
pg_class.reltuples | Table-level row estimate |
SQL Notes
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders (status);Gotchas
- Assuming uniform distribution - Without MCV/histogram, planner guesses badly on skewed status columns. Fix: Higher statistics target or extended stats.
- Stats on empty tables - Default estimates mislead until first meaningful
ANALYZE. Fix: Analyze after initial load thresholds. - Expression predicates lack stats -
WHERE lower(email) = 'x'ignores plain column stats. Fix: Index on expression or extended stats on expression. - Correlated AND columns -
country+postal_codemultiply incorrectly. Fix:CREATE STATISTICS ... dependencies. - Never analyzing after large DELETE -
reltuplesstale for days. Fix: ManualANALYZEpost bulk DML.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Partial index | Stable selective predicate | Predicate varies per user |
| Partition pruning | Time/tenant pruning | Single small table |
| Materialized counts | Dashboard denominators | Real-time need |
FAQs
What is default_statistics_target?
Controls histogram detail and MCV count. Higher is slower analyze, better plans on skew.
How often are stats refreshed?
Autovacuum triggers autoanalyze based on change thresholds. Bulk loads need manual analyze.
Does n_distinct negative mean?
Negative values are fractions of table size (PostgreSQL scaled distinct estimator).
Can I see stats for indexes?
Yes via pg_stats on indexed columns and pg_stat_user_indexes for usage, not histograms.
Null fraction impact?
IS NULL selectivity uses null_frac from pg_stats.
Stats on partitioned tables?
Per-partition stats matter; planner combines partition bounds and local stats.
Sampling bias?
Very small tables analyze fully; large tables sample. Sudden data shape changes need re-analyze.
Correlation stats types?
dependencies, ndistinct, mcv extended stat kinds cover different correlation patterns.
Security on pg_stats?
Readable by default; sensitive data distributions may leak. Restrict roles if needed.
Next page?
ANALYZE and autoanalyze for operational cadence.
Related
- ANALYZE & Autoanalyze - Refresh timing
- Extended Statistics - Correlated columns
- Bad Plan Case Studies - Estimate drift
- Planner GUCs - Cost knobs
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.