Indexing Materialized Views for Dashboards #
A materialized view reduced 400 million orders to 90,000 summary rows, and the dashboard still takes 700 ms per panel. The matview has one unique index, created because REFRESH CONCURRENTLY requires one, and every panel filters by region and date range — so every panel sequentially scans 90,000 rows, eight times per page load.
Precomputing is only half the job, as noted in materialized view strategy. A materialized view is a table: it needs indexes and statistics like any other.
The Condition #
A materialized view stores its result in heap storage with no indexes beyond those you create. Three consequences:
- Queries against it are planned normally. Filters, joins and ordering need supporting indexes or they scan the whole matview.
REFRESHreplaces the contents. A plain refresh rewrites the storage and rebuilds every index on it;CONCURRENTLYupdates rows and maintains the indexes incrementally. Either way, indexes survive the refresh — they are part of the object, not the query.- Statistics do not update themselves. Autovacuum does not analyze materialized views, so the planner keeps whatever statistics existed before — often none at all after the first refresh — and falls back to default estimates.
The unique index required by REFRESH … CONCURRENTLY is chosen for the refresh’s diff, not for queries. It is usually on the full grouping key, in an order nobody filters by, and it is frequently the only index present.
Because a matview is small relative to its base tables, the cost of extra indexes is small too: the write cost is paid once per refresh rather than on every base-table write.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT day, revenue FROM mv_revenue_daily
WHERE region = 'emea' AND day >= current_date - 90
ORDER BY day;
Before:
Sort (actual time=684.2..684.4 rows=90 loops=1)
Sort Key: day
-> Seq Scan on mv_revenue_daily (cost=0.00..2104.00 rows=450 width=20)
(actual time=0.9..683.1 rows=90 loops=1)
Filter: ((region = 'emea'::text) AND (day >= (CURRENT_DATE - 90)))
Rows Removed by Filter: 89910
Buffers: shared hit=1204
Execution Time: 684.9 ms
-- rows=450 estimate is a default: the matview has never been analyzed
-- 90,000 rows scanned per panel, eight panels per page
After indexing and analyzing:
CREATE INDEX mv_revenue_daily_region_day_idx ON mv_revenue_daily (region, day) INCLUDE (revenue);
ANALYZE mv_revenue_daily;
Index Only Scan using mv_revenue_daily_region_day_idx on mv_revenue_daily
(cost=0.29..8.92 rows=88 width=20) (actual time=0.02..0.03 rows=90 loops=1)
Index Cond: ((region = 'emea'::text) AND (day >= (CURRENT_DATE - 90)))
Heap Fetches: 0
Buffers: shared hit=4
Execution Time: 0.05 ms
-- no sort: the index provides (region, day) order
Step-by-Step Resolution #
-
Collect the dashboard’s real queries. Panels usually share a few filter and ordering shapes;
pg_stat_statementsfiltered by the matview’s name shows them. -
Create one index per access shape, leading with equality columns and ending with the ordering column, adding
INCLUDEcolumns for the returned values:CREATE INDEX mv_revenue_daily_region_day_idx ON mv_revenue_daily (region, day) INCLUDE (revenue, orders); -
Keep the unique index that
REFRESH … CONCURRENTLYneeds, even if no query uses it. -
Analyze after every refresh. Add it to the refresh job:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_revenue_daily; ANALYZE mv_revenue_daily; -
Vacuum after concurrent refreshes. They leave dead rows, which also keeps
Heap Fetchesabove zero for index-only scans:VACUUM (ANALYZE) mv_revenue_daily; -
Check plans from the application’s role, since dashboards often run through a different role with different settings.
-
Re-check after the matview grows — an index that was unnecessary at 5,000 rows matters at 500,000.
Before and After #
-- BEFORE: only the refresh's unique index, never analyzed
Sort → Seq Scan on mv_revenue_daily Rows Removed by Filter: 89910 Execution Time: 684.9 ms
-- AFTER: (region, day) INCLUDE (revenue, orders) + ANALYZE
Index Only Scan Heap Fetches: 0 Buffers: shared hit=4 Execution Time: 0.05 ms
Statistics on matviews deserve special attention #
Because autovacuum ignores materialized views entirely, they are the most common place to find a table planned on defaults. The symptoms are the familiar round numbers — estimates of 0.5% of the rows, or exactly 200 groups — described in default selectivity for unestimable predicates. On a small matview queried alone, the wrong estimate rarely changes the plan. It matters when the matview is joined to other tables: a summary joined to a dimension table with a defaulted estimate can produce a nested loop over hundreds of thousands of rows.
A plain REFRESH also resets the matview’s statistics to nothing, because it rewrites the relation. A refresh job that does not analyze therefore alternates between planning on good statistics and planning on none, which shows up as plans that change shape every refresh cycle without any data change. Adding ANALYZE to the job costs seconds on a small matview and removes the whole class of problem.
How many indexes a matview can afford #
The usual objection to adding indexes — write amplification — barely applies here. A base table pays index maintenance on every insert and update, continuously; a materialized view pays it once per refresh. If the matview refreshes every ten minutes and serves eight dashboard panels, four well-chosen indexes cost four index builds per refresh (or incremental maintenance under CONCURRENTLY) and save eight full scans on every page load, by hundreds of users.
The practical limit is refresh duration. Measure it with and without each candidate index: on a small matview the difference is usually a few hundred milliseconds, which is irrelevant next to the query savings. On a large matview refreshed frequently, index rebuild time can dominate the refresh, and that is the point at which an incremental summary table — where only changed rows are written, so only those index entries are maintained — becomes the better structure.
Common Pitfalls #
Relying on the refresh’s unique index for queries. Its columns and order suit the diff, not the dashboard. Diagnostic signal: sequential scans despite an index existing. Fix: index for the query shapes.
Forgetting ANALYZE. Autovacuum never analyzes matviews. Diagnostic signal: default-looking estimates. Fix: analyze in the refresh job.
Indexing every column “just in case”. Each index is rebuilt or maintained at every refresh. Diagnostic signal: refresh time growing with index count. Fix: index the shapes that are actually queried.
Dead rows after concurrent refreshes. They inflate the matview and block index-only scans. Diagnostic signal: Heap Fetches above zero and growing size. Fix: vacuum after refresh.
Frequently Asked Questions #
Do materialized views need their own indexes? #
Yes. A materialized view is stored like a table and is planned like one, so filters, joins and ordering against it need indexes or the query scans all of its rows.
Does autovacuum analyze materialized views? #
No. They are never analyzed automatically, so the planner may use default estimates for them. Run ANALYZE explicitly, ideally as part of the refresh job.
Do indexes survive REFRESH MATERIALIZED VIEW? #
Yes. A plain refresh rebuilds them as part of replacing the contents, and a concurrent refresh maintains them incrementally. You do not need to recreate indexes after a refresh, but you do need to analyze.
Related #
- Materialized View Strategy — parent guide: precomputation decisions
- Incremental Summary Tables Instead of Full Refresh — sibling: cheaper maintenance
- Covering Indexes for ORDER BY and LIMIT — the index shape used here