← Materialized Views

Materialized View Pitfalls the Documentation Neglected to Mention

They solve the performance problem beautifully. Then they introduce rather a lot of new ones.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 22 min read
We had arranged for a diagram. It has been... materialized elsewhere.

Good evening. I see you have discovered materialized views.

An excellent discovery. A materialized view takes a slow aggregation query that runs thousands of times per day and precomputes the result once. Your 400ms dashboard query becomes 2ms. Your database CPU drops by 40%. Everything feels faster. You tell your colleagues. They create more materialized views.

And then, gradually, a different category of problem emerges.

CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT date_trunc('day', created_at) AS day,
       region,
       COUNT(*) AS order_count,
       SUM(total) AS revenue
FROM orders
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY 1, 2;

-- Instant dashboard queries instead of 400ms aggregation.
-- But this is where the trouble begins.

The PostgreSQL documentation explains how to create materialized views and how to refresh them. What it does not explain — or explains only in the driest footnotes — is what happens when you operate them in production over weeks and months. The problems are not bugs. They are properties of the design, as fundamental to materialized views as the performance benefit that drew you to them in the first place.

I think it would be courteous to walk through all of them before you build your architecture around assumptions that will not hold. There are, by my count, eleven distinct pitfalls. Some are obvious after the first incident. Others take months to surface. I intend to address them all, because a partial inventory would be a disservice to you and an embarrassment to me.

Pitfall 1: stale data is the default

A materialized view is a snapshot. It reflects the state of the source data at the moment it was last refreshed. Between refreshes, the world changes and the matview does not.

-- Your matview was refreshed at 06:00 AM.
-- It is now 11:47 AM. A customer placed a $50,000 order at 09:15.
-- Your dashboard does not show it. Your CEO notices.

-- How stale is it?
SELECT schemaname, matviewname,
       pg_stat_get_last_autovacuum_time(c.oid) AS last_refresh
FROM pg_matviews m
JOIN pg_class c ON c.relname = m.matviewname;

-- There is no built-in "last refreshed" timestamp for matviews.
-- You must track this yourself.

This is not a bug — it is the fundamental trade-off of materialization. You are exchanging real-time accuracy for query speed. But the consequences of staleness vary enormously by use case. A reporting dashboard that refreshes hourly is probably fine. A dashboard that executives check before making pricing decisions needs to be rather more current than "whenever cron last ran."

The insidious part: there is no built-in mechanism to tell you how stale the data is. PostgreSQL does not record when a materialized view was last refreshed. I shall repeat that, because it bears repeating. PostgreSQL does not record when a materialized view was last refreshed. There is no system catalog column. No pg_stat_matviews. No last_refresh timestamp. If you want to know, you must build it yourself.

-- Build your own staleness tracking. Nobody else will.
CREATE TABLE matview_refresh_log (
    matview_name TEXT PRIMARY KEY,
    last_refreshed TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    refresh_duration_ms INTEGER,
    row_count BIGINT
);

-- Update after each refresh:
DO $$
DECLARE
    t0 TIMESTAMPTZ := clock_timestamp();
    rc BIGINT;
BEGIN
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;
    SELECT COUNT(*) INTO rc FROM mv_daily_revenue;
    INSERT INTO matview_refresh_log (matview_name, last_refreshed, refresh_duration_ms, row_count)
    VALUES ('mv_daily_revenue', NOW(),
            EXTRACT(EPOCH FROM clock_timestamp() - t0) * 1000, rc)
    ON CONFLICT (matview_name) DO UPDATE
    SET last_refreshed = EXCLUDED.last_refreshed,
        refresh_duration_ms = EXCLUDED.refresh_duration_ms,
        row_count = EXCLUDED.row_count;
END $$;

-- Now you can answer "how stale is this?" in your application:
SELECT matview_name,
       last_refreshed,
       NOW() - last_refreshed AS staleness,
       refresh_duration_ms
