Using PostgreSQL Materialized Views to Speed Up Repeated Aggregations
Learn how PostgreSQL materialized views can turn expensive repeated aggregations into fast index‑only reads, with steps to create, query, refresh, and verify the improvement.
24 Jul 2025, 15:45 UTC

The problem: costly repeated scans on a fact table
Imagine a dashboard that shows daily sales totals. The underlying sales fact table holds tens of millions of rows, and the query that powers the widget is:
SELECT sale_date, SUM(amount) AS total_sales
FROM sales
GROUP BY sale_date
ORDER BY sale_date;
Running this query every few minutes forces PostgreSQL to scan the entire table, consuming CPU and I/O and adding latency for users.
Takeaway: materialized views turn the scan into a cheap read
A materialized view stores the result of that aggregation as a physical table. Querying the view reads only the pre‑computed rows, which can be served with an index‑only scan. Refreshing the view updates the stored data, and you can choose how often to refresh based on your freshness requirements.
When to consider a materialized view
- The base query is executed frequently with little or no change in its WHERE clause.
- The underlying data changes relatively slowly compared to the query frequency.
- You can tolerate a brief period of staleness between refreshes.
Creating the view
You need CREATE privilege on the schema and SELECT on the source table. Run this as a role that has those rights:
CREATE MATERIALIZED VIEW sales_daily AS
SELECT sale_date,
SUM(amount) AS total_sales
FROM sales
GROUP BY sale_date;
-- Enable fast look‑ups by date
CREATE UNIQUE INDEX sales_daily_sale_date_idx ON sales_daily (sale_date);
The unique index is required if you later want to refresh concurrently.
Querying the view
Now the dashboard can query the view instead of the base table:
SELECT * FROM sales_daily WHERE sale_date = CURRENT_DATE;
With the index in place, PostgreSQL will typically show an Index Only Scan on sales_daily_sale_date_idx in EXPLAIN ANALYZE output, meaning it reads only the index pages.
Refresh strategies
Two refresh modes are available:
- Blocking refresh – simple and fast for small views:
REFRESH MATERIALIZED VIEW sales_daily;
While refreshing, the view is locked; concurrent SELECT statements wait until the refresh finishes.
- Concurrent refresh – allows reads to continue (requires PostgreSQL 9.4+ and a unique index):
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_daily;
This builds a new version of the view in parallel and swaps it in, but it does extra work (roughly 20‑30 % more CPU) and can fail if the unique index is missing or if there are duplicate keys.
Verifying the benefit
Follow these steps to confirm the improvement in your environment:
- Baseline – run the original aggregation with
EXPLAIN ANALYZEand note the execution time and rows scanned. - Create the view as shown above.
- Query the view with the same
EXPLAIN ANALYZE; you should see a dramatically lower execution time and an index‑only scan. - Measure refresh cost – time a
REFRESH MATERIALIZED VIEW(orCONCURRENTLY) and ensure it fits inside your maintenance window. - Check freshness – after a refresh, query
pg_stat_user_tablesfor the view’slast_vacuumandlast_analyzetimestamps, or compareMAX(sale_date)in the view to the base table to see lag.
Trade‑offs and limitations
- Storage overhead – the view duplicates the aggregated data; size is roughly the number of distinct groups times the width of the aggregated columns.
- Staleness risk – if source data changes every few seconds, frequent refreshes may erase the performance gain.
- Concurrent refresh overhead – requires a unique index and extra CPU; monitor
pg_stat_progress_create_indexif you suspect long‑running refreshes. - Version note –
REFRESH ... CONCURRENTLYis available from PostgreSQL 9.4; older versions only support the blocking form.
Actionable closing
Start by capturing the current cost of your repetitive aggregation with EXPLAIN ANALYZE. If the query scans more than a few hundred thousand rows each run, create a materialized view as shown, add a unique index on the grouping columns, and test both blocking and concurrent refreshes. Choose the refresh interval that keeps the view’s lag within your service‑level agreement while keeping refresh CPU usage below a threshold you can afford (e.g., < 5 % of total CPU during off‑peak hours). Periodically re‑run the baseline check to ensure the view still provides a net benefit as data volumes evolve.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.