Expression Statistics for Computed Predicates #
WHERE date_trunc('day', created_at) = '2026-09-16' is estimated at 50,000 rows on a 10-million-row table and returns 312,000. WHERE lower(email) = '[email protected]' is estimated at the same 50,000 and returns one. Both estimates are 0.5% of the table — a constant, not a measurement — because the planner has statistics for created_at and email, but not for anything computed from them.
How estimates are built from column statistics is described in statistics and selectivity estimation. This page covers giving the planner statistics for expressions.
The Planner Condition #
ANALYZE collects statistics for columns. When a predicate compares an expression — a function call, a cast, arithmetic, COALESCE — the planner looks for statistics describing that exact expression. There are two places it can find them:
- Expression indexes. When a table has an index on an expression,
ANALYZEalso samples that expression and stores statistics for it, keyed to the index. The planner uses them for any predicate that matches the indexed expression, whether or not the index itself is chosen for the scan. - Extended statistics on expressions (PostgreSQL 14+).
CREATE STATISTICS name ON (expression) FROM tablegathers MCV lists, histograms and distinct counts for an expression without building an index. Multi-expression objects can also capture correlation between expressions and columns.
Without either, the planner falls back to fixed default selectivities — 0.5% for equality, one third for inequality — described in default selectivity for unestimable predicates. Those defaults are wrong for almost every real expression: lower(email) is nearly unique, date_trunc('day', …) has a few hundred distinct values, and a boolean expression may be true for 99% of rows.
The expression must match exactly as the planner normalizes it: same function, same arguments, same constants, same collation. date_trunc('day', created_at) statistics do not help created_at::date.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE)
SELECT count(*) FROM signups WHERE date_trunc('day', created_at) = '2026-09-16';
Aggregate (actual time=1804.2..1804.2 rows=1 loops=1)
-> Seq Scan on signups (cost=0.00..244120.00 rows=50000 width=0)
(actual time=812.4..1780.6 rows=312044 loops=1)
Filter: (date_trunc('day'::text, created_at) = '2026-09-16 00:00:00+00'::timestamp with time zone)
Rows Removed by Filter: 9687956
-- rows=50000 = 10,000,000 × 0.005: the default equality selectivity
After adding expression statistics:
CREATE STATISTICS signups_created_day ON (date_trunc('day', created_at)) FROM signups;
ANALYZE signups;
-> Seq Scan on signups (cost=0.00..244120.00 rows=309870 width=0)
(actual time=810.2..1776.1 rows=312044 loops=1)
-- rows=309870: estimate now comes from the expression's MCV list
Inspect what was collected:
SELECT expr, n_distinct, most_common_vals[1:3], most_common_freqs[1:3]
FROM pg_stats_ext_exprs WHERE statistics_name = 'signups_created_day';
expr | n_distinct | most_common_vals | most_common_freqs
-------------------------------------+------------+-------------------------------------+-------------------
date_trunc('day'::text, created_at) | 412 | {"2026-09-16 00:00:00+00", …} | {0.0312, …}
Step-by-Step Resolution #
-
Spot default estimates. Divide
rows=by the table’s row count. Exactly 0.005, 0.333 or 0.5 strongly suggests a default. -
Decide between an index and a statistics object. If queries need to find rows by the expression, an expression index gives both an access path and statistics. If the expression only needs accurate estimates — for join sizing, or because the filter is not selective —
CREATE STATISTICSavoids index maintenance cost.-- access path + statistics CREATE INDEX CONCURRENTLY users_lower_email_idx ON users (lower(email)); -- statistics only (PostgreSQL 14+) CREATE STATISTICS signups_created_day ON (date_trunc('day', created_at)) FROM signups; -
Analyze the table. Neither source has statistics until
ANALYZEruns. -
Verify the statistics exist. Expression index statistics appear in
pg_statswithtablenameset to the index name; extended expression statistics inpg_stats_ext_exprs:SELECT tablename, attname, n_distinct FROM pg_stats WHERE tablename = 'users_lower_email_idx'; -
Match the query text to the expression. Normalise application code so every query uses the same form — for example always
date_trunc('day', created_at)rather than a mix withcreated_at::date. -
Combine with correlated columns when needed:
CREATE STATISTICS … (mcv) ON (date_trunc('day', created_at)), country FROM signupsrecords how the expression co-varies with another column, following extended statistics for correlated columns.
Before and After #
-- BEFORE: no expression statistics
Seq Scan on signups (rows=50000) (actual rows=312044) -- joins above planned for 50k rows
-- AFTER: CREATE STATISTICS ON (date_trunc('day', created_at)); ANALYZE
Seq Scan on signups (rows=309870) (actual rows=312044) -- joins above planned correctly
Why an accurate estimate matters when the plan does not change #
In the example the scan itself stayed a sequential scan: at 3% selectivity there was no better access path. The payoff comes above it. A report that joins these signups to users and sessions was planned for 50,000 rows and chose nested loops with index probes; with 312,000 rows it now chooses hash joins and runs several times faster. Estimate errors at the leaves propagate upward, as described in spotting row estimate explosions, so fixing a leaf estimate is worthwhile even when the leaf’s own node is unchanged.
The opposite case is the lower(email) lookup. There the default estimate is too high by a factor of 50,000, which can make the planner reject an index scan in favour of a sequential or bitmap scan and choose hash joins where a single nested-loop probe would do. Because that query also needs an access path, an expression index is the right fix, and its statistics come for free.
Keeping expression statistics cheap #
Each expression added to a statistics object is sampled on every ANALYZE of the table, so objects should contain only expressions that actually appear in predicates or grouping clauses. pg_stat_statements is a good source for that list: search normalized query texts for function calls applied to columns in WHERE and GROUP BY. Expressions that appear only in the SELECT list do not need statistics at all, because the planner never estimates selectivity for them.
Common Pitfalls #
Creating the object and not analyzing. The planner keeps using defaults. Diagnostic signal: no row in pg_stats_ext_exprs. Fix: ANALYZE table.
Expression mismatch. created_at::date does not match date_trunc('day', created_at). Diagnostic signal: default estimate persists despite statistics. Fix: use one canonical expression everywhere.
Time zone dependent expressions. date_trunc('day', timestamptz) depends on the session TimeZone, so statistics gathered under one zone describe different day boundaries under another. Diagnostic signal: estimates that shift between application and batch sessions. Fix: use the three-argument date_trunc('day', created_at, 'UTC') (PostgreSQL 12+) in both the query and the statistics definition.
Adding an index only for statistics. Index maintenance costs every write. Diagnostic signal: an expression index with idx_scan = 0. Fix: replace it with CREATE STATISTICS on the expression.
Frequently Asked Questions #
Why is my estimate exactly 0.5% of the table? #
That is PostgreSQL’s default selectivity for an equality comparison it has no statistics for. It usually means the predicate compares an expression — a function or cast applied to a column — rather than the bare column.
Do expression indexes provide statistics even when not used? #
Yes. ANALYZE samples the indexed expression and stores statistics for it, and the planner uses those statistics to estimate any predicate matching the expression, regardless of whether it chooses the index for the scan.
What is the difference between CREATE STATISTICS on an expression and an expression index? #
Both give the planner statistics for the expression. An expression index also provides an access path and costs maintenance on every write; a statistics object provides estimates only, with no write overhead beyond ANALYZE.
Related #
- Statistics and Selectivity Estimation — parent guide: how estimates are built
- Extended Statistics for Correlated Columns — sibling: multi-column statistics objects
- Function-Wrapped Columns and Sargability — the access-path side of the same problem