FROM matview_refresh_log;

This is not an unreasonable amount of code. But it is code you will write, maintain, and debug — and if it breaks, you will have no idea how stale your data is until someone notices the numbers are wrong. The operational burden of a materialized view extends well beyond the CREATE statement.

I should offer a counterpoint. For many workloads — nightly batch reports, weekly analytics, internal dashboards that nobody checks more than twice a day — staleness is genuinely not a problem. The 200x query speedup justifies minutes or even hours of staleness. The pitfall is not that staleness exists. The pitfall is that nothing warns you when the staleness exceeds your tolerance.

Pitfall 2: REFRESH blocks all reads

The standard REFRESH MATERIALIZED VIEW command acquires an ACCESS EXCLUSIVE lock on the matview. This is the strongest lock PostgreSQL offers. No other session can read from the matview — or even acquire a lightweight ACCESS SHARE lock — while the refresh is running.

-- REFRESH MATERIALIZED VIEW acquires an ACCESS EXCLUSIVE lock.
-- This blocks ALL reads on the matview until the refresh completes.

REFRESH MATERIALIZED VIEW mv_daily_revenue;
-- Duration: 12 seconds on a 50M row source table.
-- During those 12 seconds, every dashboard query waiting.

-- CONCURRENTLY avoids the exclusive lock:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;
-- Requires a UNIQUE INDEX on the matview.
-- Reads continue during refresh. New data appears atomically.

If your source query takes 12 seconds to execute, your dashboard is unavailable for 12 seconds. Every refresh cycle. If you refresh every 5 minutes, you have 12 seconds of downtime every 5 minutes. If your connection pool has 20 connections and all of them try to read the matview during a refresh, all 20 block. Your pool fills. New requests queue. Timeouts cascade.

-- During a blocking refresh, you will see this in pg_stat_activity:
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
  AND query LIKE '%mv_daily_revenue%';

-- Every session trying to SELECT from the matview is blocked.
-- They accumulate. Your connection pool fills.
-- Your application starts timing out.
-- All because of a 12-second refresh.

This is the single most common production incident involving materialized views, and it is entirely avoidable. REFRESH CONCURRENTLY solves it by building a new version alongside the existing one and swapping atomically. Readers continue seeing the old data until the swap, then immediately see the new data. No lock contention. No blocked queries.

But CONCURRENTLY has its own requirements, and they are not trivial.

Pitfall 3: CONCURRENTLY has strict requirements

-- CONCURRENTLY requires a unique index. No exceptions.
CREATE UNIQUE INDEX ON mv_daily_revenue (day, region);

-- Without this index, CONCURRENTLY fails:
-- ERROR: cannot refresh materialized view concurrently
--        without a unique index

-- The unique index must cover ALL rows. If your matview has NULLs
-- in the unique columns, CONCURRENTLY will fail on those rows.

The CONCURRENTLY option is not merely a flag you append. It requires a unique index on the materialized view — one that covers every row in the result set, including rows with null values in the indexed columns. If your matview's natural key contains nullable columns, you will discover this requirement at the most inconvenient possible moment.

-- Problem: your matview has NULLs in a key column.
-- NULLs are not equal to each other in SQL, so they violate uniqueness.

-- Workaround: use COALESCE in an expression index.
CREATE UNIQUE INDEX ON mv_daily_revenue (
    day,
    COALESCE(region, '__NULL__')
);

-- Or restructure the view to eliminate NULLs:
CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT date_trunc('day', created_at) AS day,
       COALESCE(region, 'unspecified') AS region,
       COUNT(*) AS order_count,
       SUM(total) AS revenue
FROM orders
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY 1, 2;

-- The second approach is cleaner. The NULLs were probably
-- an oversight in the source data anyway.

