PostgreSQL Materialized Views in 2026: Refresh Strategies, Incremental Updates, and Query Rewriting
Master PostgreSQL materialized views: concurrent refresh, incremental updates with pg_ivm, query rewriting, refresh scheduling, and dashboard query patterns for SaaS analytics.
PostgreSQL Materialized Views in 2026: Refresh Strategies, Incremental Updates, and Query Rewriting
A materialized view is a saved query result stored as a physical table. Unlike regular views (which re-execute the query on every access), a materialized view lets you pay the computation cost once and read it many times at near-zero cost.
For SaaS dashboardsβMRR charts, usage analytics, team activity summariesβmaterialized views can turn 5-second analytics queries into 10ms reads. This post covers creation, refresh strategies, the new pg_ivm extension for incremental refresh, and patterns for keeping dashboard data fresh without hammering your production database.
When to Use Materialized Views
| Scenario | Use Materialized View? | Alternative |
|---|---|---|
| Dashboard query takes >500ms | β Yes | |
| Query joins 5+ large tables | β Yes | |
| Data is acceptable 5β60 min stale | β Yes | |
| Real-time required (< 1 min stale) | β οΈ Maybe with pg_ivm | Redis cache |
| Query runs millions of times/hour | β Yes | |
| Query is fast but called from many places | β No | Regular view |
| Data is updated infrequently | β Yes |
Creating Materialized Views
-- Team MRR snapshot (recomputed from subscription events)
CREATE MATERIALIZED VIEW team_mrr_snapshot AS
SELECT
s.team_id,
t.name AS team_name,
t.plan,
COUNT(DISTINCT s.id) AS active_subscriptions,
SUM(
CASE
WHEN s.billing_period = 'monthly' THEN s.amount
WHEN s.billing_period = 'annual' THEN s.amount / 12.0
END
) AS mrr_usd,
MAX(s.created_at) AS latest_subscription_at,
now() AS computed_at
FROM subscriptions s
JOIN teams t ON t.id = s.team_id
WHERE s.status = 'active'
GROUP BY s.team_id, t.name, t.plan;
-- Always create a unique index for CONCURRENT refresh
CREATE UNIQUE INDEX team_mrr_snapshot_team_id ON team_mrr_snapshot(team_id);
-- Per-project task analytics
CREATE MATERIALIZED VIEW project_task_stats AS
SELECT
p.id AS project_id,
p.team_id,
p.name AS project_name,
COUNT(*) AS total_tasks,
COUNT(*) FILTER (WHERE t.status = 'done') AS completed_tasks,
COUNT(*) FILTER (WHERE t.status = 'in_progress') AS in_progress_tasks,
COUNT(*) FILTER (WHERE t.due_date < now() AND t.status != 'done') AS overdue_tasks,
ROUND(
100.0 * COUNT(*) FILTER (WHERE t.status = 'done') / NULLIF(COUNT(*), 0),
1
) AS completion_pct,
AVG(
EXTRACT(EPOCH FROM (t.completed_at - t.created_at)) / 3600
) FILTER (WHERE t.completed_at IS NOT NULL) AS avg_completion_hours,
MAX(t.updated_at) AS last_activity_at,
now() AS computed_at
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id AND t.deleted_at IS NULL
WHERE p.deleted_at IS NULL
GROUP BY p.id, p.team_id, p.name;
CREATE UNIQUE INDEX project_task_stats_project_id ON project_task_stats(project_id);
CREATE INDEX project_task_stats_team_id ON project_task_stats(team_id);
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Refresh Strategies
1. Full Refresh (Simple)
-- Blocking: takes a lock during refresh β use for non-critical data
REFRESH MATERIALIZED VIEW team_mrr_snapshot;
-- Non-blocking: requires a unique index, allows reads during refresh
REFRESH MATERIALIZED VIEW CONCURRENTLY team_mrr_snapshot;
Key difference: CONCURRENTLY computes the new data in a temp table, then swaps it in β reads continue uninterrupted. Always prefer CONCURRENTLY for production.
2. Scheduled Refresh via pg_cron
-- Install pg_cron extension (available on RDS, Aurora, Cloud SQL)
CREATE EXTENSION pg_cron;
-- Refresh MRR snapshot every 15 minutes
SELECT cron.schedule(
'refresh-mrr-snapshot',
'*/15 * * * *', -- Every 15 minutes
$$REFRESH MATERIALIZED VIEW CONCURRENTLY team_mrr_snapshot$$
);
-- Refresh task stats every 5 minutes during business hours
SELECT cron.schedule(
'refresh-task-stats',
'*/5 8-20 * * 1-5', -- Every 5 min, MonβFri, 8AMβ8PM
$$REFRESH MATERIALIZED VIEW CONCURRENTLY project_task_stats$$
);
-- Verify scheduled jobs
SELECT jobid, schedule, command, active FROM cron.job;
3. Trigger-Based Refresh (Near Real-Time)
For data that needs to be fresher but you still want the caching benefit:
-- Track when source tables change
CREATE TABLE materialized_view_refresh_queue (
view_name TEXT NOT NULL,
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (view_name) -- ON CONFLICT DO NOTHING for deduplication
);
-- Trigger: queue refresh when subscriptions change
CREATE OR REPLACE FUNCTION queue_mrr_refresh()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO materialized_view_refresh_queue (view_name)
VALUES ('team_mrr_snapshot')
ON CONFLICT (view_name) DO UPDATE SET queued_at = now();
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_subscriptions_changed
AFTER INSERT OR UPDATE OR DELETE ON subscriptions
FOR EACH STATEMENT
EXECUTE FUNCTION queue_mrr_refresh();
Then a worker checks the queue and refreshes:
// lib/db/refresh-worker.ts
import { pool } from "@/lib/db";
export async function processRefreshQueue() {
const client = await pool.connect();
try {
// Acquire advisory lock β only one refresh worker runs at a time
const acquired = await client.query(
`SELECT pg_try_advisory_lock(hashtext('mv-refresh-worker')) as acquired`
);
if (!acquired.rows[0].acquired) return; // Another worker is running
// Get views that need refreshing
const pending = await client.query<{ view_name: string }>(
`DELETE FROM materialized_view_refresh_queue
WHERE queued_at < now() - INTERVAL '10 seconds' -- 10-second debounce
RETURNING view_name`
);
for (const row of pending.rows) {
const viewName = row.view_name.replace(/[^a-z_]/g, ""); // Sanitize
console.log(`Refreshing materialized view: ${viewName}`);
await client.query(
`REFRESH MATERIALIZED VIEW CONCURRENTLY ${viewName}`
);
}
} finally {
await client.query(`SELECT pg_advisory_unlock(hashtext('mv-refresh-worker'))`);
client.release();
}
}
Incremental Refresh with pg_ivm
The pg_ivm extension enables incremental materialized view maintenance β instead of recomputing the full result, it applies only the changes (INSERT/UPDATE/DELETE deltas). This can be 10β100x faster for large tables.
-- Install pg_ivm (available as an extension on major providers)
CREATE EXTENSION pg_ivm;
-- Create an incrementally maintainable materialized view
SELECT create_immv(
'daily_active_users',
$$
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(DISTINCT user_id) AS dau
FROM page_views
WHERE created_at >= now() - INTERVAL '90 days'
GROUP BY 1
$$
);
-- pg_ivm creates triggers that automatically keep this up to date
-- No manual REFRESH needed β updates apply incrementally on each INSERT/DELETE
pg_ivm limitations (as of 2026):
- No support for OUTER JOINs or
DISTINCTin the view query - No window functions
- Works best for aggregation-only views (COUNT, SUM, AVG)
- Available on PostgreSQL 14+, some managed providers (check docs before using)
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Query Rewriting Patterns
PostgreSQL doesn't automatically rewrite queries to use materialized views (unlike some other databases). You need to route your application queries to the view explicitly:
// lib/analytics/dashboard.ts
import { db } from "@/lib/db";
// β Slow β recomputes from raw data every time
export async function getTeamMRRSlow(teamId: string) {
return db.$queryRaw`
SELECT SUM(
CASE
WHEN billing_period = 'monthly' THEN amount
WHEN billing_period = 'annual' THEN amount / 12.0
END
) as mrr
FROM subscriptions
WHERE team_id = ${teamId}::uuid AND status = 'active'
`;
}
// β
Fast β reads from materialized view
export async function getTeamMRR(teamId: string) {
const result = await db.$queryRaw<[{ mrr_usd: number; computed_at: Date }]>`
SELECT mrr_usd, computed_at
FROM team_mrr_snapshot
WHERE team_id = ${teamId}::uuid
`;
return {
mrr: result[0]?.mrr_usd ?? 0,
asOf: result[0]?.computed_at ?? new Date(),
};
}
// Dashboard endpoint β combines multiple materialized views
export async function getDashboardStats(teamId: string) {
const [mrr, taskStats, recentActivity] = await Promise.all([
db.$queryRaw<[{ mrr_usd: number; active_subscriptions: number }]>`
SELECT mrr_usd, active_subscriptions
FROM team_mrr_snapshot
WHERE team_id = ${teamId}::uuid
`,
db.$queryRaw<Array<{ project_name: string; completion_pct: number; overdue_tasks: number }>>`
SELECT project_name, completion_pct, overdue_tasks
FROM project_task_stats
WHERE team_id = ${teamId}::uuid
ORDER BY last_activity_at DESC
LIMIT 10
`,
db.activity.findMany({
where: { teamId },
orderBy: { createdAt: "desc" },
take: 5,
}),
]);
return {
mrr: mrr[0]?.mrr_usd ?? 0,
activeSubscriptions: mrr[0]?.active_subscriptions ?? 0,
topProjects: taskStats,
recentActivity,
};
}
Monitoring Materialized View Freshness
-- Check when each materialized view was last refreshed
-- (PostgreSQL doesn't track this natively β add computed_at to your views)
SELECT
schemaname,
matviewname,
hasindexes,
ispopulated
FROM pg_matviews
ORDER BY matviewname;
-- Custom freshness check using embedded computed_at column
SELECT
'team_mrr_snapshot' AS view_name,
computed_at,
now() - computed_at AS age,
CASE
WHEN now() - computed_at > INTERVAL '1 hour' THEN 'STALE'
WHEN now() - computed_at > INTERVAL '15 minutes' THEN 'AGING'
ELSE 'FRESH'
END AS freshness
FROM team_mrr_snapshot
LIMIT 1;
API Response with Freshness Metadata
// app/api/dashboard/route.ts
export async function GET(req: NextRequest) {
const { teamId } = await getTeamFromRequest(req);
const stats = await getDashboardStats(teamId);
return NextResponse.json(
{
data: stats,
meta: {
// Tell the client when data was computed β show "as of X" in the UI
mrrAsOf: stats.mrrComputedAt,
cacheStatus: "materialized",
},
},
{
headers: {
// Allow browser to cache for 5 minutes
"Cache-Control": "private, max-age=300, stale-while-revalidate=60",
},
}
);
}
Performance Comparison
| Query | Without MV | With MV | Speedup |
|---|---|---|---|
| Team MRR (50K subscriptions) | 1,200ms | 8ms | 150Γ |
| Project task stats (10M tasks) | 3,800ms | 12ms | 316Γ |
| DAU chart (30 days, 100M events) | 45,000ms | 15ms | 3,000Γ |
| User growth curve | 8,500ms | 18ms | 472Γ |
Cost and Timeline
| Task | Timeline | Cost (USD) |
|---|---|---|
| Identify slow queries and design MVs | 0.5β1 day | $400β$800 |
| Create MVs + unique indexes | 0.5 day | $400 |
| pg_cron refresh schedule setup | 0.5 day | $300β$500 |
| Trigger-based refresh queue | 1β2 days | $800β$1,600 |
| Application query routing to MVs | 0.5β1 day | $400β$800 |
| Full analytics refresh pipeline | 1.5β2 weeks | $5,000β$9,000 |
See Also
- PostgreSQL Window Functions β Analytics queries inside materialized views
- PostgreSQL Partitioning β Partitioning the underlying tables
- PostgreSQL Advisory Locks β Coordinating refresh workers
- SaaS Dunning Management β MRR tracking and subscription analytics
Working With Viprasol
We optimize PostgreSQL analytics for SaaS dashboardsβfrom identifying slow queries through implementing full materialized view pipelines with automated refresh. Our team has reduced dashboard load times by 100β500Γ for clients with millions of records.
What we deliver:
- Slow query identification and materialized view design
- CONCURRENT refresh scheduling with pg_cron
- Trigger-based refresh for near-real-time data
- pg_ivm incremental refresh for high-write tables
- Freshness monitoring and alerting
Explore our web development services or contact us to speed up your PostgreSQL analytics.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.