Repository navigation
No index bloat estimate for PostgreSQL, where the remedy is the safe one #2561
Description
Part of #2530. Deferred out of #2542 rather than dropped — the mechanism, the options and the measurement that should decide are below.
The gap
#2542 ships a heap bloat estimate (pg_table_bloat_stats, schema v85). Index bloat is not covered, and on PostgreSQL it is arguably the more actionable half:
- The remedy is much safer.
REINDEX INDEX CONCURRENTLYrebuilds an index without blocking reads or writes.VACUUM FULLon a heap takes anACCESS EXCLUSIVElock for the entire rewrite. So a bloated index is a finding somebody can act on in business hours, and a bloated heap usually is not. - B-tree indexes bloat differently from heaps and often worse. A heap reuses free space through the free space map; a btree page that empties is only reclaimed when the whole page is empty, so a churning index on a monotonically-increasing key can hold a large share of dead space indefinitely without the heap looking bad at all.
pg_index_usage_stats(No index usage collection for PostgreSQL, where an unused index costs more than on SQL Server #2541, v84) already collectspg_relation_size(indexrelid)per index, so the measured side is already stored. What is missing is the expected size to compare it against.
The mechanism
The btree estimator is a different query from the heap one, not a parameterisation of it. It needs, per index:
pg_index.indexrelid→ the indexed columns (indkey, pluspg_get_expr(indexprs, ...)for expression indexes)pg_stats.avg_widthandnull_fracfor each of those columns on the parent table- the btree tuple overhead: an index tuple header plus the item pointer, and the fill factor (
reloptions, default 90 for btree, not 100) pg_class.relpagesfor the index itself
...producing an expected leaf-page count to compare against relpages. The published version of this is ioguix's btree_bloat.sql, which is the sibling of the table query #2542 adapted.
What has to be decided by measurement, not reasoning
#2542 established that this class of estimator can be catastrophically wrong in ways the arithmetic cannot see, so the same evidence is needed here before anything ships:
- Accuracy against ground truth.
pgstatindex()(frompgstattuple) reportsavg_leaf_densityandleaf_fragmentationfor a btree. Compare the estimate against it across index shapes: narrow int keys, wide text keys, multi-column, partial, expression, and aDESC/NULLS FIRSTordering. The estimate must be measured on a REINDEXed index too — an index that has just been rebuilt is the known-good case, and an estimator that reports 30% bloat on a freshly-reindexed index is unusable at any accuracy. - The same permissions trap.
pg_statsis filtered by SELECT privilege andpg_monitordoes not confer it, so apg_monitor-only role sees no column widths and this estimator will return confident nonsense exactly as the heap one did (measured in No bloat measurement for PostgreSQL: we collect the whole cause chain and none of the damage #2542: 88.59% reported for a table whose true bloat is 0.50%). Whatever ships needs the sameestimate_unavailablesuppression, not a caption. - Expression indexes may be unmodellable.
pg_statscarries widths for an expression index's own statistics under the INDEX's name rather than the table's, and only if the index has been analyzed. Worth measuring whether that is reliably present; if not, expression indexes should be explicitly excluded rather than estimated badly. - Fan-out cost. This would be per-index on top of an existing per-index collector. The cheapest shape is almost certainly columns on
pg_index_usage_statsrather than a new table — the collector is already readingpg_indexand already paying the fan-out — but that adds a rung and widens a table that is already 24 payload columns, so it is a decision rather than an obvious win.
Notes
- Cadence should probably follow
pg_index_usage_stats(daily) rather thanpg_table_bloat_stats(hourly): index bloat accumulates over days, and the two would then share one query. avg_leaf_densityfrompgstatindexis a density, not a bloat percentage —100 - avg_leaf_densityis not directly comparable to the page-count ratio the estimator produces, and conflating them would produce a wrong accuracy claim. Whoever does this should state which quantity they are comparing.- Whatever ships must not recommend
REINDEXon the strength of an unconfirmed estimate any more than No bloat measurement for PostgreSQL: we collect the whole cause chain and none of the damage #2542 recommendsVACUUM FULL— same rule, same reason.
Activity
erikdarlingdata commentedon Aug 24, 2026
Measured against
pgstatindexground truth on PostgreSQL 17. This is the evidence this issue said had to exist before anything ships, and it changes what should be built.Rig: six real btrees with genuine churn (delete 60-80% of rows, then
VACUUM), one of them freshlyREINDEXed as the known-good control.Accuracy, by index shape
pgstatindexfree %ix_narrow_intix_wide_textix_descDESC NULLS FIRSTix_multiix_partialix_exprThe REINDEXed control passes. This issue set the bar at "an estimator that reports 30% bloat on a freshly-reindexed index is unusable"; it reports 6.9%. Worth noting why that is not zero and should not be: fillfactor is 90, so a fresh btree is ~90% dense and ~10% free is normal. Any read built on this must not present that 10% as bloat.
Partial indexes are unmodellable as written. The estimate uses
pg_class.reltuplesfor the parent table, but a partial index contains only the rows matching its predicate — so expected pages are computed for 200k rows when the index holds 20k. Result: -56%.Expression indexes are worse, and for a different reason.
pg_statscarries no row forlower(b)under the table's column names, soavg_widthfalls back to the default and expected pages explode. Result: -296%. This confirms the suspicion in the issue body: they should be excluded explicitly, not estimated badly.Both failures present as NEGATIVE bloat — the estimator claiming the index is smaller than it should be, which is nonsense on its face and is the cheap signal for suppression.
The permission trap, confirmed for indexes too
Same shape as #2542 measured on the heap estimator, and if anything starker:
Every estimate for that role becomes garbage, and confidently so:
pg_monitordoes not confer the SELECT privilegepg_statsfilters on. So a least-privilege monitoring login gets no column widths and the estimator produces numbers rather than an error — exactly the failure #2542 hit at 88.59% for a table whose true bloat was 0.50%.What this means for the build
indpred IS NOT NULL) and expression (indexprs IS NOT NULL) indexes by predicate rather than trying to model them.pg_statsrow, the answer isestimate_unavailablewith the grant to fix it — the same suppression No bloat measurement for PostgreSQL: we collect the whole cause chain and none of the damage #2542 shipped. A negative result is a second, independent tripwire and should also suppress.pg_index_usage_stats' query, as the issue suggests — nothing measured here argues against it.What I have not done
I stopped at the measurement rather than shipping the collector. The accuracy question this issue gated on is now answered, and the answer is "yes, but only for a specific subset, and only when statistics are visible" — which is a materially different collector from the one the issue imagined, so it is worth being explicit before building it rather than after.
Rig teardown done; nothing left running.
erikdarlingdata commentedon Aug 25, 2026
Ran all four measurements this issue says must decide it. Three confirm what it suspected; the fourth inverts the design.
Rig: PostgreSQL 17, a 400k-row table with seven index shapes (int key, wide text, multi-column, partial, expression,
DESC NULLS FIRST, and the pkey), churned with updates and deletes, plus a 3M-row table for cost.1. Ground truth, and the density trap this issue flagged in its notes
Freshly built, zero bloat by construction:
So
100 - avg_leaf_densityis ≈ 10% on a PERFECT index. The note was right to warn about it, and now there is a number: conflating density with bloat gives every index in the fleet a ~10% false bloat floor. It is also not a constant — after REINDEX the same indexes landed between 87.07 and 90.81, so "subtract from 90" is not a fix either.After churn, the same indexes: density 38.8–51.6, fragmentation ~50, and
t_pkeyat 2196 pages against 943 afterREINDEX— a true bloat of 57%. That is the ground truth any estimator has to be measured against.2. The permissions trap reproduces exactly
Confirmed for the parent table AND for the expression index's own stats row. Meanwhile
pg_class.relpagesstays readable. So the estimator keeps its denominator and loses its numerator, which is precisely the shape that produced #2542's 88.59%-against-a-true-0.50%.It is cleanly detectable, which is what matters for suppression:
has_table_privilege('t','SELECT')returns false and the stats-row count is 0, both readable by the monitoring role itself. Soestimate_unavailableis implementable without guessing.3. Expression indexes ARE modellable — this issue's suspicion does not hold
pg_statsdoes carry the width, under the index's name rather than the table's:and it was present after an ordinary
ANALYZEof the parent table, with no separate analyze of the index. So they should be included rather than excluded — subject to §2, since that row is hidden by the same privilege filter.4. The finding that changes the design:
pgstatindexworks forpg_monitorpg_monitorincludespg_stat_scan_tables, and pgstattuple grants EXECUTE on its functions to that role. So under exactly the permission set where the estimator goes blind, the exact function still works.That reframes the whole issue. The estimator exists to avoid a full index scan — but it is unusable for a
pg_monitorrole, and apg_monitorrole is what we run as.The cost, measured deterministically rather than by clock
pgstatindexon a 491 MB / 62,840-page index touched exactly 62,840 blocks — measured as apg_statio_all_indexesdelta afterpg_stat_reset(). It reads every page of the index, once, and that is the honest cost statement.I am deliberately not quoting the wall-clock numbers I also collected: they ranged 33–115 ms across runs in a direction that made no sense (the "cold" run after a restart was faster than the warm one), because the host page cache survives a container restart. The block count is exact; the timings from this rig are not evidence.
Recommendation
Prefer
pgstatindexwhere pgstattuple is present; estimate only where it is not. Concretely:pgstatindex, givingavg_leaf_densityandleaf_fragmentation. Works under our permissions, needs nopg_stats, and models partial/expression/multi-column indexes without special cases because it counts real pages rather than modelling them.pg_index_usage_statsquery, as proposed.pg_statsis readable. When neither holds, emit the miss rather than a number.REINDEXoff an estimate, per this issue's own last note. Offpgstatindexit is a defensible recommendation, because that is a measurement.One thing I could not check
Whether pgstattuple is available on Aurora — the AWS SSO session expired mid-session and re-authenticating is interactive. That check decides whether the exact path is the primary or the fallback on this fleet, so it should happen before anyone writes code. Authenticate to AWS, then read the extension list for
aurora-postgresql.Merge pull request #2598 from erikdarlingdata/feature/2561-pg-index-b…
erikdarlingdata commentedon Aug 25, 2026
Shipped in #2598 as schema v94 — measured, not estimated, which inverts what this issue proposed and for a reason worth recording.
The issue's plan was to port the ioguix btree estimator off
pg_statscolumn widths. Measured, that is the one route that cannot work for the role this product runs as:pg_statsreturns ZERO rows to apg_monitor-only login, because the view filters onhas_column_privilege— the same trap that produced #2542's 88.59% against a true 0.50%. Meanwhilepgstatindexdoes run forpg_monitor, because pgstattuple grants EXECUTE topg_stat_scan_tables. So under our exact permissions the estimator is blind and the exact function works.Answering this issue's four measurement questions directly:
100 - avg_leaf_densityinvents ~10 points of bloat on a perfect index, and there is no constant to subtract. Density is stored raw; the reclaimable estimate is computed against a 90% floor and shown beside it, never instead of it.pgstatindexcounts real pages, so partial, expression, multi-column and DESC/NULLS FIRST indexes need no special handling at all. What does need handling is the index method: it RAISES on GIN, BRIN and hash, so one of those would fail the whole collection every cycle. Btrees only, filtered behind anOFFSET 0fence.relpagesblocks; 62,840 for a 491 MB index), so daily cadence and a 20 GB ceiling. Nothing is silently capped: an oversized index still gets a row with a stated reason and sorts first, because unknown is not zero and it is the likeliest big win.Also taking the issue's own last note seriously — the read ranks by reclaimable bytes, never density, because a 64 KB index at 20% tops a density-ranked list and is worth 50 KB next to a 10 GB index at 45% worth 5.37 GB.
Availability is now a first-class answer rather than a guess: #2588's
pg_extension_availabilityreports whether pgstattuple is installed, available-and-one-command-away, or absent.erikdarlingdata commentedon Aug 25, 2026
Availability confirmed on real Aurora, which upgrades this from a defensive design to the primary path on this fleet.
The collector was written to fail gracefully if pgstattuple were absent. Measured on the
aurora-target-aAurora cluster via #2588's extension collector:So exact index bloat is reachable on Aurora with one
CREATE EXTENSION— it is not a self-hosted-only nicety, and the estimator fallback stays the fallback rather than becoming the real answer here.Nothing to change; recording it so nobody re-opens the availability question later.
erikdarlingdata commentedon Aug 25, 2026
Correcting my earlier comment on this issue, and it is good news.
I reported
pgstattupleas available on the Aurora target — installable, oneCREATE EXTENSIONaway. That came frompg_extension_availability, which readspg_extensionfrom a single connection.pg_table_bloat_stats, which runs per database, reportspgstattuple_available = truefor all 361 tables it measured — i.e.pg_extensionhas apgstattuplerow in the database those tables live in.So
pgstattupleis already installed in the application database on that cluster. The exact-measure path in this collector works there today with no install step; it is the primary path, not a fallback needing provisioning first.The disagreement between those two readings is itself a defect — filed as #2599.
pg_extensionis per-database andpg_extension_availabilityrecords nodatabase_name, so itsinstalled/availableanswer depends on which database the target is configured for and nothing in the row says which. Until #2599 lands, trust the per-database collectors' extension readings overpg_extension_availability's for any "can I use this here" question.Correct the index-bloat grant claims: there is no statistics-based es…
Document the PostgreSQL grant and extensions that decide whether a co…
Retire the census prose everywhere, not just where the diff touched
Estimate PostgreSQL index bloat from statistics instead of walking ev…