The NULL issue is a design consequence of how PostgreSQL implements the concurrent diff. To determine which rows are new, changed, or deleted, it needs a way to uniquely identify each row across the old and new versions. NULLs, by SQL specification, are not equal to anything — including other NULLs — so a unique index containing NULLs cannot guarantee uniqueness. The workaround is mechanical but must be applied at creation time, not discovered during the first failed refresh.

The performance cost of CONCURRENTLY

There is a further matter I should raise, because it catches people off guard.

-- CONCURRENTLY refresh: what it actually does.
-- Step 1: Execute the full source query (same cost as standard refresh)
-- Step 2: Compute a diff against the existing matview contents
-- Step 3: Apply inserts/updates/deletes to bring the matview current

-- On a matview with 500,000 rows where 2,000 changed:
--   Standard refresh:     8.4 seconds (drop + rewrite all 500K rows)
--   Concurrent refresh:  14.1 seconds (query + diff + apply 2K changes)

-- On a matview with 500,000 rows where 400,000 changed:
--   Standard refresh:     8.4 seconds
--   Concurrent refresh:  22.7 seconds (diff is expensive when most rows changed)

-- CONCURRENTLY is slower. Sometimes much slower.
-- The benefit is availability, not speed.

CONCURRENTLY is slower than a standard refresh. Often significantly slower. The diff computation requires reading both the old and new result sets and comparing them row by row. When most rows have changed, the diff is nearly as expensive as a full rewrite, but with additional overhead.

You trade availability for duration. This is usually the right trade-off — a 14-second refresh that serves stale reads is vastly preferable to a 8-second refresh that blocks all reads. But you should know you are making it.

Pitfall 4: disk usage accumulates silently

A materialized view is a physical table. It stores the complete result set on disk, with its own indexes, its own TOAST tables, its own visibility map. It occupies real space that grows with the underlying data.

-- Materialized views are full physical copies of the query result.
-- They consume real disk space and are not cleaned up automatically.

-- Check matview sizes:
SELECT matviewname,
       pg_size_pretty(pg_total_relation_size(matviewname::regclass)) AS size
FROM pg_matviews
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(matviewname::regclass) DESC;

-- A matview over a 10GB table with heavy aggregation might be 50MB.
-- A matview that just filters without aggregating might be 8GB.
-- Each refresh writes the entire result set again.

The trap is matviews that filter without aggregating. If your matview selects 80% of a 10GB table with a WHERE clause, the matview itself is 8GB. Add an index on the matview and you are at 9GB. Every refresh writes the entire result set again. If you are using CONCURRENTLY, the old row versions linger as dead tuples until VACUUM cleans them.

-- After a CONCURRENTLY refresh, old row versions remain until VACUUM.
-- If autovacuum is configured conservatively, bloat accumulates.

-- Check bloat on your matviews:
SELECT schemaname, relname,
       pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
       n_dead_tup,
       last_autovacuum
FROM pg_stat_user_tables
WHERE relname LIKE 'mv_%'
ORDER BY n_dead_tup DESC;

-- A matview refreshed CONCURRENTLY every 5 minutes
-- with 500K rows generates 500K dead tuples per refresh.
-- That is 144 million dead tuples per day if autovacuum
-- cannot keep pace. Your disk usage graph climbs steadily.

-- Mitigation: tune autovacuum for matviews specifically.
ALTER TABLE mv_daily_revenue
SET (autovacuum_vacuum_scale_factor = 0.01,
     autovacuum_vacuum_threshold = 1000);

Aggregating matviews are typically small — the GROUP BY collapses millions of rows into thousands. A matview that aggregates daily revenue from 10 million orders might be 2MB. That is excellent. A matview that selects all orders for the current quarter with a WHERE clause might be 3GB. That is a very different operational profile.

Know which kind you have. Monitor sizes weekly. And if you see a matview that is nearly as large as its source table, question whether a matview is the right solution. A well-designed index on the source table might provide 90% of the performance benefit at 1% of the disk cost.

Pitfall 5: every refresh is a full recomputation

