Skip to content
SP StackPractices
intermediate By StackPractices

Materialized View Pattern

Precompute and store expensive query results in a read-optimized cache to avoid repeated costly aggregation or joins across large datasets.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Materialized View Pattern

Overview

The Materialized View Pattern precomputes and stores expensive query results in a dedicated, read-optimized table or cache. Instead of executing complex aggregations, joins, or full-table scans on every read request, the results are computed once and served directly from the materialized view.

Unlike standard database views (which compute results on-the-fly), materialized views store actual data that must be refreshed when underlying data changes. This trade-off sacrifices real-time consistency for dramatically improved read performance — often reducing query time from seconds to milliseconds.

Common use cases include dashboards, analytics, search indexes, and microservice read models that require data from multiple sources.

When to Use

  • Complex aggregation queries (SUM, COUNT, AVG) over large datasets that run frequently
  • Joins across multiple tables that are too expensive for real-time execution
  • Building read models for CQRS or event-driven architectures
  • Analytics dashboards with consistent query patterns
  • Search or filtering endpoints that need precomputed indexes
  • Microservices where a service needs data owned by other services

When to Avoid

  • Data freshness is critical and cannot tolerate even seconds of staleness
  • Underlying data changes extremely frequently, making refresh overhead exceed query savings
  • Simple queries that already execute in milliseconds
  • Small datasets where indexes suffice
  • When strong consistency is more important than read performance

Solution

Python (Django with Scheduled Refresh)

from django.db import models, connection
from django.core.management.base import BaseCommand
from django.utils import timezone
from celery import shared_task
import logging

logger = logging.getLogger(__name__)

# Source models
class Order(models.Model):
    customer_id = models.IntegerField()
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField(auto_now_add=True)

# Materialized view model
class DailyRevenueView(models.Model):
    date = models.DateField(primary_key=True)
    total_revenue = models.DecimalField(max_digits=15, decimal_places=2)
    order_count = models.IntegerField()
    last_refreshed = models.DateTimeField()

    class Meta:
        managed = False  # Django doesn't manage this table
        db_table = 'mv_daily_revenue'

# Full refresh task
@shared_task
def refresh_daily_revenue_view():
    """Complete rebuild of the materialized view"""
    with connection.cursor() as cursor:
        cursor.execute("""
            DROP TABLE IF EXISTS mv_daily_revenue;
            CREATE TABLE mv_daily_revenue AS
            SELECT
                DATE(created_at) as date,
                SUM(amount) as total_revenue,
                COUNT(*) as order_count,
                NOW() as last_refreshed
            FROM orders_order
            WHERE status = 'completed'
            GROUP BY DATE(created_at);
            CREATE INDEX idx_mv_date ON mv_daily_revenue(date);
        """)
    logger.info("Daily revenue materialized view refreshed")

# Incremental refresh (more efficient for large datasets)
@shared_task
def incremental_refresh_daily_revenue():
    """Only update rows affected since last refresh"""
    with connection.cursor() as cursor:
        # Find the most recent date in the view
        cursor.execute("SELECT MAX(date) FROM mv_daily_revenue")
        last_date = cursor.fetchone()[0]

        if last_date:
            # Delete stale data and recompute
            cursor.execute("""
                DELETE FROM mv_daily_revenue
                WHERE date >= %s;
            """, [last_date])

            cursor.execute("""
                INSERT INTO mv_daily_revenue
                SELECT
                    DATE(created_at) as date,
                    SUM(amount) as total_revenue,
                    COUNT(*) as order_count,
                    NOW() as last_refreshed
                FROM orders_order
                WHERE status = 'completed'
                  AND DATE(created_at) >= %s
                GROUP BY DATE(created_at);
            """, [last_date])
        else:
            # Full refresh if empty
            refresh_daily_revenue_view.delay()

    logger.info("Incremental refresh completed")

# Query the view
class RevenueService:
    def get_revenue_for_date(self, date):
        try:
            return DailyRevenueView.objects.get(date=date)
        except DailyRevenueView.DoesNotExist:
            return None

    def get_revenue_range(self, start_date, end_date):
        return DailyRevenueView.objects.filter(
            date__range=(start_date, end_date)
        ).order_by('date')

Java (Spring with JPA and @Scheduled)

import jakarta.persistence.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

// Read-only entity for the materialized view
@Entity
@Table(name = "mv_product_stats")
public class ProductStatsView {
    @Id
    private Long productId;
    private String productName;
    private Long totalSold;
    private BigDecimal totalRevenue;
    private Double averageRating;
    private LocalDate lastRefreshed;

    // getters...
}

@Service
public class ProductStatsRefreshService {

    private final EntityManager entityManager;

    public ProductStatsRefreshService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Scheduled(cron = "0 0 2 * * ?") // Daily at 2 AM
    @Transactional
    public void refreshProductStats() {
        entityManager.createNativeQuery("""
            DROP TABLE IF EXISTS mv_product_stats;
            CREATE TABLE mv_product_stats AS
            SELECT
                p.id as product_id,
                p.name as product_name,
                COALESCE(SUM(oi.quantity), 0) as total_sold,
                COALESCE(SUM(oi.quantity * oi.price), 0) as total_revenue,
                COALESCE(AVG(r.rating), 0) as average_rating,
                CURRENT_DATE as last_refreshed
            FROM products p
            LEFT JOIN order_items oi ON p.id = oi.product_id
            LEFT JOIN reviews r ON p.id = r.product_id
            GROUP BY p.id, p.name;
            CREATE INDEX idx_mv_product ON mv_product_stats(product_id);
        """).executeUpdate();
    }
}

