Precomputing results with materialized views
Open in the interactive app: practice, quiz, mock interview →In one minute
- A materialized view stores the result of a query as a physical table, so reads skip the expensive joins and aggregations.
- The core problem is freshness: the view is a copy, and something must keep it in step with the base tables.
- Two maintenance styles: full refresh (recompute everything on a schedule) and incremental maintenance (apply each base-table change as a delta).
- Use them for expensive, repeated, read-heavy queries where bounded staleness is acceptable.
How it works
View versus materialized view
A regular view is a stored query; every read re-executes it. A materialized view runs the query once and persists the rows. You can index it, and reads cost the same as reading any table. The database (or your pipeline) owns the copy, which is the difference from hand-rolled denormalization.
Full refresh
The simplest strategy reruns the defining query and swaps in the result. In PostgreSQL, REFRESH MATERIALIZED VIEW takes a lock that blocks readers during the rebuild; REFRESH ... CONCURRENTLY builds a new copy, diffs it against the old one, and applies changes, so readers continue but the refresh takes longer. It needs a unique index on plain columns with no WHERE clause, works only on a view that is already populated, and still allows just one refresh at a time per view (Postgres docs). Cost is proportional to the base data, not to what changed. A 30-minute refresh of a 500 GB fact table every hour burns half your I/O to update maybe 1% of rows.
Incremental maintenance
Incremental view maintenance (IVM) treats each insert, update, or delete on a base table as a delta and computes the effect on the view. For SUM and COUNT grouped by a key, this is easy: an inserted row adds to its group, a deleted row subtracts, and a group disappears when its count reaches zero. AVG is maintained as sum and count. MIN and MAX are harder: deleting the current minimum forces a rescan of that group. Joins require looking up matching rows on the other side for each delta. Postgres itself has no incremental mode, only full refresh; the pg_ivm extension adds one that updates the view in AFTER triggers inside the writing transaction, supports count, sum, avg, min and max, and charges every base-table write for the trigger (pg_ivm). The general theory, which handles joins, recursion and nested aggregates, is an active research area (Budiu et al., DBSP).
Systems that do this natively include Oracle (fast refresh with materialized view logs, either on demand or at every commit) (Oracle docs), ClickHouse (insert-triggered views that transform each block as it lands), and streaming databases such as Materialize and RisingWave, which maintain views continuously from change streams (Materialize docs).
ClickHouse's insert trigger has a sharp edge: the view sees only newly inserted blocks. Rows in the right-hand table of a join do not trigger anything when they change (ClickHouse docs), and an update or delete on the source is not an insert, so the view never hears about it. For queries that cannot be maintained that way, ClickHouse added refreshable materialized views, which rerun the whole query on a schedule (REFRESH EVERY 1 MINUTE) and were declared production-ready in release 24.10 in late 2024 (ClickHouse 24.10).
Staleness and correctness
With scheduled refresh, staleness equals the refresh interval plus refresh duration. With IVM fed by change data capture, staleness is the replication lag of the change stream, usually sub-second. Deltas must be applied exactly once and in order; replaying a delta double-counts. Track the last applied log position (LSN or offset) per view and skip anything at or below it.
Down the stack
Layer 1The contract
A PostgreSQL materialized view promises that a reader sees the complete result of the defining query as of the last successful refresh, never a half-refreshed state, because the refresh runs in one transaction. It promises nothing about freshness: if nobody refreshes, it stays stale forever. With CONCURRENTLY, readers are not blocked, but the view must have a unique index. An incremental view built on a change stream promises something different: the view equals the query result as of a specific log position, and lag is the distance between that position and the source's current one.
Layer 2The mechanism
A plain REFRESH MATERIALIZED VIEW runs the query into a brand-new heap file and swaps it in under an ACCESS EXCLUSIVE lock, which blocks even SELECT. REFRESH ... CONCURRENTLY instead builds the new result in a temporary table, diffs it against the current contents with a full outer join, and applies only the changed rows as deletes and inserts, holding a weaker lock that still allows reads. ClickHouse materialized views are insert triggers: each INSERT block into the source table is run through the view's SELECT, and the output is written to a target table, often a SummingMergeTree or AggregatingMergeTree that stores partial aggregates and merges them in the background. Oracle keeps a materialized view log per base table, recording changed rows so a fast refresh can apply only those.
Layer 3The machine
A full refresh over a 500 GB fact table reads every page: at 1 GB/s of sequential throughput that is over 8 minutes of pure I/O, before hashing for GROUP BY (which spills to temporary files if it outgrows work_mem) and before writing the new heap. The whole new contents also go into the WAL, so replicas replay the full refresh too. The concurrent variant does more work: the diff join plus row-by-row writes, each leaving a dead tuple for vacuum. In ClickHouse every insert creates a new on-disk part with one compressed file per column, so thousands of tiny inserts per second create thousands of parts. The default thresholds are parts_to_delay_insert = 1,000, after which inserts are slowed, and parts_to_throw_insert = 3,000, after which they fail with a "too many parts" error (ClickHouse docs); batching inserts to tens of thousands of rows avoids that. Every materialized view on a table adds its own insert into its target table, so the part count multiplies with the number of views. An incremental upsert per delta costs one index lookup and one WAL record, a few microseconds of CPU, with the fsync amortized across a batch.
Why it's built this way
- The same expensive aggregation is requested many times, so storing its result is cheaper than recomputing it.
- A stored result goes stale, so it needs a refresh mechanism.
- Full recomputation costs grow with total data, not with what changed, so large views move to incremental deltas.
- Deltas only work for aggregates that can be undone, like sums and counts, so
MIN,MAXandDISTINCTneed extra state or rescans. - Delta consumers crash and replay, so the applied log position is stored atomically with the view rows.
Diagram
flowchart LR
W[Writes] --> B["Base tables"]
B --> L["Change log (WAL / CDC)"]
L --> D["Delta processor"]
D -->|"apply +/- per group"| MV["Materialized view"]
B -.->|"scheduled full refresh"| MV
R[Readers] --> MV
D --> P["Last applied LSN"]Worked example
An analytics product shows revenue per merchant per day. The payments table has 2 billion rows and grows by 400 per second. The dashboard query (SUM(amount), COUNT(*) GROUP BY merchant_id, day) takes 90 seconds, and the dashboard serves 300 requests per second. Running it live is impossible.
A materialized view keyed on (merchant_id, day) has about 50 million rows. Reads become indexed point lookups under 5 ms.
- Full refresh hourly: about 25 minutes of heavy scanning per hour, and data up to 85 minutes stale. Acceptable for a finance report, not for a merchant watching today's sales.
- Incremental via CDC: 400 deltas per second, each a single-row upsert on the view. Staleness is about 1 second, and the load is small. Refunds arrive as negative deltas on the original day.
- Hybrid: IVM for freshness plus a weekly full rebuild of the last 90 days to catch any drift from bugs or out-of-band fixes.
In the wild
- Cloudflare, 2018: replacing a rollup database with ClickHouse materialized views. Cloudflare's HTTP analytics took about 6 million requests per second, with peaks near 8 million. The 2014 pipeline rolled minute aggregates up into hours, days and months in a single Postgres "RollupDB" plus a Citus cluster. The replacement wrote raw rows into ClickHouse and maintained breakdowns with
SummingMergeTreeviews, with unique counts in a separateAggregatingMergeTreeview. A first design of eight separate per-breakdown views was dropped because joining them took a 300-line query and querying them in parallel was only slightly faster than the old pipeline (Cloudflare). - Cloudflare, 2025: a precomputed query result doubles and takes down the proxy. A feature file for Bot Management was regenerated every five minutes from a ClickHouse query on
system.columnsthat did not filter by database. A permissions change made a second database's metadata visible, the result more than doubled, and the proxy, which allowed 200 features (about 60 were in use), panicked. Core traffic was disrupted for about three hours. Cloudflare's first fix was to validate its own generated files as strictly as user input (Cloudflare). A stored query result is only as safe as the query that defines it.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Regular view | Always fresh, no storage | Full cost on every read | Query is already cheap |
| Full refresh | Simple, always correct after refresh | Cost tracks data size, stale between runs | Small data or loose freshness |
| Concurrent refresh | Readers not blocked | Slower, needs a unique key | Readers cannot tolerate a lock |
| Incremental (IVM) | Fresh, cost tracks change rate | Limited query shapes, exactly-once needed | Large base, small changes, sums and counts |
| Streaming database | Continuous, complex joins | New system to operate | Many live views over event streams |
Pitfalls in production
- Refresh outlasting its interval. An hourly refresh that takes 70 minutes overlaps itself. Alert on refresh duration and skip a run if the previous one is still going.
- Blocking refresh. A non-concurrent refresh locks out readers for minutes. Use the concurrent variant or swap tables atomically.
- Double-applied deltas. A consumer restarts and replays a batch, inflating sums. Store the last applied offset in the same transaction as the view update.
- Unsupported aggregates.
MIN,MAX, andDISTINCTcannot be maintained by pure deltas. Store per-group state or fall back to a group rescan. - Nobody knows it is stale. Users see yesterday's numbers as today's. Expose a "last updated" time with the data.
Leadership lens
- Agree the staleness budget with the product owner first; it decides full refresh versus IVM.
- Make refresh duration, lag, and drift visible metrics with alerts, the same as replication lag.
- Know who owns the view's correctness when the base schema changes. Views break silently on column renames and type changes.
- Prefer the database's native feature when it covers the query; a hand-built delta pipeline is a service your team now owns.
Check yourself
Why is
SUMeasy to maintain incrementally butMAXis not?Show answer
A sum can subtract a deleted row's value, while deleting the current maximum gives no way to know the next largest value without rescanning the group.
What determines staleness for a CDC-fed incremental view?
Show answer
The lag between a commit on the base table and the delta being applied, which is mostly change-stream lag.
Why should the applied offset be stored in the same transaction as the view update?
Show answer
Otherwise a crash between the two either loses or replays deltas, and replayed deltas double-count.
Sources and further study
Primary
- Budiu et al., DBSP: Automatic Incremental View Maintenance for Rich Query Languages (2022): a general method for turning a query into an incremental one, including joins and recursion.
Systems docs
- ClickHouse: incremental materialized views: insert triggers, target tables and the join caveat.
- ClickHouse: refreshable materialized views: scheduled full recomputation,
APPENDmode and dependencies between views. - Oracle: refreshing materialized views: complete versus fast refresh, materialized view logs, and on-commit refresh.
- pg_ivm: incremental maintenance for Postgres through triggers, with its list of unsupported query features.
- Materialize: views and indexes: a streaming database that keeps view results up to date as changes arrive.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, chapter 3 "Storage and Retrieval" (data cubes and materialized views under column-oriented storage) and chapter 12 "The Future of Data Systems" (section "Unbundling Databases": derived views maintained from a log).
Other curricula
- donnemartin/system-design-primer: mentions materialized views in one line as the database-managed way to keep denormalized copies consistent.
- roadmap.sh: System Design: includes Materialized View as its own node, alongside Denormalization and Index Table.
Watch
- Materialized views explained (ClickHouse): incremental versus refreshable materialized views in a columnar engine.
Free resources
- PostgreSQL: Materialized Views: how they work in Postgres and when to use them.
- PostgreSQL: REFRESH MATERIALIZED VIEW: the locking difference with
CONCURRENTLY. - Azure Architecture Center: Materialized View pattern: the pattern applied across stores.
- ClickHouse: Materialized views: insert-time transformation in practice.
- Wikipedia: Materialized view: background and vendor support.