PostgreSQL does not support incremental refresh for materialized views. Every REFRESH re-executes the underlying query from scratch, regardless of how many rows in the source data have changed. One new order in a 10-million-row table triggers the same 30-second recomputation as a bulk import of 500,000 orders.

-- Each REFRESH re-executes the underlying query from scratch.
-- There is no incremental refresh in PostgreSQL (natively).

-- If your source query takes 30 seconds:
--   Refresh every 5 minutes = 30s of CPU every 5 minutes
--   = 10% of a CPU core consumed by refresh alone

-- With 10 matviews refreshing every 5 minutes at 30s each:
--   300s of CPU every 300s = one full core dedicated to refreshes

-- And that is just CPU. Each refresh also generates:
--   - WAL writes (the new matview data)
--   - Shared buffer pressure (evicting other cached pages)
--   - I/O load on the source tables (full scans)
--   - Temporary disk usage during the refresh operation

The math becomes uncomfortable at scale. A single matview refreshing every 5 minutes with a 30-second source query consumes 10% of a CPU core in perpetuity. Ten matviews at that cadence consume an entire core. And the cost is not limited to CPU — each refresh generates WAL writes, exerts pressure on the shared buffer cache, and performs full scans of the source tables, competing for I/O bandwidth with your live queries.

The deeper problem: refreshing unchanged data

-- The real waste: refreshing when nothing changed.

-- Your cron job runs every 5 minutes:
*/5 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue"

-- At 3 AM, nobody is placing orders. The source data is identical
-- to 5 minutes ago. But the refresh runs anyway:
--   - Full source query execution: 30 seconds of CPU
--   - Diff computation: 14 seconds (finds zero changes)
--   - Total: 44 seconds of work to confirm nothing changed
--   - 12 times per hour × 8 quiet hours = 96 wasted refreshes per night

-- This is the infrastructure equivalent of checking
-- whether the front door is locked every five minutes
-- when nobody has been near it all evening.

Time-based refresh is a blunt instrument. It refreshes on a schedule regardless of whether the underlying data has changed. During quiet hours — nights, weekends, low-traffic periods — this means burning compute to confirm that nothing happened. The matview system has no way to know whether a refresh is necessary, because it has no awareness of the write traffic hitting the source tables.

-- What incremental refresh would look like (if PostgreSQL had it):
-- "Only 3 orders changed since last refresh. Update those 3 rows."

-- What actually happens:
-- "Re-execute the entire query. Discard the old result. Store the new one."

-- For a matview joining orders (10M rows) with customers (50K rows):
--   Incremental: process 3 changed rows → ~2ms
--   Full refresh: scan 10M rows, join, aggregate → ~30 seconds

-- The pg_ivm extension brings incremental refresh to PostgreSQL
-- via trigger-maintained views. It has limitations (no CTEs,
-- no UNION, no window functions, no CROSS JOIN) but for
-- eligible queries, the difference is transformative.

I should note that the pg_ivm extension brings incremental refresh to PostgreSQL through trigger-maintained views. For eligible queries — those without CTEs, UNION, window functions, or certain join types — the improvement is substantial. Rather than re-executing the full source query, pg_ivm updates only the rows affected by each write. A single new order refreshes in milliseconds, not seconds. If your queries fit within pg_ivm's constraints, it eliminates this pitfall almost entirely.

The limitation is those constraints. pg_ivm cannot handle every query pattern, and it adds trigger overhead to every write on the source tables. For write-heavy workloads, that overhead may exceed the savings. A waiter who overstates his case is no waiter at all, so I will say this plainly: for most production workloads, the native full-refresh behavior is what you will be managing.

Pitfall 6: cascade dependencies are your problem

When one materialized view reads from another, you have created a dependency chain. PostgreSQL does not track or enforce this dependency. It does not know that mv_regional_reports reads from mv_order_summaries. It does not prevent you from refreshing them in the wrong order. It does not halt a cascade when an upstream refresh fails.