// Repository for querying
public interface ProductStatsRepository
        extends JpaRepository<ProductStatsView, Long> {

    List<ProductStatsView> findByTotalSoldGreaterThan(Long minSold);

    @Query("SELECT p FROM ProductStatsView p ORDER BY p.totalRevenue DESC")
    List<ProductStatsView> findTopRevenue();
}

SQL (PostgreSQL Native Materialized Views)

-- Create materialized view
CREATE MATERIALIZED VIEW mv_order_summary AS
SELECT
    customer_id,
    COUNT(*) as total_orders,
    SUM(amount) as total_spent,
    MAX(created_at) as last_order_date,
    AVG(amount) as avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY customer_id;

-- Index for fast lookups
CREATE UNIQUE INDEX idx_mv_customer_id ON mv_order_summary(customer_id);

-- Query the view (milliseconds vs seconds on raw tables)
SELECT * FROM mv_order_summary
WHERE customer_id = 12345;

-- Full refresh
REFRESH MATERIALIZED VIEW mv_order_summary;

-- Concurrent refresh (doesn't lock reads)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_summary;

-- Incremental refresh using triggers (manual implementation)
CREATE TABLE mv_customer_summary (
    customer_id INT PRIMARY KEY,
    total_orders INT DEFAULT 0,
    total_spent DECIMAL(12,2) DEFAULT 0,
    last_order_date TIMESTAMP
);

-- Populate initially
INSERT INTO mv_customer_summary
SELECT
    customer_id,
    COUNT(*),
    SUM(amount),
    MAX(created_at)
FROM orders
GROUP BY customer_id;

-- Incremental update trigger
CREATE OR REPLACE FUNCTION refresh_customer_summary()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO mv_customer_summary (customer_id, total_orders, total_spent, last_order_date)
    VALUES (NEW.customer_id, 1, NEW.amount, NEW.created_at)
    ON CONFLICT (customer_id) DO UPDATE SET
        total_orders = mv_customer_summary.total_orders + 1,
        total_spent = mv_customer_summary.total_spent + NEW.amount,
        last_order_date = GREATEST(mv_customer_summary.last_order_date, NEW.created_at);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_insert_trigger
AFTER INSERT ON orders
FOR EACH ROW
EXECUTE FUNCTION refresh_customer_summary();

Explanation

Materialized views solve the read/write trade-off by separating the write-optimized source tables from the read-optimized view:

  • Write path: Inserts/updates go to normalized, transactionally consistent source tables
  • Read path: Queries hit the denormalized materialized view with precomputed results
  • Refresh path: A background process reconciles the view with source data on a schedule or via triggers

The refresh strategy determines staleness:

  • Full refresh: Rebuild entire view — simple but expensive
  • Incremental refresh: Only update changed rows — complex but efficient
  • Trigger-based: Real-time updates on every write — consistent but impacts write latency
  • Scheduled: Periodic batch refresh — balanced for most use cases

Variants

VariantRefresh StrategyBest For
Database-nativeREFRESH MATERIALIZED VIEWPostgreSQL, Oracle, SQL Server environments
Application-managedScheduled job/taskCross-database or NoSQL environments
Event-drivenListen to CDC/eventsReal-time analytics, event sourcing
CQRS read modelProjection from eventsMicroservices with separate read/write models
Cache-basedRedis/Memcached with TTLHigh-throughput, eventually consistent reads

Best Practices

  • Use concurrent refresh when available. PostgreSQL’s REFRESH CONCURRENTLY allows reads during refresh.
  • Index the materialized view. Without indexes, the view may not outperform the raw query.
  • Measure refresh cost vs query savings. Refresh every 5 minutes only if queries run every second.
  • Document staleness expectations. Users should know data is N minutes behind.
  • Monitor refresh failures. A stale view is worse than no view if users rely on it.

Common Mistakes

  • Refreshing too frequently. The overhead of refresh may exceed the cost of just running the query.
  • Not indexing the view. A heap-scan materialized view is often slower than an indexed source table.
  • Treating views as source of truth. Materialized views are caches — always have canonical data.
  • Ignoring concurrent refresh locking. Non-concurrent refresh locks the view, causing query timeouts.
  • Forgetting to handle deletes. Incremental refresh that only handles inserts misses deletions.

Real-World Examples

Airbnb

Airbnb uses materialized views in their search infrastructure. The search index is a materialized view rebuilt periodically from the canonical listings database. Complex geospatial queries, availability calculations, and pricing aggregations are precomputed so search returns in milliseconds.

GitHub

GitHub’s contribution graphs and repository statistics are materialized views. Computing commit counts, PR merge rates, and contributor activity across millions of repositories in real-time is impossible — these are precomputed and refreshed on a schedule.

Shopify

Shopify uses materialized views for merchant analytics dashboards. Revenue, order counts, and inventory levels are aggregated nightly into materialized views. Merchants see “yesterday’s” data instantly rather than waiting for live aggregation across billions of rows.

Frequently Asked Questions

Q: How do I choose between a standard view and a materialized view? A: Use a standard view when data freshness is critical and query performance is acceptable. Use a materialized view when the query is expensive and staleness of minutes/hours is acceptable.

Q: What is the difference between materialized views and caching? A: Materialized views are typically stored in the same database and refreshed systematically. Caches (Redis) are external, usually TTL-based, and invalidated more aggressively. Many systems use both.

Q: How stale should a materialized view be? A: Depends on user expectations. Analytics dashboards: hours. Search indexes: minutes. Payment ledgers: never — use real-time queries instead.

Q: Can I use materialized views with microservices? A: Yes — CQRS uses materialized views as read models. Events from multiple services feed into a read-model builder that maintains a materialized view local to the querying service.

Q: What databases support materialized views natively? A: PostgreSQL, Oracle, SQL Server, BigQuery, Snowflake, and Redshift have native support. MySQL and MongoDB require application-level implementations.