Materialized View Strategy: Precompute and Index Expensive Results #

A materialized view is the one indexing tool that lets you pay an expensive aggregate or join cost once, store the result on disk, and then serve it back with a cheap indexed scan. Unlike a plain view — which is just a stored query that re-executes every time — a materialized view has its own physical storage that you refresh on a schedule. The catch that trips up most teams: PostgreSQL will never silently swap a matview in for a base-table query. You get its benefit only by querying the matview by name and indexing it like any other table.

What a Materialized View Buys You #

The core win is turning a query that costs thousands of cost units on every execution into one that costs tens. Consider a dashboard that reads daily revenue per region from a 200-million-row line_items table joined to orders. Computed live, that aggregate scans and hashes millions of rows for every page load. Precomputed into a matview keyed by (sale_date, region), the same answer is a handful of index lookups.

This makes the matview a genuine index tuning instrument rather than merely a caching convenience. You are not just storing rows — you are creating a new, much smaller relation whose grain matches your query, and then building indexes on that relation so the planner can reach individual answers without scanning the whole precomputed set.

Where a matview beats a covering index is expressiveness: an index can only reorder and cover columns of an existing table, but a matview can store the result of a GROUP BY, a multi-table join, a window function, or a distinct-count. Where it costs you is freshness — the stored result is a snapshot, correct only as of the last refresh.

The matview lifecycle #

Materialized View Lifecycle Base tables feed a REFRESH operation that computes an aggregate and writes it to the materialized view's own storage; a UNIQUE index is built on that storage; report queries reference the matview by name and use an Index Scan. Base Tables orders line_items live, always current scan + aggregate REFRESH scheduled job write Matview Storage mv_daily_revenue UNIQUE (date, region) snapshot as of refresh queried by name Report query → Index Scan on matview No auto-rewrite: a base-table query never uses the matview implicitly

The No-Auto-Rewrite Rule #

This is the single most important fact about matviews in PostgreSQL, and it separates them from features in some other databases: there is no automatic query rewrite to a materialized view. Some engines maintain “materialized view rewrite” where the optimizer detects that a submitted query is a subset of a stored matview and silently redirects it. PostgreSQL does not do this at all.

A matview is used if and only if the query names it. This query never touches mv_daily_revenue, even if that matview holds exactly this aggregate:

-- References base tables only → planner scans base tables, ignores any matview
SELECT sale_date, region, sum(amount) AS revenue
FROM   orders o
JOIN   line_items li ON li.order_id = o.order_id
GROUP  BY sale_date, region;

To benefit, the query must read the matview directly:

-- References the matview by name → planner scans matview storage
SELECT sale_date, region, revenue
FROM   mv_daily_revenue
WHERE  region = 'EU';

The practical consequence: adopting a matview is an application change, not a transparent database tweak. Every place that should benefit has to be edited to select from the matview — or wrapped behind an indirection layer, which is exactly what the query-rewrite rules for matviews page covers.

Building and Indexing a Matview #

Creation is a single statement that runs and stores the query result immediately:

CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT o.sale_date,
       o.region,
       sum(li.amount)      AS revenue,
       count(*)            AS line_count
FROM   orders o
JOIN   line_items li ON li.order_id = o.order_id
GROUP  BY o.sale_date, o.region
WITH DATA;   -- populate now; WITH NO DATA leaves it unscannable until first REFRESH

The matview is now a physical relation. To make it useful for point lookups — and to unlock CONCURRENTLY refreshes — add a unique index on its natural grain:

-- UNIQUE index is mandatory for REFRESH ... CONCURRENTLY; it also speeds point lookups
CREATE UNIQUE INDEX ux_mv_daily_revenue
  ON mv_daily_revenue (sale_date, region);

-- Optional secondary index for a common filter path
CREATE INDEX ix_mv_daily_revenue_region
  ON mv_daily_revenue (region);

