CREATE MATERIALIZED VIEW mv_name AS SELECT ...; (PostgreSQL) / CREATE VIEW ... WITH SCHEMABINDING + CREATE UNIQUE CLUSTERED INDEX (SQL Server)REFRESH MATERIALIZED VIEW (non-concurrent) acquires an exclusive lock — blocks all reads during refreshREFRESH CONCURRENTLY requires at least one UNIQUE index covering all rows (no WHERE clause, no expression indexes) [src2]SCHEMABINDING — underlying table schema cannot be altered while the view exists [src4]| Scenario | Pattern | Refresh Cost | Storage Overhead | Trade-off |
|---|---|---|---|---|
| Dashboard aggregations (daily) | Full refresh on schedule | O(base_table) | Low-Medium | Simple; blocks reads briefly |
| Dashboard aggregations (live) | Concurrent refresh + cron | O(delta) comparison | Medium | No read blocking; needs unique index |
| Reporting on large joins | Full refresh nightly | O(join_result) | High | Massive read speedup; stale during day |
| Near-real-time analytics | Oracle ON COMMIT / trigger-based | O(delta) per commit | Medium | Fresh data; slows writes |
| Search/filter on computed cols | Indexed materialized view | O(index_maintenance) | Medium | Fast reads; write amplification |
| Remote/federated data caching | Materialized view on foreign table | O(network_transfer) | Matches result set | Eliminates network latency on reads |
| Time-series rollups (hourly) | Partitioned MV + incremental | O(partition) | High | Refresh only latest partition |
| MySQL workaround | Summary table + triggers/events | O(trigger_logic) | Matches result set | Full manual maintenance |
| High-availability refresh | Oracle out-of-place refresh | O(base_table) | 2x during refresh | Zero downtime; temporary double storage |
| Multi-level aggregations | Nested/stacked MVs | O(each_level) | Multiplied per level | Complex dependency chain |
| Write-heavy OLTP tables | Avoid materialized views | N/A | N/A | Write overhead exceeds read benefit |
| Small tables (<10K rows) | Regular view + indexes | N/A | None | MV adds complexity without benefit |
START: Do you need precomputed query results?
├── Query < 50ms already?
│ ├── YES → Use a regular view or direct query (no MV needed)
│ └── NO ↓
├── Can you tolerate stale data?
│ ├── NO → Use regular views with proper indexing, or application cache with invalidation
│ └── YES ↓
├── Which database engine?
│ ├── PostgreSQL ↓
│ │ ├── Need zero-downtime refresh?
│ │ │ ├── YES → CREATE MATERIALIZED VIEW + UNIQUE INDEX + REFRESH CONCURRENTLY
│ │ │ └── NO → CREATE MATERIALIZED VIEW + REFRESH (simpler, blocks reads)
│ │ └── Refresh frequency?
│ │ ├── Daily/hourly → cron job or pg_cron
│ │ └── After each ETL load → trigger REFRESH in pipeline
│ ├── Oracle ↓
│ │ ├── Need real-time freshness?
│ │ │ ├── YES → CREATE MATERIALIZED VIEW ... REFRESH FAST ON COMMIT (+ MV logs)
│ │ │ └── NO → CREATE MATERIALIZED VIEW ... REFRESH FORCE ON DEMAND
│ │ └── Large partitioned tables?
│ │ └── YES → Use PCT (Partition Change Tracking) refresh
│ ├── SQL Server ↓
│ │ ├── Enterprise Edition?
│ │ │ ├── YES → CREATE VIEW WITH SCHEMABINDING + CREATE UNIQUE CLUSTERED INDEX
│ │ │ └── NO → Manual summary table pattern (indexed views auto-match only on Enterprise)
│ │ └── Note: Indexed views auto-update on base table changes (no manual refresh)
│ └── MySQL ↓
│ └── Create summary table + scheduled EVENT or triggers for refresh
└── DEFAULT → PostgreSQL MATERIALIZED VIEW with scheduled REFRESH is the most common pattern
Define the expensive query you want to precompute. The view is populated immediately on creation. [src1]
CREATE MATERIALIZED VIEW mv_monthly_sales AS
SELECT
date_trunc('month', order_date) AS month,
product_category,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue,
AVG(total_amount) AS avg_order_value
FROM orders
JOIN order_items USING (order_id)
JOIN products USING (product_id)
WHERE order_date >= '2024-01-01'
GROUP BY 1, 2
WITH DATA;
Verify: SELECT COUNT(*) FROM mv_monthly_sales; → expected: row count > 0
Materialized views are physical tables — index them like any other table. A unique index is required for REFRESH CONCURRENTLY. [src2]
-- Required for REFRESH CONCURRENTLY
CREATE UNIQUE INDEX idx_mv_monthly_sales_pk
ON mv_monthly_sales (month, product_category);
-- Additional indexes for common query patterns
CREATE INDEX idx_mv_monthly_sales_category
ON mv_monthly_sales (product_category);
CREATE INDEX idx_mv_monthly_sales_revenue
ON mv_monthly_sales (revenue DESC);
Verify: \di+ mv_monthly_sales* → expected: should list all three indexes
Use CONCURRENTLY to avoid blocking reads during refresh. This compares old and new data, applying only deltas. [src2]
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales;
Verify: SELECT * FROM mv_monthly_sales ORDER BY month DESC LIMIT 5; → expected: updated data
Schedule periodic refresh using pg_cron (or external cron). [src1]
-- Install pg_cron extension (requires superuser)
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Refresh every hour at minute 0
SELECT cron.schedule(
'refresh-monthly-sales',
'0 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales'
);
Verify: SELECT * FROM cron.job WHERE jobname = 'refresh-monthly-sales'; → expected: scheduled job row
Check when the view was last refreshed and whether it needs updating. [src1]
SELECT schemaname, matviewname, hasindexes, ispopulated
FROM pg_matviews
WHERE matviewname = 'mv_monthly_sales';
Verify: ispopulated should be true
Wrap refresh in error handling for production pipelines. [src2]
CREATE OR REPLACE FUNCTION refresh_mv_safely(view_name text)
RETURNS void AS $$
BEGIN
EXECUTE format('REFRESH MATERIALIZED VIEW CONCURRENTLY %I', view_name);
RAISE NOTICE 'Successfully refreshed %', view_name;
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to refresh %: %', view_name, SQLERRM;
BEGIN
EXECUTE format('REFRESH MATERIALIZED VIEW %I', view_name);
RAISE NOTICE 'Fallback refresh succeeded for %', view_name;
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'All refresh attempts failed for %: %', view_name, SQLERRM;
END;
END;
$$ LANGUAGE plpgsql;
Verify: SELECT refresh_mv_safely('mv_monthly_sales'); → expected: success notice
-- Input: orders + products tables with millions of rows
-- Output: precomputed category-level daily aggregations
-- 1. Create the materialized view
CREATE MATERIALIZED VIEW mv_daily_category_stats AS
SELECT
order_date::date AS day,
p.category,
COUNT(DISTINCT o.customer_id) AS unique_customers,
COUNT(*) AS order_count,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
GROUP BY 1, 2
WITH DATA;
-- 2. Create unique index (required for CONCURRENTLY)
CREATE UNIQUE INDEX ON mv_daily_category_stats (day, category);
-- 3. Refresh without blocking reads
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_category_stats;
-- 4. Query (uses indexes, sub-ms response)
SELECT category, SUM(revenue) AS total_revenue
FROM mv_daily_category_stats
WHERE day >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY category
ORDER BY total_revenue DESC;
-- Input: sales table with frequent inserts
-- Output: incrementally refreshed aggregation
-- 1. Create materialized view log on base table
CREATE MATERIALIZED VIEW LOG ON sales
WITH ROWID, SEQUENCE (product_id, customer_id, sale_date, amount)
INCLUDING NEW VALUES;
-- 2. Create MV with FAST refresh ON DEMAND
CREATE MATERIALIZED VIEW mv_product_sales
BUILD IMMEDIATE
REFRESH FAST ON DEMAND
ENABLE QUERY REWRITE
AS
SELECT product_id, COUNT(*) AS sale_count,
SUM(amount) AS total_revenue, COUNT(*) AS cnt
FROM sales GROUP BY product_id;
-- 3. After new data loads, refresh incrementally
EXEC DBMS_MVIEW.REFRESH('mv_product_sales', 'F');
-- 4. Check staleness
SELECT mview_name, staleness, last_refresh_type, last_refresh_date
FROM user_mviews WHERE mview_name = 'MV_PRODUCT_SALES';
-- Input: orders + order_items tables
-- Output: automatically maintained materialized aggregation
-- 1. Create the view WITH SCHEMABINDING (required)
CREATE VIEW dbo.vw_category_revenue
WITH SCHEMABINDING
AS
SELECT p.category, COUNT_BIG(*) AS order_count,
SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM dbo.order_items oi
INNER JOIN dbo.products p ON oi.product_id = p.product_id
GROUP BY p.category;
GO
-- 2. Materialize with unique clustered index
CREATE UNIQUE CLUSTERED INDEX idx_vw_category_revenue
ON dbo.vw_category_revenue (category);
GO
-- 3. Query (Enterprise auto-uses; Standard needs NOEXPAND)
SELECT category, total_revenue
FROM dbo.vw_category_revenue WITH (NOEXPAND)
ORDER BY total_revenue DESC;
-- Input: orders table (MySQL has no native MVs)
-- Output: manually maintained summary table
-- 1. Create the summary table
CREATE TABLE mv_daily_sales (
sale_date DATE NOT NULL,
product_id INT NOT NULL,
total_quantity INT NOT NULL DEFAULT 0,
total_revenue DECIMAL(12,2) NOT NULL DEFAULT 0,
last_refreshed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (sale_date, product_id)
) ENGINE=InnoDB;
-- 2. Full refresh procedure
DELIMITER //
CREATE PROCEDURE refresh_mv_daily_sales()
BEGIN
TRUNCATE TABLE mv_daily_sales;
INSERT INTO mv_daily_sales (sale_date, product_id, total_quantity, total_revenue)
SELECT DATE(order_date), product_id,
SUM(quantity), SUM(quantity * unit_price)
FROM orders GROUP BY DATE(order_date), product_id;
END //
DELIMITER ;
-- 3. Automate with MySQL Event Scheduler
SET GLOBAL event_scheduler = ON;
CREATE EVENT refresh_daily_sales
ON SCHEDULE EVERY 1 HOUR
DO CALL refresh_mv_daily_sales();
-- BAD — blocks all SELECT queries during refresh (can take minutes on large tables)
REFRESH MATERIALIZED VIEW mv_dashboard_stats;
-- GOOD — allows concurrent reads during refresh
CREATE UNIQUE INDEX ON mv_dashboard_stats (report_date, department_id);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard_stats;
-- BAD — materialized view has no indexes, queries do sequential scans
CREATE MATERIALIZED VIEW mv_user_activity AS
SELECT user_id, COUNT(*) as actions, MAX(created_at) as last_active
FROM activity_log GROUP BY user_id;
-- This query seq-scans the entire MV
SELECT * FROM mv_user_activity WHERE user_id = 12345;
-- GOOD — add indexes matching your query patterns
CREATE MATERIALIZED VIEW mv_user_activity AS
SELECT user_id, COUNT(*) as actions, MAX(created_at) as last_active
FROM activity_log GROUP BY user_id;
CREATE UNIQUE INDEX ON mv_user_activity (user_id);
CREATE INDEX ON mv_user_activity (last_active DESC);
-- Now uses index scan: sub-ms response
SELECT * FROM mv_user_activity WHERE user_id = 12345;
-- BAD — created the view but never refreshes it
CREATE MATERIALIZED VIEW mv_inventory_levels AS
SELECT product_id, SUM(quantity) AS stock
FROM inventory GROUP BY product_id;
-- ... weeks later, stock levels are completely wrong
-- GOOD — materialized view + scheduled refresh + monitoring
CREATE MATERIALIZED VIEW mv_inventory_levels AS
SELECT product_id, SUM(quantity) AS stock
FROM inventory GROUP BY product_id;
CREATE UNIQUE INDEX ON mv_inventory_levels (product_id);
-- Refresh every 15 minutes
SELECT cron.schedule('refresh-inventory', '*/15 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_inventory_levels');
-- BAD — FORCE silently falls back to COMPLETE refresh every time
CREATE MATERIALIZED VIEW mv_sales_summary
REFRESH FORCE ON DEMAND AS
SELECT product_id, SUM(amount) FROM sales GROUP BY product_id;
EXEC DBMS_MVIEW.REFRESH('mv_sales_summary', '?');
-- GOOD — MV log enables true incremental refresh
CREATE MATERIALIZED VIEW LOG ON sales
WITH ROWID, SEQUENCE (product_id, amount) INCLUDING NEW VALUES;
CREATE MATERIALIZED VIEW mv_sales_summary
REFRESH FAST ON DEMAND AS
SELECT product_id, SUM(amount) AS total, COUNT(*) AS cnt
FROM sales GROUP BY product_id;
EXEC DBMS_MVIEW.REFRESH('mv_sales_summary', 'F');
last_refreshed column or check pg_stat_user_tables. [src1]ERROR: cannot refresh materialized view concurrently without at least one unique index. Fix: CREATE UNIQUE INDEX ON mv_name (col1, col2); before using CONCURRENTLY. [src2]COUNT_BIG(*) instead of COUNT(*). Using COUNT causes creation to fail. Fix: Always use COUNT_BIG(*) in indexed view definitions. [src4]WITH SCHEMABINDING prevents ALTER TABLE on any referenced column. Fix: Drop and recreate indexed view around migrations. [src4]PURGE IMMEDIATE. [src3]REFRESH writes an entirely new copy before swapping. A 10GB MV needs 20GB free during refresh. Fix: Monitor disk space; use CONCURRENTLY for delta-based updates. [src2]EVENT-based refresh instead of triggers for write-heavy tables. [src6]CREATE MATERIALIZED VIEW ... WITH NO DATA creates the structure without populating it. Querying raises ERROR: materialized view has not been populated. Fix: Run REFRESH MATERIALIZED VIEW mv_name; before first query. [src1]-- PostgreSQL: List all materialized views and their state
SELECT schemaname, matviewname, hasindexes, ispopulated
FROM pg_matviews ORDER BY matviewname;
-- PostgreSQL: Check materialized view size on disk
SELECT pg_size_pretty(pg_total_relation_size('mv_monthly_sales')) AS total_size;
-- PostgreSQL: Check indexes on a materialized view
SELECT indexname, indexdef FROM pg_indexes
WHERE tablename = 'mv_monthly_sales';
-- PostgreSQL: Check if CONCURRENTLY is possible (has unique index?)
SELECT i.relname AS index_name, ix.indisunique
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
WHERE t.relname = 'mv_monthly_sales' AND ix.indisunique = true;
-- Oracle: Check materialized view staleness and last refresh
SELECT mview_name, staleness, last_refresh_type, last_refresh_date, compile_state
FROM user_mviews ORDER BY mview_name;
-- Oracle: Check materialized view log sizes
SELECT log_owner, master, log_table, rowids, primary_key, filter_columns
FROM all_mview_logs ORDER BY master;
-- SQL Server: List indexed views
SELECT v.name AS view_name, i.name AS index_name, i.type_desc
FROM sys.views v
JOIN sys.indexes i ON v.object_id = i.object_id
WHERE i.type_desc = 'CLUSTERED' ORDER BY v.name;
-- PostgreSQL: Check pg_cron refresh schedule
SELECT jobid, schedule, command, active, jobname
FROM cron.job WHERE command LIKE '%REFRESH%';
| Database | Version | MV Support | Key Feature |
|---|---|---|---|
| PostgreSQL 18+ | Current (2025) | Native | Improved planner for MVs |
| PostgreSQL 9.4+ | LTS/Stable | Native | REFRESH CONCURRENTLY (requires unique index) |
| PostgreSQL 9.3 | Original release | Native | CREATE MATERIALIZED VIEW (no CONCURRENTLY) |
| Oracle 23ai | Current (2024) | Native + FAST + Query Rewrite | Automatic MV management improvements |
| Oracle 12cR2+ | Stable | Native + FAST + PCT | ON STATEMENT refresh; out-of-place refresh |
| Oracle 8i+ | Legacy | Native | Basic MVs with ON COMMIT/DEMAND |
| SQL Server 2022 | Current | Indexed Views | Same SCHEMABINDING requirement |
| SQL Server 2005+ | Stable | Indexed Views | WITH SCHEMABINDING + clustered index |
| Azure Synapse | Current | Native CREATE MATERIALIZED VIEW | Different syntax from on-prem SQL Server |
| MySQL 9.0 | Current | No native support | Summary tables + triggers/events |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Expensive aggregation queries run frequently (>10x/day) | Base data changes every second and staleness is unacceptable | Regular views with optimized indexes |
| Dashboard/reporting queries with complex JOINs | Query is already fast (<50ms) without materialization | Direct query or application-level caching |
| Read-heavy workloads with predictable query patterns | Write-heavy OLTP where MV maintenance exceeds read savings | Write-through cache (Redis/Memcached) |
| Foreign/remote data that is expensive to fetch | Small tables where full scan is trivial | Regular views (zero maintenance overhead) |
| Data warehouse / OLAP analytical queries | Schema changes frequently (SQL Server SCHEMABINDING) | CTEs or temporary tables per-session |
| Precomputing search indexes or denormalized data | Need to update data through the view | Updatable views or direct table access |
| Reducing load on a hot table during peak hours | Query results vary per user/session (not cacheable) | Per-request computation with connection pooling |
pg_cron, external scheduler, or application-triggered refresh.REFRESH is faster. [src2]ENABLE QUERY REWRITE is set, Oracle may transparently rewrite base table queries to use the MV. Monitor STALENESS in USER_MVIEWS. [src3]pg_total_relation_size() or equivalent after creation.