-- Matview B depends on matview A.
-- You must refresh A before B. PostgreSQL will not do this for you.

REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_summaries;   -- A
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_regional_reports;  -- B (reads from A)

-- If you refresh B first, it reads stale data from A.
-- If A's refresh fails, B's refresh succeeds with stale data.
-- There is no dependency tracking. No transaction wrapping.
-- You are the orchestrator.

Two matviews are manageable. You refresh A, then B, and you remember the order. Five matviews with a diamond dependency — where C depends on both A and B, and D depends on C — is an orchestration problem. Ten matviews with a complex dependency graph is a system you will eventually regret building.

-- A proper refresh scheduler for dependent matviews.
-- This is the kind of thing you will eventually build,
-- whether you planned to or not.

DO $$
DECLARE
    refresh_start TIMESTAMPTZ;
BEGIN
    -- Layer 1: base matviews (read from source tables only)
    refresh_start := clock_timestamp();
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_summaries;
    RAISE NOTICE 'mv_order_summaries: %ms',
        EXTRACT(EPOCH FROM clock_timestamp() - refresh_start) * 1000;

    -- Layer 2: depends on layer 1
    refresh_start := clock_timestamp();
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_regional_reports;
    RAISE NOTICE 'mv_regional_reports: %ms',
        EXTRACT(EPOCH FROM clock_timestamp() - refresh_start) * 1000;

    -- Layer 3: depends on layer 2
    refresh_start := clock_timestamp();
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_executive_dashboard;
    RAISE NOTICE 'mv_executive_dashboard: %ms',
        EXTRACT(EPOCH FROM clock_timestamp() - refresh_start) * 1000;

EXCEPTION WHEN OTHERS THEN
    RAISE WARNING 'Cascade refresh failed at: %', SQLERRM;
    -- If layer 1 fails, layers 2 and 3 must not run.
    -- If layer 2 fails, layer 3 must not run.
    -- You are handling this in a DO block exception handler.
    -- In production, this becomes a proper job queue.
END $$;

In practice, this means building a refresh scheduler that understands the dependency graph, handles failures gracefully, logs timing for each layer, and alerts you when something goes wrong. This is not database work. This is infrastructure work — the kind of operational burden that the elegant simplicity of CREATE MATERIALIZED VIEW did not warn you about.

If you find yourself building a cascade refresh scheduler, pause and consider whether you have outgrown manual matview management. The complexity curve is steep, and it steepens with every new view you add.

"What this book has taught you to do by hand, Gold Lapel does automatically. What this book has taught you to monitor, it monitors. What this book has taught you to schedule, it schedules."

— from You Don't Need Redis, Chapter 14: Deployment: The Staff Is Being Assembled

Pitfall 7: Row-Level Security does not apply

This one is a security concern, and I raise it with some urgency.

-- Row-Level Security policies do NOT apply to materialized views.
-- The matview is populated by the user who runs REFRESH.
-- Anyone who can SELECT from the matview sees all rows.

-- Your policy on the source table:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.tenant_id')::int);

-- Your matview:
CREATE MATERIALIZED VIEW mv_order_stats AS
SELECT tenant_id, COUNT(*), SUM(total)
FROM orders GROUP BY tenant_id;

-- The matview contains data from ALL tenants.
-- A user with SELECT on the matview sees every tenant's data.
-- RLS on the source table is completely bypassed.

-- Fix: restrict access to the matview itself.
REVOKE SELECT ON mv_order_stats FROM PUBLIC;
GRANT SELECT ON mv_order_stats TO reporting_role;

-- Or add a WHERE clause in your application queries against the matview.
-- But that is your responsibility, not PostgreSQL's.

Materialized views are populated by the user who executes the REFRESH command — typically a privileged service account. The RLS policies on the source table are evaluated at refresh time against that user's context, not the end user's context. If your service account bypasses RLS (as most superuser or owner accounts do), the matview contains every row from every tenant.

