Overriding n_distinct on Large Tables #
pg_stats says the user_id column of your 900-million-row events table has 184,000 distinct values. You have 11 million users. Every equality lookup on a user who is not in the most-common-values list is estimated at roughly 60 times their real row count, and every GROUP BY user_id is sized for 184,000 groups instead of 11 million.
The statistic is not stale; it is extrapolated from a sample that is too small to see a long tail. This page shows how to recognise the underestimate and replace it with a value you supply — one of the repairs laid out in statistics and selectivity estimation.
Why the Sample Underestimates Distinct Values #
ANALYZE reads a fixed-size sample — 30,000 rows at the default target — and counts the distinct values in it. It then extrapolates to the whole table with a statistical estimator that weighs how many values appeared exactly once in the sample. That estimator is reasonable when values repeat often. On a column with a long tail of rarely repeated values, most of them never appear in the sample at all, and the extrapolation has nothing to scale up.
The planner uses n_distinct in two places where the error hurts:
- Equality on a non-MCV value. Selectivity is the leftover frequency divided by
n_distinct − mcv_count. A distinct count 60× too small makes each value look 60× too common. - Group estimates.
GROUP BY,DISTINCT,UNIONand hash-aggregate sizing all usen_distinctdirectly. An underestimate makes a HashAggregate look small enough to fit inwork_mem, and the actual hash table then spills.
n_distinct is stored in one of two forms. A positive number is an absolute count. A negative number between -1 and 0 is a fraction of the row count: -0.0122 means one distinct value per 82 rows. ANALYZE stores the fractional form when it concludes the count scales with the table, so always read the sign before comparing.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, count(*) FROM events
WHERE occurred_at >= now() - interval '1 day'
GROUP BY user_id;
HashAggregate (cost=… rows=184000 width=16) (actual time=… rows=3920114 loops=1)
Group Key: user_id
Planned Partitions: 0 Batches: 33 Memory Usage: 262193kB Disk Usage: 1822512kB
-- rows=184000 planned groups vs 3,920,114 actual → hash table sized 21× too small
-- Planned Partitions: 0 → the planner expected no spill; Batches: 33 → it spilled heavily
-> Index Scan using events_occurred_at_idx on events (actual rows=41822031 loops=1)
And the statistic behind it:
SELECT n_distinct, (SELECT reltuples FROM pg_class WHERE relname = 'events') AS reltuples
FROM pg_stats WHERE tablename = 'events' AND attname = 'user_id';
n_distinct | reltuples
------------+-----------
184212 | 9.02e+08
-- positive → absolute count; real distinct users ≈ 11,000,000
Step-by-Step Resolution #
-
Measure the true distinct count — on a replica or with an approximation when an exact
count(DISTINCT)over the whole table is too heavy:-- exact, on a replica SELECT count(DISTINCT user_id) FROM events; -- cheaper proxy: distinct count in a large block sample, scaled SELECT count(DISTINCT user_id) FROM events TABLESAMPLE SYSTEM (1);For a column that references another table’s primary key,
SELECT count(*) FROM usersis often the best available upper bound. -
Choose the form. If the number of users grows as events grow, use a fraction so the value stays right as the table grows:
11,000,000 / 900,000,000 ≈ 0.0122, stored as-0.0122. If the number is fixed — countries, currencies, a small enum — use the absolute count. -
Set the override.
ALTER TABLE events ALTER COLUMN user_id SET (n_distinct = -0.0122);For a partitioned parent, set the inherited variant as well, since parent-level estimates use their own statistics:
ALTER TABLE events ALTER COLUMN user_id SET (n_distinct_inherited = -0.0122); -
Analyze to apply it. The option is read during
ANALYZEand written intopg_statistic:ANALYZE events; SELECT n_distinct FROM pg_stats WHERE tablename = 'events' AND attname = 'user_id'; -- -0.0122 -
Re-check both estimate types — a non-MCV equality lookup and the
GROUP BY:EXPLAIN SELECT * FROM events WHERE user_id = 70331; EXPLAIN SELECT user_id, count(*) FROM events GROUP BY user_id;
Before and After #
-- BEFORE: n_distinct = 184212 (sample extrapolation)
HashAggregate (rows=184000) (actual rows=3920114) Batches: 33 Disk Usage: 1822512kB Execution Time: 48122.7 ms
-- AFTER: SET (n_distinct = -0.0122); ANALYZE events
HashAggregate (rows=3790000) (actual rows=3920114) Planned Partitions: 32 Batches: 32 Execution Time: 21406.3 ms
The spill did not disappear — four million groups do not fit in the memory budget — but the planner now plans the partitioning up front instead of discovering it mid-execution, and the cost comparison against a sort-based GroupAggregate is made with a true group count.
Common Pitfalls #
Setting the override without analyzing. The column option alone is inert. Diagnostic signal: pg_attribute.attoptions shows n_distinct=-0.0122 but pg_stats.n_distinct still shows the sampled value. Fix: run ANALYZE on the table.
Using an absolute count for a growing column. A fixed 11000000 is correct today and too small in a year. Diagnostic signal: estimates that were good after the change slowly drift high again as the table grows. Fix: use the negative fractional form for anything that scales with row count.
Overriding only the partitions of a partitioned table. Queries planned against the parent use inherited statistics. Diagnostic signal: partition-level EXPLAIN estimates are right while queries on the parent remain wrong. Fix: set n_distinct_inherited on the parent too and analyze the parent explicitly — autovacuum never analyzes partitioned parents.
Overriding a column whose problem is skew, not count. When a handful of values dominate, the fix is a longer MCV list; n_distinct only governs the tail. Diagnostic signal: the misestimated values are among the most frequent in the column. Fix: raise the statistics target instead.
Frequently Asked Questions #
Should I use a positive or a negative n_distinct override?
Use a negative fraction when the number of distinct values grows with the table, such as user_id on an events table. Use a positive absolute count when it is fixed regardless of size, such as a country code.
When does an n_distinct override take effect?
At the next ANALYZE. ALTER TABLE … SET (n_distinct = …) only stores the option; the value in pg_statistic is replaced during analyze.
What is n_distinct_inherited for? It overrides the distinct count for statistics gathered across a partitioned table or inheritance tree as a whole, which the planner uses when a query targets the parent.
Related #
- Statistics and Selectivity Estimation — parent guide: where n_distinct enters the selectivity arithmetic
- HashAggregate Spill Diagnosis — reading Batches and Disk Usage when group estimates are low
- Extended Statistics for Correlated Columns — sibling: distinct counts for column combinations