Back to Blog

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.

Viprasol Tech Team
January 15, 2027
13 min read

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

ScenarioUse 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_ivmRedis cache
Query runs millions of times/hourβœ… Yes
Query is fast but called from many places❌ NoRegular 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 DISTINCT in 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

QueryWithout MVWith MVSpeedup
Team MRR (50K subscriptions)1,200ms8ms150Γ—
Project task stats (10M tasks)3,800ms12ms316Γ—
DAU chart (30 days, 100M events)45,000ms15ms3,000Γ—
User growth curve8,500ms18ms472Γ—

Cost and Timeline

TaskTimelineCost (USD)
Identify slow queries and design MVs0.5–1 day$400–$800
Create MVs + unique indexes0.5 day$400
pg_cron refresh schedule setup0.5 day$300–$500
Trigger-based refresh queue1–2 days$800–$1,600
Application query routing to MVs0.5–1 day$400–$800
Full analytics refresh pipeline1.5–2 weeks$5,000–$9,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol Β· Web Development

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.