Materialized Views Cheat Sheet
Materialized views across PostgreSQL, ClickHouse, and Snowflake covering creation syntax, refresh strategies, and indexing.
PostgreSQL Materialized View
Create, index, and refresh a materialized view without blocking reads.
CREATE MATERIALIZED VIEW daily_revenue ASSELECT date_trunc('day', created_at) AS day, sum(amount) AS revenue, count(*) AS order_countFROM ordersGROUP BY 1WITH DATA;-- Required for CONCURRENTLY refresh: a unique indexCREATE UNIQUE INDEX ON daily_revenue (day);-- Refresh without locking out concurrent readsREFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
ClickHouse & TimescaleDB Equivalents
Incrementally maintained materialized views, rather than full-refresh snapshots.
-- ClickHouse: incrementally populated on every insert to the source tableCREATE MATERIALIZED VIEW daily_revenue_mvENGINE = SummingMergeTreeORDER BY day ASSELECT toDate(created_at) AS day, sum(amount) AS revenueFROM ordersGROUP BY day;-- TimescaleDB: continuous aggregate, refreshed on a scheduleCREATE MATERIALIZED VIEW daily_revenueWITH (timescaledb.continuous) ASSELECT time_bucket('1 day', created_at) AS day, sum(amount) AS revenueFROM ordersGROUP BY day;
Scheduling Refreshes (pg_cron)
Keep a Postgres materialized view fresh on a schedule.
CREATE EXTENSION IF NOT EXISTS pg_cron;SELECT cron.schedule( 'refresh-daily-revenue', '*/15 * * * *', $$REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue$$);-- Check scheduled jobsSELECT * FROM cron.job;
Materialized vs. Regular Views — Tradeoffs
When to reach for a materialized view instead of a plain view or app-level cache.
- Regular view- just a stored query, always live, zero storage, but re-executes the full query every time
- Materialized view- stores results on disk; fast reads, but data is stale until refreshed
- REFRESH ... CONCURRENTLY- Postgres-only, avoids locking readers out during refresh, requires a unique index
- Incremental MVs (ClickHouse, Timescale)- update as new rows arrive instead of full recompute, ideal for append-only event data
- Staleness budget- decide upfront how stale is acceptable; drives your refresh interval/schedule
Indexing a Materialized View
A materialized view is a real relation on disk and accepts the same indexing toolkit as a table.
CREATE INDEX idx_daily_revenue_day ON daily_revenue (day);-- Partial index for a common dashboard filterCREATE INDEX idx_daily_revenue_high_value ON daily_revenue (day) WHERE revenue > 10000;-- Covering index avoids a heap fetch entirely for this query shapeCREATE INDEX idx_daily_revenue_covering ON daily_revenue (day) INCLUDE (revenue, order_count);-- Stats go stale after every REFRESH; re-analyze so the planner stays accurateANALYZE daily_revenue;
Chained & Dependent Materialized Views
Layered materialized views must be refreshed in dependency order, leaf to root.
CREATE MATERIALIZED VIEW monthly_revenue ASSELECT date_trunc('month', day) AS month, sum(revenue) AS revenueFROM daily_revenueGROUP BY 1;-- Inspect what depends on daily_revenue before scripting a refresh chainSELECT dependent_ns.nspname, dependent_view.relnameFROM pg_dependJOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oidJOIN pg_class dependent_view ON pg_rewrite.ev_class = dependent_view.oidJOIN pg_class source_table ON pg_depend.refobjid = source_table.oidJOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespaceWHERE source_table.relname = 'daily_revenue';-- Refresh leaf-to-root or monthly_revenue reads stale dataREFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;
Tracking Refresh Staleness
Postgres doesn't record a materialized view's last-refresh timestamp natively, so log it yourself.
CREATE TABLE mv_refresh_log ( mv_name TEXT PRIMARY KEY, last_refreshed TIMESTAMPTZ);CREATE OR REPLACE FUNCTION refresh_and_log(mv TEXT) RETURNS void AS $$BEGIN EXECUTE format('REFRESH MATERIALIZED VIEW CONCURRENTLY %I', mv); INSERT INTO mv_refresh_log (mv_name, last_refreshed) VALUES (mv, now()) ON CONFLICT (mv_name) DO UPDATE SET last_refreshed = now();END;$$ LANGUAGE plpgsql;SELECT refresh_and_log('daily_revenue');SELECT mv_name, now() - last_refreshed AS staleness FROM mv_refresh_log;
True Incremental Maintenance with pg_ivm
The pg_ivm extension updates a view's rows on every write instead of recomputing the whole query on a schedule.
CREATE EXTENSION IF NOT EXISTS pg_ivm;SELECT create_immv('daily_revenue_ivm', 'SELECT date_trunc(''day'', created_at) AS day, sum(amount) AS revenue FROM orders GROUP BY 1');-- No REFRESH call needed — the view updates inside the same transaction-- as the write to ordersINSERT INTO orders (created_at, amount) VALUES (now(), 42.00);SELECT * FROM daily_revenue_ivm WHERE day = date_trunc('day', now());
Refresh Strategy Tradeoffs, Beyond Basic Scheduling
Choosing between eager, lazy, and incremental maintenance once REFRESH ... CONCURRENTLY alone isn't enough.
- Eager (synchronous)- the view updates inside the write transaction; always consistent, but adds latency to every write (pg_ivm, Oracle ON COMMIT)
- Lazy (scheduled)- refresh runs on a timer or cron job; cheap writes, but reads see a bounded staleness window
- Incremental view maintenance- only the rows affected by a change are recomputed, not the entire query (pg_ivm, ClickHouse MVs, TimescaleDB continuous aggregates)
- Full refresh- recomputes the entire result set from scratch on every run; simplest to reason about but the most expensive at scale
- Dependency ordering- multi-level materialized views must refresh leaf-to-root, or a downstream view reads a stale upstream snapshot
- Swap-and-rename pattern- build a new table under a temp name and atomically rename it into place, giving zero-downtime refreshes on engines without a CONCURRENTLY option
In PostgreSQL, `REFRESH MATERIALIZED VIEW` without `CONCURRENTLY` takes an ACCESS EXCLUSIVE lock and blocks all reads for the duration — always create the required unique index up front and use CONCURRENTLY in production, even though it's slower per refresh.