Views, materialized views and derived data
Materialized views and refreshing
The idea in 30 seconds
A materialized view runs its query once and stores the result like a table, so reading it is almost free. The price is freshness: it shows the data as of the last refresh, and every refresh runs the full query again. A plain refresh also locks readers out while it runs; a concurrent refresh does not, if the view has a unique index.
Helps to know first: Aggregation and grouping, Joins and relational querying
On this page
Storing the answer
A view reruns its query every time. When the query is expensive and the answer changes slowly, like a monthly revenue report, that is wasted work. A materialized view runs the query once and stores the result:
CREATE MATERIALIZED VIEW monthly_revenue_mv AS
SELECT date_trunc('month', o.ordered_at) AS month, p.category,
sum(i.quantity * i.unit_price) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN products p ON p.id = i.product_id
GROUP BY 1, 2;
To get new data into it you ask explicitly:
REFRESH MATERIALIZED VIEW monthly_revenue_mv;
A refresh throws the old contents away and runs the whole query again.
What it buys and what it costs
We built a bigger version of the shop: one million orders with 2.5 million order lines (196 MB), and the report above, which has 72 rows (12 months, 6 categories). Medians of five runs, PostgreSQL 18.3 on a laptop:
| Operation | Time |
|---|---|
| Report as a plain view, whole year | 1,665 ms |
| Report as a plain view, one month | 158 ms |
| Report from the materialized view, whole year | 0.01 ms |
REFRESH MATERIALIZED VIEW | 1,652 ms |
REFRESH MATERIALIZED VIEW CONCURRENTLY | 1,652 ms |
Reading became essentially free. But every refresh costs as much as running the report once, and that grows with the tables, not with how much changed.
Stale between refreshes
PredictA new sale
A 50,000.00 Audio sale is recorded in June. Without refreshing, what does June’s Audio revenue show in the plain view, and in the materialized view?
The view has it; the materialized view does not.
source | revenue
-------------------+--------------
view | 847157123.16
materialized view | 847107123.16The materialized view is exactly 50,000.00 short until the next refresh. Nothing warns you: it returns the old answer as confidently as the new one.
Refreshing without locking readers out
A plain refresh takes the strongest table lock there is, ACCESS EXCLUSIVE, which blocks even SELECT. We started a refresh and, 0.3 seconds later, a simple query on the materialized view:
| Refresh running | Reader waited |
|---|---|
REFRESH MATERIALIZED VIEW | 1,423 ms |
REFRESH MATERIALIZED VIEW CONCURRENTLY | 1 ms |
CONCURRENTLY refreshes without locking readers out: it holds a weaker lock that still allows SELECT. It needs two things:
- A unique index on the materialized view, on plain columns, covering every row: here
CREATE UNIQUE INDEX ON monthly_revenue_mv (month, category);. - The view must already be populated: the very first fill cannot be concurrent.
It is not free either: it still runs the full query. The documentation says a plain refresh tends to use fewer resources when many rows change, and a concurrent one may be faster when few do; in our test the two took the same time.
Reproduce the measurements
The data generator we used, on a throwaway database:
SELECT setseed(0.42);
CREATE TABLE products (id int PRIMARY KEY, category text NOT NULL);
INSERT INTO products
SELECT g, (ARRAY['Accessories','Audio','Displays','Furniture','Storage','Video'])[1 + g % 6]
FROM generate_series(1, 600) g;
CREATE TABLE orders (id int PRIMARY KEY, ordered_at timestamptz NOT NULL);
INSERT INTO orders
SELECT g, timestamptz '2025-01-01' + random() * interval '365 days'
FROM generate_series(1, 1000000) g;
CREATE TABLE order_items (
order_id int NOT NULL REFERENCES orders, product_id int NOT NULL REFERENCES products,
quantity int NOT NULL, unit_price numeric(10,2) NOT NULL);
INSERT INTO order_items
SELECT o, 1 + (random() * 599)::int, 1 + (random() * 3)::int, (100 + random() * 20000)::numeric(10,2)
FROM generate_series(1, 1000000) o, LATERAL generate_series(1, 1 + (random() * 3)::int + 0 * o) n;
VACUUM ANALYZE;
The + 0 * o matters: it makes the number of lines random per order. Without it, PostgreSQL evaluates the inner random() once and every order gets the same number of lines, which is what happened on our first try.
Check yourself
What is the difference between a view and a materialized view in what they store and when they do the work?
Show answer
A view stores only its query and runs it on every use. A materialized view stores the query’s result and runs the query only when refreshed.
Why did readers wait 1.4 seconds during a plain refresh but not during a concurrent one?
Show answer
A plain refresh takes an
ACCESS EXCLUSIVElock, which conflicts with every other lock, including the one aSELECTneeds.CONCURRENTLYtakes anEXCLUSIVElock, which still allows reads.What must exist before
REFRESH MATERIALIZED VIEW CONCURRENTLYwill run?Show answer
A unique index on the materialized view that uses only column names and covers all rows, and the view must already have been populated once.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 39.3 Materialized ViewsPrimary· §39.3 Materialized Views
A materialized view persists its query's result in table-like form and is brought up to date with REFRESH MATERIALIZED VIEW.
- 2.PostgreSQL documentation: REFRESH MATERIALIZED VIEWPrimary· Description, Parameters, Notes
A refresh completely replaces the contents; without CONCURRENTLY it can block readers; CONCURRENTLY needs a unique index on plain column names covering all rows and an already populated view; only one refresh runs at a time per view.
- 3.PostgreSQL documentation: 13.3 Explicit LockingPrimary· §13.3.1 Table-Level Locks
REFRESH MATERIALIZED VIEW takes an ACCESS EXCLUSIVE lock, which blocks SELECT; with CONCURRENTLY it takes an EXCLUSIVE lock, which still allows reads.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: REFRESH MATERIALIZED VIEWPrimary
The whole page is short, and every sentence in it matters if you run refreshes in production.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.