> How the planner uses table statistics (pg_stats, histograms, most-common-values) to estimate row counts and choose execution plans.
Scanned 9/11/2026
Install to Claude Code
npx -y skills add Intense-Visions/harness-engineering --skill db-query-statistics --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Db Query Statistics?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/intense-visions-db-query-statistics-1acb62f7)More formats (shields.io, HTML) on the badges page.
# Query Statistics and Selectivity
> How the planner uses table statistics (pg_stats, histograms, most-common-values) to estimate row counts and choose execution plans.
## When to Use
- Diagnosing planner misestimations (estimated rows vs actual rows differ by 10x+)
- Understanding why the planner chose a bad plan despite correct indexes
- Tuning statistics targets for columns with skewed distributions
- After bulk data loads, migrations, or large deletes that change data distribution
- Investigating queries where EXPLAIN shows correct indexes but wrong join strategies
## Instructions
### Key Concepts
The planner does not look at actual table data at query time. Instead, it uses pre-computed statistics stored in `pg_statistic` (accessible via the `pg_stats` view). These statistics are sampled approximations, not exact counts.
**Key statistics columns in `pg_stats`:**
- `n_distinct` -- estimated number of distinct values. Positive values are absolute counts; negative values are fractions of total rows (e.g., -1.0 means every row is unique)
- `most_common_vals` -- the N most frequent values in the column
- `most_common_freqs` -- the frequency of each most-common value (fractions of total rows)
- `histogram_bounds` -- equal-frequency histogram bucket boundaries for values not in the MCV list
- `null_frac` -- fraction of rows that are NULL
- `correlation` -- how well the physical row order matches the logical (sorted) order. Values near 1.0 or -1.0 mean high correlation
**The ANALYZE command** samples the table and updates these statistics:
```sql
ANALYZE orders; -- analyze one table
ANALYZE orders (status); -- analyze one column
ANALYZE; -- analyze all tables in the database
```
Autovacuum runs ANALYZE automatically, but it may lag behind large data changes.
### Worked Example
Examining statistics for the `status` column on an orders table:
```sql
SELECT
attname,
n_distinct,
most_common_vals,
most_common_freqs,
null_frac,
correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
```
```
attname | n_distinct | most_common_vals | most_common_freqs | null_frac | correlation
---------+------------+-----------------------------+--------------------------+-----------+------------
status | 3 | {completed,active,cancelled} | {0.85,0.12,0.03} | 0 | 0.15
```
**How the planner uses this:** For `WHERE status = 'active'`, the estimated rows = total_rows \* 0.12. On a 10M-row table, the estimate is 1.2M rows. For `WHERE status = 'cancelled'`, the estimate is 300K rows.
**Misestimation scenario:** After a data migration that marked 90% of orders as cancelled (previously 3%), the statistics are stale:
```sql
EXPLAIN ANALYZE
SELECT * FROM orders WHERE status = 'cancelled';
```
```
Index Scan using idx_orders_status on orders
(cost=0.43..12345.67 rows=300000 width=52)
(actual time=0.031..8923.450 rows=9000000 loops=1)
```
Estimated 300K rows, actual 9M -- a 30x misestimation. The planner chose Index Scan (good for 300K) instead of Seq Scan (better for 9M). Fix:
```sql
ANALYZE orders;
```
After ANALYZE, the planner sees the updated distribution and switches to Seq Scan:
```
Seq Scan on orders (cost=0.00..223456.00 rows=9010000 width=52)
(actual time=0.012..1234.567 rows=9000000 loops=1)
Filter: (status = 'cancelled')
Execution Time: 1345.678 ms
```
**Increasing statistics granularity** for skewed distributions:
```sql
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
```
The default `default_statistics_target` is 100 (100 histogram buckets and 100 MCV entries). For highly skewed columns, increasing to 500-1000 gives the planner a more accurate picture.
### Anti-Patterns
1. **Never running ANALYZE after bulk operations.** After a large INSERT, DELETE, or UPDATE that changes data distribution, statistics are stale. The planner uses the old distribution, producing bad plans. Always `ANALYZE tablename;` after bulk changes.
2. **Setting default_statistics_target too low.** The default of 100 works for uniform distributions but fails for skewed data. Columns with thousands of distinct values at varying frequencies need higher targets.
3. **Ignoring n_distinct misestimates.** For high-cardinality columns (e.g., user_id with 50M distinct values), the sampled n_distinct can be significantly off. Override with: `ALTER TABLE orders ALTER COLUMN user_id SET (n_distinct = -1);` (tells the planner every value is unique).
4. **Disabling autovacuum.** Autovacuum also runs auto-ANALYZE. Disabling it means statistics are never refreshed automatically, guaranteeing plan degradation over time.
### PostgreSQL Specifics
**default_statistics_target** controls the number of histogram buckets and MCV entries. Default: 100. Maximum: 10000. Higher values increase ANALYZE time but improve estimation accuracy:
```sql
-- Global setting:
SET default_statistics_target = 200;
-- Per-column override:
ALTER TABLE events ALTER COLUMN event_type SET STATISTICS 500;
```
**Extended statistics** for correlated columns (PostgreSQL 10+):
```sql
CREATE STATISTICS stat_orders_status_region (dependencies)
ON status, region FROM orders;
ANALYZE orders;
```
Without extended statistics, the planner assumes columns are independent. If `status = 'active'` AND `region = 'us-east'` are correlated (e.g., 80% of us-east orders are active), the default estimation multiplies their individual frequencies, producing a significant underestimate.
**Monitoring ANALYZE freshness:**
```sql
SELECT relname, last_analyze, last_autoanalyze, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'orders';
```
If `last_autoanalyze` is days old and `n_dead_tup` is high, autovacuum is falling behind.
## Details
### Advanced Topics
**Multivariate statistics evolution:**
- PostgreSQL 10: functional dependencies (`dependencies`)
- PostgreSQL 12: MCV lists for column combinations (`mcv`)
- PostgreSQL 14: expression statistics (`expressions`)
```sql
CREATE STATISTICS stat_orders_multi (dependencies, mcv)
ON status, region, tenant_id FROM orders;
```
**Selectivity estimation for complex predicates.** AND clauses multiply individual selectivities (assuming independence). OR clauses use inclusion-exclusion. NOT inverts selectivity. These assumptions break down for correlated columns -- extended statistics fix this.
**The 1/n_distinct fallback.** For values not in the MCV list and not in the histogram range, PostgreSQL estimates selectivity as 1/n_distinct. This is a rough guess and can be significantly wrong for new or rare values.
**pg_statistic_ext** stores extended statistics data. Query it to verify that your extended statistics are being computed:
```sql
SELECT stxname, stxkeys, stxkind
FROM pg_statistic_ext
WHERE stxrelid = 'orders'::regclass;
```
### Engine Differences
**MySQL** uses `ANALYZE TABLE` (not just `ANALYZE`) to update statistics:
```sql
ANALYZE TABLE orders;
```
MySQL stores statistics in `mysql.innodb_index_stats` and `mysql.innodb_table_stats`. Key differences:
- **Persistent statistics** (`innodb_stats_persistent = ON` by default since 5.6): statistics survive restarts
- **Sampling pages** (`innodb_stats_persistent_sample_pages`, default 20): controls sample size for ANALYZE. Much smaller than PostgreSQL's default of 30,000 \* `default_statistics_target` rows
- **Histogram support** added in MySQL 8.0: `ANALYZE TABLE t UPDATE HISTOGRAM ON col WITH 100 BUCKETS;`
- **No extended/multivariate statistics** -- MySQL assumes column independence for all multi-column predicates
MySQL's optimizer uses a simpler statistics model overall. For complex queries with correlated columns, PostgreSQL's extended statistics provide significantly better estimates.
### Real-World Case Studies
**Reporting system with daily batch inserts of 5M rows.** After each nightly batch, morning report queries degraded from 2 seconds to 30+ seconds. Investigation showed that autovacuum's ANALYZE had not run since the batch completed -- the statistics still reflected the previous day's distribution. Adding `ANALYZE reporting_events;` to the batch job's post-load step ensured fresh statistics. Query performance remained consistent at 2 seconds. Selectivity estimation error dropped from 100x (stale stats) to under 2x (fresh stats).
## Source
- [PostgreSQL Planner Statistics](https://www.postgresql.org/docs/current/planner-stats.html)
- [PostgreSQL Row Estimation Examples](https://www.postgresql.org/docs/current/row-estimation-examples.html)
## Process
1. Read the key concepts to understand how the planner uses pg_stats for selectivity estimation.
2. Apply ANALYZE after bulk data changes, and increase statistics targets for columns with skewed distributions.
3. Verify by checking `pg_stats` for your key columns and comparing EXPLAIN estimated rows to actual rows.
## Harness Integration
- **Type:** knowledge -- this skill is a reference document, not a procedural workflow.
- **No tools or state** -- consumed as context by other skills and agents.
- **related_skills:** db-explain-reading, db-scan-types, db-query-rewriting, db-btree-index
## Success Criteria
- Statistics freshness is verified after bulk operations (`ANALYZE` runs in post-load steps).
- Misestimations are diagnosed by comparing pg_stats values to actual data distribution.
- Extended statistics are created for correlated columns that cause multi-column estimation errors.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!