Anyone with SELECT on the matview sees all of it. Your carefully constructed tenant isolation policy is bypassed entirely. The matview is a physical copy of the data, and physical copies do not carry RLS policies.

The fix is to manage access at the matview level — revoke broad SELECT, grant only to roles that should see all data, and filter in your application queries. But this requires awareness. If you are adopting materialized views in a multi-tenant system with RLS, please read this section twice.

Pitfall 8: testing materialized views is genuinely difficult

-- Testing matview behavior is harder than testing regular queries.

-- Problem 1: REFRESH is not transactional in the way you expect.
-- You cannot REFRESH inside a test transaction and then ROLLBACK.
-- The matview is a physical table. The refresh commits its own work.

-- Problem 2: Staleness is time-dependent.
-- Your test inserts data, but the matview shows the old state.
-- You must REFRESH explicitly in the test, which couples your
-- test to the matview's existence and refresh timing.

-- Problem 3: CONCURRENTLY cannot run inside a transaction block.
BEGIN;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;
-- ERROR: REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run
--        inside a transaction block
ROLLBACK;

-- Your test harness wraps everything in a transaction for cleanup.
-- CONCURRENTLY refuses. Standard REFRESH works in transactions
-- but behaves differently from production (no diff, no dead tuples).

Most test harnesses wrap each test in a transaction and roll back afterward for clean isolation. REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run inside a transaction block. Standard REFRESH can, but it behaves differently from the concurrent refresh your production code uses — no diff, no dead tuples, different locking behavior.

This means your tests either use standard refresh (which does not match production) or require manual cleanup between tests (which is slow and fragile). Neither option is satisfying. The practical solution is to abstract matview reads behind a query layer — a function or view that can be swapped to read from the source tables in test environments. This adds indirection, but it makes the matviews testable without coupling every test to the refresh lifecycle.

I raise this not because it is insurmountable, but because it is the kind of friction that compounds. One matview in your test suite is a minor annoyance. Ten matviews, each requiring explicit refresh and cleanup, is a test suite that developers stop running locally.

The pitfalls at a glance

PitfallSymptomMitigation
Stale dataDashboard shows yesterday's numbersTrack refresh timestamps, set appropriate intervals
Blocking refreshQueries hang during refreshUse REFRESH CONCURRENTLY with unique index
CONCURRENTLY overheadRefresh takes 2-3x longer than expectedAccept the trade-off or schedule during low traffic
NULL uniquenessCONCURRENTLY fails on matviews with NULLsCOALESCE NULLs in the view or index expression
Disk bloatDisk usage grows without explanationMonitor matview sizes, tune autovacuum per matview
Refresh CPU costCPU spikes on scheduleStagger refresh intervals, optimize source queries
Wasted refreshesCPU spent refreshing unchanged dataTrigger refresh on writes, not on timers
Cascade stalenessDependent views show inconsistent dataRefresh in dependency order, halt on failure
No incremental refreshFull recomputation every cycleUse pg_ivm for eligible queries, or accept the cost
RLS bypassUsers see data from other tenantsApply access controls to the matview itself
Testing difficultyTests coupled to refresh timingAbstract matview reads behind a query layer
Missing CONCURRENTLY indexError on concurrent refreshCreate unique index covering all matview rows

When materialized views are still the right answer