The unique index does double duty. PostgreSQL needs it to identify matching rows when it diffs the old and new result sets during a concurrent refresh, and the planner uses it to reach individual (sale_date, region) rows without scanning the whole matview. Choosing the right column order here follows the same logic as B-tree index optimization on a base table — leading columns should match your equality predicates.

Refresh Cost: Plain vs CONCURRENTLY #

A matview is a snapshot; keeping it useful means refreshing it, and the two refresh modes have very different plan and locking profiles.

Plain refresh rebuilds the entire result set into new storage and swaps it in:

-- Fast write, but takes an AccessExclusiveLock: readers of the matview block until it finishes
REFRESH MATERIALIZED VIEW mv_daily_revenue;

Plain refresh re-runs the defining query, writes a fresh copy, and atomically replaces the old heap. It is the faster of the two because it writes sequentially with no diffing, but it holds an AccessExclusiveLock for the whole duration — every SELECT against the matview waits. That is fine inside a maintenance window and unacceptable for a 24/7 read workload.

Concurrent refresh computes the new result into a temporary relation, diffs it against the current contents by the unique key, and applies only the changed rows:

-- Requires the UNIQUE index; allows concurrent SELECTs; slower and generates dead tuples
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;

CONCURRENTLY takes only a light lock that permits reads throughout, so dashboards keep serving. The price is real: it builds a full temporary copy, runs a FULL OUTER JOIN-style diff, and issues INSERT/UPDATE/DELETE against the live matview — more I/O, longer wall-clock time, and dead tuples that autovacuum must later reclaim. The detailed model of that trade-off lives in REFRESH CONCURRENTLY cost analysis.

Aspect Plain REFRESH REFRESH CONCURRENTLY
Lock held AccessExclusiveLock (blocks reads) Light lock (reads allowed)
Unique index Not required Required
Write pattern Rebuild + atomic swap Build temp + diff + apply
Speed Faster Slower (extra I/O)
Bloat Minimal Dead tuples → vacuum
Best for Maintenance window Continuous read traffic

Reading a Matview Scan in EXPLAIN #

Because a matview has its own heap and indexes, it appears in EXPLAIN as an ordinary scan node — there is no dedicated matview operator. Here is a lookup that uses the index:

EXPLAIN (ANALYZE, BUFFERS)
SELECT sale_date, revenue
FROM   mv_daily_revenue
WHERE  region = 'EU'
  AND  sale_date >= '2026-06-01';
Index Scan using ux_mv_daily_revenue on mv_daily_revenue
    (cost=0.29..24.60 rows=42 width=20)
    (actual time=0.021..0.088 rows=40 loops=1)
  Index Cond: ((sale_date >= '2026-06-01'::date) AND (region = 'EU'::text))
  Buffers: shared hit=6
  -- ① cost= is a unitless planner metric; actual time= is wall-clock ms
  -- ② node names the matview relation directly — it is scanned like a table
  -- ③ rows estimate (42) tracks actual (40) → matview statistics are fresh
  -- ④ 6 buffers, all cache hits: the precomputed answer is tiny vs the base scan

Contrast that with the live base-table aggregate the matview replaces, which hashes millions of rows before it can answer. The whole point of the strategy is to move from a large HashAggregate over base tables to this small Index Scan over stored results. Whether you get the index scan or fall back to a Seq Scan depends on the matview’s own statistics — the subject of when the planner chooses a matview scan.

Step-by-Step Adoption Workflow #

Follow these steps to convert a repeated expensive aggregate into an indexed, refreshable matview.

Step 1 — Identify an expensive repeated aggregate

Find the query using pg_stat_statements: high calls, high total_exec_time, and a stable shape (the same GROUP BY grain every time).

SELECT query, calls, round(total_exec_time) AS total_ms, round(mean_exec_time) AS mean_ms
FROM   pg_stat_statements
WHERE  query ILIKE '%group by%sale_date%'
ORDER  BY total_exec_time DESC
LIMIT  10;

Step 2 — Create the matview and a unique index

CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT o.sale_date, o.region, sum(li.amount) AS revenue, count(*) AS line_count
FROM   orders o JOIN line_items li ON li.order_id = o.order_id
GROUP  BY o.sale_date, o.region
WITH DATA;

CREATE UNIQUE INDEX ux_mv_daily_revenue ON mv_daily_revenue (sale_date, region);

Step 3 — Schedule a concurrent refresh

Drive REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue; from cron, pg_cron, or your job scheduler at whatever interval your staleness tolerance allows.

Step 4 — Point queries at the matview

Edit each report and application query that computed this aggregate live to select from mv_daily_revenue instead. Remember: nothing happens automatically — the query must name the matview.

Step 5 — ANALYZE the matview after refresh

-- Autovacuum does analyze matviews, but its timing lags; do it explicitly after refresh
ANALYZE mv_daily_revenue;

Step 6 — Verify an Index Scan

EXPLAIN (ANALYZE, BUFFERS)
SELECT sale_date, revenue FROM mv_daily_revenue
WHERE region = 'EU' AND sale_date >= '2026-06-01';
-- Confirm: Index Scan using ux_mv_daily_revenue, not Seq Scan; rows estimate ≈ actual.

Common Pitfalls #

Expecting automatic rewrite. The most frequent mistake is creating a matview and assuming existing base-table queries will speed up. They will not — PostgreSQL never substitutes a matview for a base-table query. Every consumer must be redirected explicitly.

CONCURRENTLY without a unique index. REFRESH MATERIALIZED VIEW CONCURRENTLY fails outright with cannot refresh materialized view "..." concurrently unless a unique index covering every row exists. Create the unique index first; if the grain has duplicates, the matview needs a different key.

Stale-data window. Between refreshes the matview is a snapshot. Consumers must tolerate data that is as old as your refresh interval. For anything that must be exactly current, keep reading the base tables.

Missing ANALYZE after refresh. A refresh replaces the matview’s contents but leaves its planner statistics describing the old data until autovacuum catches up. Run ANALYZE on the matview as the last step of every refresh job, or the planner may pick a Seq Scan over your carefully built index.

Bloat from CONCURRENTLY. Because concurrent refresh applies row-level INSERT/UPDATE/DELETE, it leaves dead tuples behind on every run. On a frequently refreshed matview this bloat accumulates; ensure autovacuum keeps up or the storage and scan cost creep upward over time.

Refresh storms. Scheduling several heavy matviews to refresh at the same instant, or refreshing far more often than the data changes, can saturate I/O and CPU. Stagger refresh jobs and match the interval to the real rate of change in the base tables.

Frequently Asked Questions #

Will PostgreSQL automatically use a materialized view for a matching base-table query? #

No. PostgreSQL has no automatic query rewrite to materialized views. A query only reads a matview if it references that matview by name. If you SELECT from the base tables, the planner scans the base tables and never substitutes the precomputed result, no matter how closely the shapes match. See when the planner chooses a matview scan for the details.

Do I need a unique index to refresh a materialized view? #

Only for REFRESH MATERIALIZED VIEW CONCURRENTLY. Plain REFRESH works without any index but takes an AccessExclusiveLock that blocks readers. CONCURRENTLY requires at least one UNIQUE index covering all rows so PostgreSQL can diff old and new rows, and in exchange it allows concurrent reads during the refresh.

Why does my query still ignore the matview index and do a Seq Scan? #

A matview is scanned from its own statistics, and those statistics go stale the moment you refresh it. If you have not run ANALYZE on the matview since the last refresh, the planner may misjudge selectivity and choose a Seq Scan over the index. Run ANALYZE on the matview after every refresh. The cost estimation models page explains how those statistics feed the scan decision.

Does a matview show up in EXPLAIN differently from a table? #

No. Because a matview has its own physical storage, EXPLAIN shows an ordinary Seq Scan or Index Scan node naming the matview relation, exactly as it would for a table. There is no special MaterializedView node.


Up: Index Tuning & Strategy