I have spent rather a lot of paragraphs cataloguing what can go wrong. Allow me to be equally clear about what goes right, because materialized views are genuinely excellent for specific use cases. I would not want to leave you with the impression that they should be avoided. They should be understood.

  • Dashboard aggregations where staleness of 5-15 minutes is acceptable. The 200x query speedup easily justifies the refresh overhead. A dashboard that queries mv_daily_revenue instead of aggregating 10 million orders on every page load is not merely faster — it is a fundamentally different class of user experience.
  • Report generation where the data is refreshed once before the report runs. Staleness is not an issue because the refresh precedes the read. This is the ideal use case: predictable timing, known data requirements, no concurrent access during refresh.
  • Denormalized lookup tables that combine data from multiple sources and are queried frequently. Refresh nightly or on a schedule, query instantly during business hours. If the source data changes slowly and the read pattern is heavy, the matview is a net win by a wide margin.
  • Expensive joins that serve multiple consumers. When five different application endpoints all need the same four-table join with the same aggregation, computing it once in a matview and indexing it is dramatically cheaper than running the join five times per request.

The key is matching the refresh frequency to the staleness tolerance, and being honest about the operational complexity you are taking on. One well-managed matview for a dashboard aggregation is a clear win. Fifteen matviews with interdependencies, inconsistent refresh schedules, and no monitoring is a system that will cause you grief at 3 AM.

A brief word on alternatives

Before you commit to materialized views, consider whether a simpler mechanism solves your problem.

  • A well-designed index may reduce your query from 400ms to 4ms without any refresh overhead. If the bottleneck is a missing index rather than an inherently expensive computation, a matview is the wrong tool. Check EXPLAIN ANALYZE first.
  • A regular view with a covering index gives you the abstraction of a named query without the physical copy. For simple joins and filters, this may be sufficient.
  • Application-level caching (Redis, Memcached) gives you fine-grained control over invalidation and TTL. The trade-off is an additional moving part in your infrastructure, but the caching logic lives in your application where you can reason about it directly.
  • Query optimization — rewriting the query itself to be faster. A 400ms query that can be rewritten to 5ms with a CTE restructure or a join order hint does not need materialization.

Materialized views are most compelling when the query is inherently expensive — complex joins with aggregation over large datasets — and the read frequency is high relative to the write frequency. If your query is expensive and rare, a matview wastes space. If your query is cheap and frequent, an index suffices. The sweet spot is expensive and frequent, with tolerable staleness.

What write-aware refresh looks like

The core problem with PostgreSQL's native refresh mechanism is that it is blind to writes. It refreshes on a schedule — every 5 minutes, every hour, every night — regardless of whether the source data has changed. This produces two failure modes: wasted compute when nothing changed, and stale data when changes arrive between scheduled refreshes.

Gold Lapel takes a different approach. Because it sits between the application and the database as a transparent proxy, it observes both the read queries (which matviews accelerate) and the write queries (which invalidate matview data). It triggers a refresh only when the underlying data has actually changed — and only for the matviews affected by that specific change.

When pg_ivm is available, Gold Lapel creates incrementally maintained materialized views for eligible queries. Writes propagate to the matview via triggers — no scheduled refresh needed at all. For queries that fall outside pg_ivm's constraints, Gold Lapel falls back to standard matviews with write-driven refresh: the proxy detects the write, marks the affected matview as stale, and refreshes it before the next read. The write traffic itself becomes the signal.

The cascade dependency problem disappears, because the proxy maintains the dependency graph and refreshes in the correct order. The RLS concern is addressed through automatic predicate injection. The disk bloat is managed through cold cleanup — matviews that stop receiving queries are dropped automatically.

I am not suggesting that Gold Lapel eliminates every pitfall. It does not change the fact that CONCURRENTLY is slower than standard refresh, or that non-aggregating matviews consume significant disk. It does, however, address the operational pitfalls — the scheduling, the monitoring, the dependency management, the wasted refreshes — that account for most of the production grief.

If you prefer to manage materialized views yourself, the pitfalls above are your inventory, and I trust you will manage them capably. If you would rather the matter were attended to on your behalf — well. That is precisely the service we provide.

Frequently asked questions

Terms referenced in this article

The refresh problem is, in my experience, the one that sends most teams looking for help. I have written a book chapter on automatic materialized view refresh strategies that addresses the staleness question with rather more thoroughness than a blog post permits.