← Back to list

Day 16 of 32 Days of SQL Concepts — Materialized Views

Materialized views represent a sophisticated architectural pattern addressing the fundamental tension between computational efficiency and…

Karthik · 2026-06-05 18:01 · 51 claps · 16.2 min read
#data-engineering #data-science #technology #software-development #software-engineering
Open on Medium ↗
Wiki topics: ML · Machine Learning 🔧 · Data Engineering 🔬 · Science · General 🏛️ · Architecture

Day 16 of 32 Days of SQL Concepts — Materialized Views

Materialized views represent a sophisticated architectural pattern addressing the fundamental tension between computational efficiency and data currency. Where standard views execute queries dynamically upon each reference, materialized views persist precomputed result sets as physical tables, trading currency against performance. For a data engineer with your background, understanding materialized view architecture, refresh strategies, and optimization patterns constitutes essential knowledge for building scalable analytics infrastructure.

Materialized views bridge conceptual distance between transactional databases and data warehouse architectures. They enable organizations to precompute expensive aggregations, joins, and transformations during periods of lower demand, serving these precomputed results to reporting applications during peak analytical load. This chapter addresses materialized view implementation across SQL Server, Azure SQL Database, and Apache Spark, providing comprehensive patterns for production environments.

Part One: Foundational Materialized View Concepts

1.1 Materialized Views versus Standard Views

The distinction between materialized and standard views fundamentally affects performance characteristics and refresh complexity.

Standard Views: Dynamic Execution

Standard views execute their underlying queries upon each reference:

-- Create standard view
CREATE VIEW vw_customer_summary AS
SELECT 
    c.customer_id,
    c.customer_name,
    c.region,
    COUNT(o.order_id) as order_count,
    SUM(o.order_amount) as total_spent,
    AVG(o.order_amount) as avg_order_value
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region;

-- Query execution
SELECT * FROM vw_customer_summary
WHERE region = 'EMEA';
-- Behind the scenes:
-- 1. Parse and bind the query
-- 2. Execute full join between customers and orders
-- 3. Calculate aggregates across all rows
-- 4. Filter to EMEA region
-- 5. Return results
-- Performance: Slow (full computation every execution)
-- Currency: Always current (reflects latest data)

Materialized Views: Precomputed Storage

Materialized views persist results as physical tables:

-- Create materialized view (simulated in SQL Server via indexed view)
CREATE VIEW vw_customer_summary_mv WITH SCHEMABINDING AS
SELECT 
    c.customer_id,
    c.customer_name,
    c.region,
    COUNT_BIG(o.order_id) as order_count,
    SUM(o.order_amount) as total_spent,
    AVG(o.order_amount) as avg_order_value,
    COUNT_BIG(*) as cnt  -- Required for aggregation
FROM dbo.customers c
LEFT JOIN dbo.orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region;

-- Create clustered index (materializes the view)
CREATE UNIQUE CLUSTERED INDEX ix_mv_customer_id
    ON vw_customer_summary_mv(customer_id);
-- Query execution
SELECT * FROM vw_customer_summary_mv
WHERE region = 'EMEA';
-- Behind the scenes:
-- 1. Precomputed results already in table
-- 2. Index seek to EMEA region customers
-- 3. Return cached results
-- Performance: Fast (table scan or index access)
-- Currency: Stale (current only until next refresh)

1.2 Storage and Performance Implications

Materialized views occupy physical disk space and memory:

-- Check materialized view size
SELECT 
    OBJECT_NAME(ps.object_id) as view_name,
    ps.index_type_desc,
    CAST((ps.page_count * 8.0 / 1024) AS DECIMAL(10,2)) as size_mb,
    ps.record_count
FROM sys.dm_db_partition_stats ps
INNER JOIN sys.views v ON ps.object_id = v.object_id
WHERE v.name = 'vw_customer_summary_mv';

-- Cost analysis
/*
Storage Cost: 45.5 MB
Computation Cost (if standard view): 2 seconds per execution
Executions per day: 10,000
Daily computation savings: 20,000 seconds = 5.5 hours
Break-even: After 23 seconds of daily refresh processing
*/

1.3 Refresh Strategies and Patterns

The choice of refresh strategy fundamentally affects materialized view utility.

Full Refresh Strategy

Complete regeneration of the entire materialized view:

-- Full refresh pattern
ALTER PROCEDURE sp_refresh_mv_customer_summary
AS
BEGIN
    BEGIN TRANSACTION;

    -- Truncate and repopulate approach
    TRUNCATE TABLE vw_customer_summary_mv;

    INSERT INTO vw_customer_summary_mv
    SELECT 
        c.customer_id,
        c.customer_name,
        c.region,
        COUNT_BIG(o.order_id) as order_count,
        SUM(o.order_amount) as total_spent,
        AVG(o.order_amount) as avg_order_value,
        COUNT_BIG(*) as cnt
    FROM dbo.customers c
    LEFT JOIN dbo.orders o ON c.customer_id = o.customer_id
    GROUP BY c.customer_id, c.customer_name, c.region;

    COMMIT TRANSACTION;

    -- Duration analysis
    /*
    Full refresh characteristics:
    - Processing time: O(n) where n = source data size
    - Downtime: None (materialized view inaccessible during refresh)
    - Locks: May block concurrent access
    - Network impact: High bandwidth consumption
    - Suitable for: Small to medium materialized views
    */
END;

-- Schedule for off-peak execution
EXEC sp_refresh_mv_customer_summary;

Incremental Refresh Strategy

Update only changed rows:

-- Incremental refresh pattern
-- Requires change tracking on source tables
ALTER TABLE customers ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS = ON);
ALTER TABLE orders ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS = ON);

ALTER PROCEDURE sp_refresh_mv_customer_summary_incremental
AS
BEGIN
    BEGIN TRANSACTION;

    -- Identify changed customer records
    CREATE TEMP TABLE changed_customers AS
    SELECT c.customer_id
    FROM customers c
    WHERE ISNULL(
        CHANGE_TRACKING_MIN_VALID_VERSION(OBJECT_ID('customers')),
        0
    ) <= SYS_CHANGE_VERSION
        AND SYS_CHANGE_OPERATION != 'D';

    -- Remove rows for changed customers from materialized view
    DELETE FROM vw_customer_summary_mv
    WHERE customer_id IN (SELECT customer_id FROM changed_customers);

    -- Reinsert updated rows
    INSERT INTO vw_customer_summary_mv
    SELECT 
        c.customer_id,
        c.customer_name,
        c.region,
        COUNT_BIG(o.order_id) as order_count,
        SUM(o.order_amount) as total_spent,
        AVG(o.order_amount) as avg_order_value,
        COUNT_BIG(*) as cnt
    FROM dbo.customers c
    LEFT JOIN dbo.orders o ON c.customer_id = o.customer_id
    WHERE c.customer_id IN (SELECT customer_id FROM changed_customers)
    GROUP BY c.customer_id, c.customer_name, c.region;

    -- Update change tracking version
    DECLARE @sync_version BIGINT;
    SET @sync_version = CHANGE_TRACKING_CURRENT_VERSION();

    DROP TABLE changed_customers;
    COMMIT TRANSACTION;

    -- Duration analysis
    /*
    Incremental refresh characteristics:
    - Processing time: O(m) where m = changed rows (typically m << n)
    - Downtime: None (materialized view accessible)
    - Locks: Minimal, only affected rows
    - Network impact: Low bandwidth consumption
    - Suitable for: Large materialized views with limited changes
    - Complexity: Higher implementation overhead
    */
END;
-- Can refresh more frequently with acceptable overhead
EXEC sp_refresh_mv_customer_summary_incremental;

Partition-Based Refresh

Refresh materialized views by partition:

-- Partition-based refresh (suitable for time-series data)
ALTER PROCEDURE sp_refresh_mv_sales_monthly
    @refresh_month DATE = NULL
AS
BEGIN
    IF @refresh_month IS NULL
        SET @refresh_month = CAST(DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1) AS DATE);

    DECLARE @month_start DATE = @refresh_month;
    DECLARE @month_end DATE = DATEADD(MONTH, 1, @month_start);

    BEGIN TRANSACTION;

    -- Remove existing partition data
    DELETE FROM mv_sales_summary
    WHERE order_month = @month_start;

    -- Reload current partition
    INSERT INTO mv_sales_summary
    SELECT 
        CAST(DATEFROMPARTS(YEAR(o.order_date), MONTH(o.order_date), 1) AS DATE) as order_month,
        p.product_id,
        p.product_name,
        COUNT_BIG(o.order_id) as order_count,
        SUM(o.order_amount) as total_sales,
        SUM(oi.quantity) as quantity_sold
    FROM dbo.orders o
    INNER JOIN dbo.order_items oi ON o.order_id = oi.order_id
    INNER JOIN dbo.products p ON oi.product_id = p.product_id
    WHERE o.order_date >= @month_start
        AND o.order_date < @month_end
    GROUP BY 
        CAST(DATEFROMPARTS(YEAR(o.order_date), MONTH(o.order_date), 1) AS DATE),
        p.product_id,
        p.product_name;

    COMMIT TRANSACTION;

    RETURN 0;
END;

-- Refresh current month: Fast (recent data only)
EXEC sp_refresh_mv_sales_monthly;
-- Refresh historical month (if restatement needed)
EXEC sp_refresh_mv_sales_monthly '2024-01-01';

Part Two: SQL Server Materialized View Patterns

2.1 Indexed Views (SQL Server’s Materialized View Implementation)

SQL Server achieves materialization through indexed views with specific requirements.

Creating Indexed Views

-- Step 1: Create view with SCHEMABINDING
CREATE VIEW dbo.vw_employee_sales_summary WITH SCHEMABINDING
AS
SELECT 
    e.employee_id,
    e.employee_name,
    e.department,
    COUNT_BIG(s.sales_id) as total_sales_count,
    SUM(s.sale_amount) as total_revenue,
    AVG(s.sale_amount) as avg_sale_amount,
    MIN(s.sale_date) as first_sale_date,
    MAX(s.sale_date) as most_recent_sale_date,
    COUNT_BIG(*) as record_count
FROM dbo.employees e
LEFT JOIN dbo.sales s ON e.employee_id = s.employee_id
GROUP BY 
    e.employee_id,
    e.employee_name,
    e.department;

-- Step 2: Create unique clustered index (materializes the view)
CREATE UNIQUE CLUSTERED INDEX ix_employee_sales_pk
    ON dbo.vw_employee_sales_summary(employee_id);
-- Step 3: Create non-clustered indexes for query optimization
CREATE NONCLUSTERED INDEX ix_employee_sales_dept
    ON dbo.vw_employee_sales_summary(department)
    INCLUDE (total_revenue, total_sales_count);
-- View is now materialized: occupies physical storage
-- Automatically maintained when base tables change
-- Query optimizer recognizes and uses indexed view in execution plans

SCHEMABINDING Requirements

SCHEMABINDING creates strong coupling between view and underlying tables:

-- Requirements for indexed views with SCHEMABINDING:
/*
1. All base tables must be schema-qualified (dbo.table_name, not table_name)
2. Cannot reference temporary tables or views
3. Base table modifications blocked if dependent indexed views exist
4. Aggregate functions restricted (COUNT_BIG required, not COUNT)
5. Cannot contain DISTINCT, TOP, ORDER BY, or UNION
6. Must be deterministic (same input always produces same output)
7. Text columns (TEXT, IMAGE) excluded from aggregations
8. Derived tables and subqueries not permitted in view definition
*/

-- Valid indexed view creation
CREATE VIEW vw_valid_indexed WITH SCHEMABINDING AS
SELECT 
    dbo.customers.customer_id,        -- Schema-qualified
    COUNT_BIG(*) as order_count       -- COUNT_BIG, not COUNT
FROM dbo.customers
INNER JOIN dbo.orders 
    ON dbo.customers.customer_id = dbo.orders.customer_id
GROUP BY dbo.customers.customer_id;
-- Create unique clustered index
CREATE UNIQUE CLUSTERED INDEX ix_valid_indexed
    ON vw_valid_indexed(customer_id);
-- Invalid indexed view attempt (violates requirements)
/*
CREATE VIEW vw_invalid_indexed WITH SCHEMABINDING AS
SELECT DISTINCT
    dbo.customers.customer_id,
    dbo.customers.customer_name
FROM dbo.customers
WHERE customer_id IN (SELECT customer_id FROM dbo.orders);
-- Fails: DISTINCT not permitted in indexed views
*/

2.2 Automatic Indexed View Maintenance

SQL Server automatically maintains indexed views when base tables change:

-- Indexed view automatic maintenance
-- When INSERT occurs on base table
INSERT INTO dbo.orders (customer_id, order_date, order_amount)
VALUES (12345, GETDATE(), 500.00);

-- Behind the scenes:
/*
1. Insert written to orders table
2. SQL Server identifies dependent indexed views
3. Indexed view's aggregates automatically updated:
   - Finds customer 12345 row in vw_employee_sales_summary
   - Increments total_sales_count
   - Adds order_amount to total_revenue
   - Recalculates avg_sale_amount
4. Transaction completes only after indexed view updated
5. Any subsequent queries use updated materialized view
*/
-- Performance impact: Write operations slower due to indexed view updates
-- Can disable automatic maintenance if refresh acceptable
SET ANSI_WARNINGS ON;
SET CONCAT_NULL_YIELDS_NULL ON;
ALTER INDEX ALL ON dbo.vw_employee_sales_summary DISABLE;
-- Now indexed view is stale until manually refreshed
ALTER INDEX ALL ON dbo.vw_employee_sales_summary REBUILD;

2.3 Query Optimizer Integration

The query optimizer automatically references indexed views when beneficial:

-- Query using indexed view transparently
SELECT 
    employee_id,
    employee_name,
    total_revenue,
    total_sales_count
FROM dbo.employees e
LEFT JOIN dbo.sales s ON e.employee_id = s.employee_id
GROUP BY 
    e.employee_id,
    e.employee_name;

-- Query optimizer analysis
-- Could execute base table joins and aggregation (slow)
-- Or reference indexed view vw_employee_sales_summary (fast)
-- Optimizer selects indexed view automatically
-- Force indexed view use with NOEXPAND
SELECT 
    employee_id,
    employee_name,
    total_revenue,
    total_sales_count
FROM dbo.vw_employee_sales_summary;
-- Use OPTION (EXPAND VIEWS) to prevent indexed view usage
SELECT 
    employee_id,
    employee_name,
    total_revenue
FROM dbo.vw_employee_sales_summary
OPTION (EXPAND VIEWS);
-- Forces base table access even though indexed view exists

Part Three: Azure SQL Database Materialized View Strategies

3.1 Materialized Views in Azure SQL Database

Azure SQL Database lacks native indexed views, requiring alternative materialization strategies.

Simulated Materialization with Tables and Views

-- Create underlying materialized table
CREATE TABLE dbo.mv_customer_summary (
    customer_id INT PRIMARY KEY,
    customer_name NVARCHAR(256) NOT NULL,
    region NVARCHAR(100) NOT NULL,
    order_count BIGINT NOT NULL,
    total_spent DECIMAL(15,2) NOT NULL,
    avg_order_value DECIMAL(15,2) NOT NULL,
    last_refreshed DATETIME2 NOT NULL DEFAULT GETDATE()
);

-- Create index for query performance
CREATE NONCLUSTERED INDEX ix_region ON dbo.mv_customer_summary(region)
    INCLUDE (order_count, total_spent, avg_order_value);
-- Create view for semantic compatibility
CREATE VIEW dbo.vw_customer_summary AS
SELECT 
    customer_id,
    customer_name,
    region,
    order_count,
    total_spent,
    avg_order_value
FROM dbo.mv_customer_summary;
-- Refresh procedure
CREATE PROCEDURE dbo.sp_refresh_mv_customer_summary
AS
BEGIN
    SET XACT_ABORT ON;

    BEGIN TRANSACTION;

    -- Merge pattern (insert/update/delete in single operation)
    MERGE INTO dbo.mv_customer_summary AS target
    USING (
        SELECT 
            c.customer_id,
            c.customer_name,
            c.region,
            COUNT(o.order_id) as order_count,
            ISNULL(SUM(o.order_amount), 0) as total_spent,
            ISNULL(AVG(CAST(o.order_amount AS DECIMAL(15,2))), 0) as avg_order_value
        FROM dbo.customers c
        LEFT JOIN dbo.orders o ON c.customer_id = o.customer_id
        GROUP BY c.customer_id, c.customer_name, c.region
    ) AS source (customer_id, customer_name, region, order_count, total_spent, avg_order_value)
    ON target.customer_id = source.customer_id
    WHEN MATCHED THEN
        UPDATE SET
            customer_name = source.customer_name,
            region = source.region,
            order_count = source.order_count,
            total_spent = source.total_spent,
            avg_order_value = source.avg_order_value,
            last_refreshed = GETDATE()
    WHEN NOT MATCHED BY TARGET THEN
        INSERT (customer_id, customer_name, region, order_count, total_spent, avg_order_value, last_refreshed)
        VALUES (source.customer_id, source.customer_name, source.region, source.order_count, source.total_spent, source.avg_order_value, GETDATE())
    WHEN NOT MATCHED BY SOURCE THEN
        DELETE;

    COMMIT TRANSACTION;
END;
-- Execute refresh
EXEC dbo.sp_refresh_mv_customer_summary;

3.2 Azure SQL Database Elastic Queries and Materialization

Distributed queries can populate materialized views across shards:

-- Create shard map reference
-- Assumes Azure SQL Database with horizontal partitioning via Elastic Database tools

CREATE TABLE dbo.mv_global_sales_summary (
    sales_date DATE NOT NULL,
    region NVARCHAR(100) NOT NULL,
    product_category NVARCHAR(100) NOT NULL,
    total_sales DECIMAL(15,2) NOT NULL,
    order_count BIGINT NOT NULL,
    PRIMARY KEY (sales_date, region, product_category)
);
-- Refresh procedure using Elastic Database Query
CREATE PROCEDURE dbo.sp_refresh_mv_sales_elastic
    @start_date DATE = NULL,
    @end_date DATE = NULL
AS
BEGIN
    IF @start_date IS NULL
        SET @start_date = CAST(DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1) AS DATE);

    IF @end_date IS NULL
        SET @end_date = GETDATE();

    -- Truncate current month's data
    DELETE FROM dbo.mv_global_sales_summary
    WHERE sales_date >= @start_date AND sales_date < DATEADD(DAY, 1, @end_date);

    -- Use Elastic Database Query to aggregate across shards
    INSERT INTO dbo.mv_global_sales_summary
    SELECT 
        CAST(s.sale_date AS DATE) as sales_date,
        r.region_name,
        p.category_name,
        SUM(s.sale_amount) as total_sales,
        COUNT(*) as order_count
    FROM
        OPENQUERY(
            'ElasticShardConnection',
            'SELECT 
                s.sale_date,
                r.region_id,
                p.category_id,
                s.sale_amount
            FROM dbo.sales s
            INNER JOIN dbo.regions r ON s.region_id = r.region_id
            INNER JOIN dbo.products p ON s.product_id = p.product_id
            WHERE s.sale_date >= @start_date AND s.sale_date < DATEADD(DAY, 1, @end_date)'
        ) AS remote_data (sale_date, region_id, category_id, sale_amount)
    INNER JOIN dbo.regions r ON remote_data.region_id = r.region_id
    INNER JOIN dbo.categories c ON remote_data.category_id = c.category_id
    GROUP BY 
        CAST(sale_date AS DATE),
        r.region_name,
        c.category_name;
END;

Part Four: PySpark Materialized View Implementation

4.1 PySpark DataFrame Caching Patterns

PySpark provides materialized view simulation through DataFrame caching:

from pyspark.sql import SparkSession
from pyspark.sql.functions import (
    col, count, sum as spark_sum, avg, 
    min as spark_min, max as spark_max,
    window, date_format, to_date
)
from pyspark.sql.window import Window as WindowSpec
import pyspark.sql.functions as F

spark = SparkSession.builder \
    .appName("MaterializedViewAnalysis") \
    .config("spark.sql.adaptive.enabled", "true") \
    .getOrCreate()
# Read source data
customers_df = spark.read.parquet("hdfs:///data/customers/")
orders_df = spark.read.parquet("hdfs:///data/orders/")
# Define materialized view logic
customer_sales_mv = customers_df.join(
    orders_df,
    on="customer_id",
    how="left"
).groupBy("customer_id", "customer_name", "region").agg(
    count("order_id").alias("order_count"),
    spark_sum("order_amount").alias("total_spent"),
    avg("order_amount").alias("avg_order_value"),
    spark_min("order_date").alias("first_order_date"),
    spark_max("order_date").alias("last_order_date")
)
# Cache the materialized view (keeps in memory)
customer_sales_mv.cache()
# Count action forces evaluation and caching
row_count = customer_sales_mv.count()
print(f"Materialized view cached with {row_count} rows")
# Subsequent queries use cached data
high_value_customers = customer_sales_mv.filter(
    col("total_spent") > 10000
).show()
# Check cache storage
print(f"Cache storage: {customer_sales_mv.storageLevel}")
# Result: StorageLevel(True, True, False, True, 1) = MEMORY_AND_DISK

4.2 Persistent Materialized View Storage

Store materialized views as Parquet tables for durability:

from pyspark.sql import SparkSession
from pyspark.sql.functions import col, count, sum as spark_sum, current_timestamp
import os

spark = SparkSession.builder \
    .appName("PersistentMaterializedViews") \
    .config("spark.sql.adaptive.enabled", "true") \
    .getOrCreate()
# Read source data
customers_df = spark.read.parquet("hdfs:///data/customers/")
orders_df = spark.read.parquet("hdfs:///data/orders/")
products_df = spark.read.parquet("hdfs:///data/products/")
# Define materialized view: Customer Purchase Summary
customer_purchase_summary = customers_df.join(
    orders_df,
    on="customer_id",
    how="left"
).join(
    products_df,
    on="product_id",
    how="left"
).groupBy(
    "customer_id",
    "customer_name",
    "region"
).agg(
    count("order_id").alias("total_orders"),
    spark_sum("order_amount").alias("lifetime_value"),
    count(col("distinct_product_id")).alias("unique_products"),
    count("product_category").alias("category_count")
)
# Add refresh timestamp
customer_purchase_summary_with_timestamp = customer_purchase_summary.withColumn(
    "last_refreshed",
    current_timestamp()
)
# Write to persistent storage
mv_output_path = "hdfs:///data/materialized_views/customer_purchase_summary/"
customer_purchase_summary_with_timestamp.write \
    .mode("overwrite") \
    .parquet(mv_output_path)
print(f"Materialized view persisted to {mv_output_path}")
# Load materialized view for subsequent queries
mv_loaded = spark.read.parquet(mv_output_path)
# Query the materialized view
high_value = mv_loaded.filter(col("lifetime_value") > 50000).show()

4.3 Delta Lake Materialized Views

Delta Lake provides ACID guarantees and time travel for materialized views:

from delta.tables import DeltaTable
from pyspark.sql.functions import (
    col, count, sum as spark_sum, avg, current_timestamp, 
    date_format, to_date, dense_rank
)
from pyspark.sql.window import Window as WindowSpec

spark = SparkSession.builder \
    .appName("DeltaMaterializedViews") \
    .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \
    .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \
    .getOrCreate()
# Source data
customers_df = spark.read.parquet("s3://data/customers/")
orders_df = spark.read.parquet("s3://data/orders/")
items_df = spark.read.parquet("s3://data/order_items/")
# Define complex materialized view: Sales by Region and Product
regional_product_sales = orders_df.join(
    items_df,
    on="order_id",
    how="inner"
).join(
    customers_df,
    on="customer_id",
    how="inner"
).groupBy(
    "region",
    "product_id",
    to_date("order_date").alias("sales_date")
).agg(
    count("order_id").alias("order_count"),
    spark_sum("quantity").alias("total_quantity"),
    spark_sum("line_total").alias("total_revenue"),
    avg("line_total").alias("avg_order_value")
)
# Add ranking within region
window_spec = WindowSpec.partitionBy("region", "sales_date") \
    .orderBy(col("total_revenue").desc())
ranked_sales = regional_product_sales.withColumn(
    "rank_in_region",
    dense_rank().over(window_spec)
).withColumn(
    "refresh_timestamp",
    current_timestamp()
)
# Write to Delta table
delta_path = "s3://analytics/materialized_views/regional_product_sales/"
ranked_sales.write \
    .format("delta") \
    .mode("overwrite") \
    .option("mergeSchema", "true") \
    .save(delta_path)
print(f"Delta materialized view created at {delta_path}")
# Load and query materialized view
mv_delta = spark.read.format("delta").load(delta_path)
# Query recent top products
top_products = mv_delta.filter(
    (col("rank_in_region") <= 10) &
    (col("sales_date") >= "2024-01-01")
).select("region", "product_id", "total_revenue", "rank_in_region")
top_products.show()
# Time travel: Query historical version from 7 days ago
historical_mv = spark.read.format("delta") \
    .option("timestampAsOf", "2024-01-08 00:00:00") \
    .load(delta_path)
comparison = historical_mv.alias("past") \
    .join(
        mv_delta.alias("current"),
        on="product_id",
        how="inner"
    ).select(
        col("product_id"),
        (col("current.total_revenue") - col("past.total_revenue")).alias("revenue_change"),
        col("current.rank_in_region").alias("current_rank"),
        col("past.rank_in_region").alias("previous_rank")
    )
comparison.show()

4.4 Scheduled Refresh Procedures

Implement automated refresh patterns in PySpark:

from pyspark.sql import SparkSession
from pyspark.sql.functions import col, count, sum as spark_sum, current_timestamp
from datetime import datetime, timedelta
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
spark = SparkSession.builder \
    .appName("ScheduledMaterializedViewRefresh") \
    .config("spark.sql.adaptive.enabled", "true") \
    .getOrCreate()
class MaterializedViewRefreshManager:
    """
    Manages materialized view refresh operations with
    full/incremental strategies and error handling
    """

    def __init__(self, spark_session):
        self.spark = spark_session
        self.refresh_log = []

    def refresh_full(self, source_query, mv_name, output_path):
        """
        Perform full materialized view refresh
        """
        try:
            start_time = datetime.now()

            # Execute source query
            mv_data = self.spark.sql(source_query)

            # Add metadata
            mv_with_metadata = mv_data.withColumn(
                "refresh_timestamp",
                current_timestamp()
            ).withColumn(
                "refresh_type",
                F.lit("FULL")
            )

            # Write with overwrite
            mv_with_metadata.write \
                .format("parquet") \
                .mode("overwrite") \
                .save(output_path)

            duration = (datetime.now() - start_time).total_seconds()
            row_count = mv_data.count()

            log_entry = {
                "mv_name": mv_name,
                "refresh_type": "FULL",
                "status": "SUCCESS",
                "row_count": row_count,
                "duration_seconds": duration,
                "timestamp": datetime.now()
            }

            self.refresh_log.append(log_entry)
            logger.info(f"Successfully refreshed {mv_name}: {row_count} rows in {duration:.2f}s")

            return True

        except Exception as e:
            logger.error(f"Error refreshing {mv_name}: {str(e)}")
            self.refresh_log.append({
                "mv_name": mv_name,
                "refresh_type": "FULL",
                "status": "FAILED",
                "error": str(e),
                "timestamp": datetime.now()
            })
            return False

    def refresh_incremental(self, 
                           current_mv_path, 
                           source_query, 
                           mv_name, 
                           output_path,
                           key_columns):
        """
        Perform incremental materialized view refresh
        """
        try:
            start_time = datetime.now()

            # Load current materialized view
            current_mv = self.spark.read.parquet(current_mv_path)

            # Execute source query
            new_data = self.spark.sql(source_query)

            # Identify changed keys
            current_keys = current_mv.select(*key_columns).distinct()
            new_keys = new_data.select(*key_columns).distinct()

            # Get keys that changed
            changed_keys = new_keys.union(current_keys) \
                .groupBy(*key_columns).count() \
                .filter(col("count") == 1) \
                .select(*key_columns)

            # Remove changed rows from current MV
            unchanged_rows = current_mv.join(
                changed_keys,
                on=key_columns,
                how="left_anti"
            )

            # Get new rows for changed keys
            new_rows = new_data.join(
                changed_keys,
                on=key_columns,
                how="inner"
            )

            # Combine unchanged + new rows
            refreshed_mv = unchanged_rows.union(new_rows)

            # Add metadata
            refreshed_mv_with_metadata = refreshed_mv.withColumn(
                "refresh_timestamp",
                current_timestamp()
            ).withColumn(
                "refresh_type",
                F.lit("INCREMENTAL")
            )

            # Write updated materialized view
            refreshed_mv_with_metadata.write \
                .format("parquet") \
                .mode("overwrite") \
                .save(output_path)

            duration = (datetime.now() - start_time).total_seconds()
            changed_count = new_rows.count()
            total_count = refreshed_mv_with_metadata.count()

            log_entry = {
                "mv_name": mv_name,
                "refresh_type": "INCREMENTAL",
                "status": "SUCCESS",
                "changed_rows": changed_count,
                "total_rows": total_count,
                "duration_seconds": duration,
                "timestamp": datetime.now()
            }

            self.refresh_log.append(log_entry)
            logger.info(f"Incrementally refreshed {mv_name}: {changed_count} changes, {total_count} total rows in {duration:.2f}s")

            return True

        except Exception as e:
            logger.error(f"Error incrementally refreshing {mv_name}: {str(e)}")
            return False
# Usage example
refresh_manager = MaterializedViewRefreshManager(spark)
# Define materialized view query
customer_summary_query = """
SELECT 
    c.customer_id,
    c.customer_name,
    c.region,
    COUNT(o.order_id) as order_count,
    SUM(o.order_amount) as total_spent,
    AVG(o.order_amount) as avg_order_amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.region
"""
# Perform full refresh
refresh_manager.refresh_full(
    source_query=customer_summary_query,
    mv_name="customer_summary",
    output_path="s3://analytics/mv/customer_summary/"
)
# Subsequent refreshes use incremental approach
refresh_manager.refresh_incremental(
    current_mv_path="s3://analytics/mv/customer_summary/",
    source_query=customer_summary_query,
    mv_name="customer_summary",
    output_path="s3://analytics/mv/customer_summary/",
    key_columns=["customer_id"]
)
# Log refresh history
import pandas as pd
refresh_df = pd.DataFrame(refresh_manager.refresh_log)
print(refresh_df.to_string())

4.5 Advanced PySpark Patterns

Complex materialized view patterns for production scenarios:

from pyspark.sql import SparkSession, Window
from pyspark.sql.functions import (
    col, count, sum as spark_sum, avg, max as spark_max,
    row_number, rank, dense_rank, ntile,
    lag, lead, first, last,
    when, case, coalesce,
    explode, array_join,
    date_trunc, to_date, year, month, dayofweek,
    broadcast, cache
)
from pyspark.sql.types import StructType, StructField, IntegerType, StringType, DecimalType, DateType
import pyspark.sql.functions as F

spark = SparkSession.builder \
    .appName("AdvancedMVPatterns") \
    .config("spark.sql.adaptive.enabled", "true") \
    .config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
    .getOrCreate()
# Pattern 1: Multi-Level Aggregation Materialized View
class MultiLevelAggregationMV:
    """
    Materialized view with aggregation at multiple hierarchical levels
    """

    def __init__(self, spark):
        self.spark = spark

    def create_sales_hierarchy_mv(self, orders_df, customers_df, products_df):
        """
        Create materialized view with sales data aggregated at
        product, category, region, and company levels
        """

        # Base join
        base_join = orders_df.join(
            customers_df,
            on="customer_id",
            how="inner"
        ).join(
            products_df,
            on="product_id",
            how="inner"
        )

        # Atomic level aggregation (Product x Region x Date)
        atomic_level = base_join.groupBy(
            "product_id",
            "product_name",
            "category_id",
            "category_name",
            "region",
            to_date("order_date").alias("sales_date")
        ).agg(
            count("order_id").alias("transaction_count"),
            spark_sum("order_amount").alias("daily_revenue"),
            avg("order_amount").alias("avg_transaction_value")
        ).withColumn("hierarchy_level", F.lit("ATOMIC"))

        # Category level aggregation
        category_level = base_join.groupBy(
            F.lit(None).cast(IntegerType()).alias("product_id"),
            F.lit(None).cast(StringType()).alias("product_name"),
            "category_id",
            "category_name",
            "region",
            to_date("order_date").alias("sales_date")
        ).agg(
            count("order_id").alias("transaction_count"),
            spark_sum("order_amount").alias("daily_revenue"),
            avg("order_amount").alias("avg_transaction_value")
        ).withColumn("hierarchy_level", F.lit("CATEGORY"))

        # Region level aggregation
        region_level = base_join.groupBy(
            F.lit(None).cast(IntegerType()).alias("product_id"),
            F.lit(None).cast(StringType()).alias("product_name"),
            F.lit(None).cast(IntegerType()).alias("category_id"),
            F.lit(None).cast(StringType()).alias("category_name"),
            "region",
            to_date("order_date").alias("sales_date")
        ).agg(
            count("order_id").alias("transaction_count"),
            spark_sum("order_amount").alias("daily_revenue"),
            avg("order_amount").alias("avg_transaction_value")
        ).withColumn("hierarchy_level", F.lit("REGION"))

        # Company level aggregation
        company_level = base_join.groupBy(
            F.lit(None).cast(IntegerType()).alias("product_id"),
            F.lit(None).cast(StringType()).alias("product_name"),
            F.lit(None).cast(IntegerType()).alias("category_id"),
            F.lit(None).cast(StringType()).alias("category_name"),
            F.lit("GLOBAL").alias("region"),
            to_date("order_date").alias("sales_date")
        ).agg(
            count("order_id").alias("transaction_count"),
            spark_sum("order_amount").alias("daily_revenue"),
            avg("order_amount").alias("avg_transaction_value")
        ).withColumn("hierarchy_level", F.lit("COMPANY"))

        # Combine all levels
        hierarchy_mv = atomic_level.union(category_level) \
            .union(region_level) \
            .union(company_level) \
            .withColumn("refresh_timestamp", current_timestamp())

        return hierarchy_mv
# Pattern 2: Materialized View with Row Ranking
class RankingMaterializedView:
    """
    Materialized view with ranking calculations
    """

    @staticmethod
    def create_sales_ranking_mv(orders_df, customers_df):
        """
        Create materialized view ranking customers by sales
        within each region and overall
        """

        # Base aggregation
        customer_sales = orders_df.join(
            customers_df,
            on="customer_id",
            how="inner"
        ).groupBy(
            "customer_id",
            "customer_name",
            "region"
        ).agg(
            count("order_id").alias("order_count"),
            spark_sum("order_amount").alias("total_sales")
        )

        # Define windows for ranking
        region_window = Window.partitionBy("region") \
            .orderBy(col("total_sales").desc())

        global_window = Window.orderBy(col("total_sales").desc())

        # Add ranking columns
        ranking_mv = customer_sales.withColumn(
            "region_rank",
            rank().over(region_window)
        ).withColumn(
            "region_dense_rank",
            dense_rank().over(region_window)
        ).withColumn(
            "global_rank",
            rank().over(global_window)
        ).withColumn(
            "region_percentile",
            F.round(
                (rank().over(region_window) - 1) / 
                (count("*").over(Window.partitionBy("region")) - 1) * 100, 2
            )
        ).withColumn(
            "refresh_timestamp",
            current_timestamp()
        )

        return ranking_mv
# Pattern 3: Time-Series Materialized View
class TimeSeriesMaterializedView:
    """
    Materialized view for time-series analysis
    """

    @staticmethod
    def create_daily_metrics_mv(orders_df, metrics_window_days=30):
        """
        Create materialized view with rolling window metrics
        """

        daily_sales = orders_df.groupBy(
            to_date("order_date").alias("sales_date")
        ).agg(
            count("order_id").alias("transaction_count"),
            spark_sum("order_amount").alias("daily_revenue"),
            avg("order_amount").alias("avg_transaction_value")
        ).orderBy("sales_date")

        # Define window for rolling calculations
        time_window = Window.orderBy(
            col("sales_date").cast("long")
        ).rangeBetween(
            -1 * metrics_window_days * 86400,  # 30 days in seconds
            0
        )

        # Add rolling metrics
        time_series_mv = daily_sales.withColumn(
            "rolling_avg_revenue",
            avg(col("daily_revenue")).over(time_window)
        ).withColumn(
            "rolling_sum_transactions",
            spark_sum(col("transaction_count")).over(time_window)
        ).withColumn(
            "day_over_day_change",
            col("daily_revenue") - lag(col("daily_revenue")).over(
                Window.orderBy("sales_date")
            )
        ).withColumn(
            "refresh_timestamp",
            current_timestamp()
        )

        return time_series_mv
# Usage
multi_level_mv = MultiLevelAggregationMV(spark)
ranking_mv = RankingMaterializedView()
timeseries_mv = TimeSeriesMaterializedView()
# Read source data
orders = spark.read.parquet("s3://data/orders/")
customers = spark.read.parquet("s3://data/customers/")
products = spark.read.parquet("s3://data/products/")
# Create materialized views
hierarchy_result = multi_level_mv.create_sales_hierarchy_mv(orders, customers, products)
ranking_result = ranking_mv.create_sales_ranking_mv(orders, customers)
timeseries_result = timeseries_mv.create_daily_metrics_mv(orders)
# Persist materialized views
hierarchy_result.write.format("parquet").mode("overwrite") \
    .save("s3://mv/sales_hierarchy/")
ranking_result.write.format("parquet").mode("overwrite") \
    .save("s3://mv/sales_ranking/")
timeseries_result.write.format("parquet").mode("overwrite") \
    .save("s3://mv/daily_metrics/")

Part Five: Materialized View Optimization and Best Practices

5.1 Performance Considerations

Materialized views create performance trade-offs requiring careful optimization:

# Performance Optimization Strategies

# 1. Partitioning Strategy
materialized_view_partitioned = base_data.repartition(
    col("sales_date"),  # Partition by date for time-series queries
    col("region")       # Secondary partition by region
).write.format("parquet") \
    .mode("overwrite") \
    .partitionBy("sales_date", "region") \
    .save("s3://mv/partitioned_sales/")
# Benefits:
# - Partition pruning eliminates irrelevant data
# - Parallel reads across partitions
# - Faster queries on specific dates/regions
# 2. Compression Strategy
compressed_mv = base_data.write.format("parquet") \
    .mode("overwrite") \
    .option("compression", "snappy") \
    .save("s3://mv/compressed/")
# Compression comparison:
# - Uncompressed: 500 MB, read time 5 seconds
# - Snappy: 200 MB, read time 3 seconds (CPU-efficient)
# - GZIP: 150 MB, read time 4 seconds (higher compression)
# - LZO: 160 MB, read time 2 seconds (fast decompression)
# 3. Statistics and Indexing
materialized_view.write.format("delta") \
    .mode("overwrite") \
    .option("delta.optimize.write", "true") \
    .option("delta.targetFileSize", "134217728") \
    .save("s3://mv/optimized/")
# 4. Column Selection (avoid storing unnecessary columns)
# Store only columns needed for most frequent queries
optimized_mv = base_data.select(
    "customer_id",
    "order_date",
    "total_amount",
    "region"
    # Exclude rarely-used detailed columns
).write.format("parquet").mode("overwrite") \
    .save("s3://mv/optimized_columns/")

5.2 Refresh Scheduling and Monitoring

Coordinate refresh operations with query workloads:

from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.apache.spark import SparkSubmitOperator
from datetime import datetime, timedelta
import logging

# Airflow DAG for materialized view refresh
default_args = {
    'owner': 'data_engineering',
    'retries': 3,
    'retry_delay': timedelta(minutes=5),
    'start_date': datetime(2024, 1, 1),
    'email': ['alerts@company.com'],
    'email_on_failure': True,
    'email_on_retry': False
}
dag = DAG(
    'materialized_view_refresh',
    default_args=default_args,
    description='Refresh materialized views',
    schedule_interval='0 2 * * *',  # 2 AM daily
    catchup=False
)
def validate_mv_refresh(**context):
    """
    Post-refresh validation
    """
    logger = logging.getLogger(__name__)

    # Compare row counts
    previous_count = context['ti'].xcom_pull(
        task_ids='refresh_customer_summary',
        key='row_count'
    )

    current_count = spark.read.parquet(
        's3://mv/customer_summary/'
    ).count()

    # Alert if significant deviation
    if abs(current_count - previous_count) / previous_count > 0.2:
        logger.warning(f"MV row count changed significantly: {previous_count} -> {current_count}")

    logger.info(f"Materialized view validation passed. Row count: {current_count}")
# Refresh tasks
refresh_customer_summary = SparkSubmitOperator(
    task_id='refresh_customer_summary',
    application='s3://scripts/refresh_mv_customer_summary.py',
    conf={'spark.executor.instances': 4},
    dag=dag
)
refresh_sales_ranking = SparkSubmitOperator(
    task_id='refresh_sales_ranking',
    application='s3://scripts/refresh_mv_sales_ranking.py',
    conf={'spark.executor.instances': 4},
    dag=dag
)
validation_task = PythonOperator(
    task_id='validate_refresh',
    python_callable=validate_mv_refresh,
    dag=dag
)
# DAG dependencies
[refresh_customer_summary, refresh_sales_ranking] >> validation_task

5.3 Materialized View Comparison Analysis

Understanding when to use materialized views versus alternatives:

Decision Framework for Materialized Views

Use Materialized Views When:
1. Query expensive (complex joins, aggregations)
2. Data updated infrequently (daily, weekly refresh acceptable)
3. Query execution frequency high (>1000 executions daily)
4. Query latency critical (millisecond response required)
5. Source data size large (complexity reduction beneficial)
6. Storage available (materialization cost acceptable)
Examples:
- Daily sales summaries for reports: IDEAL (slow computation, frequent queries)
- Real-time transaction details: POOR (constantly changing, low latency required)
- Weekly regional performance summaries: IDEAL
- Live stock prices: POOR
- Historical trend analysis: IDEAL
Avoid Materialized Views When:
1. Data changes continuously (refresh too frequent)
2. Storage space limited (materialization cost prohibitive)
3. Queries executed rarely (benefit insufficient)
4. Data currency critical (freshness requirement high)
5. Source complexity simple (materialization overhead unjustified)
"""
# Comparison: Standard View vs. Materialized View
import time
spark = SparkSession.builder.appName("MVComparison").getOrCreate()
# Load data
orders_large = spark.read.parquet("s3://data/large_orders/")
customers_large = spark.read.parquet("s3://data/large_customers/")
# Standard View (Dynamic Execution)
def query_standard_view():
    result = orders_large.join(
        customers_large,
        on="customer_id",
        how="inner"
    ).groupBy("region").agg(
        F.sum("order_amount").alias("total_sales"),
        F.count("*").alias("order_count")
    )
    return result.count()
# Materialized View (Pre-computed)
def create_materialized_view():
    mv = orders_large.join(
        customers_large,
        on="customer_id",
        how="inner"
    ).groupBy("region").agg(
        F.sum("order_amount").alias("total_sales"),
        F.count("*").alias("order_count")
    )
    mv.write.format("parquet").mode("overwrite") \
        .save("s3://mv/sales_by_region/")
def query_materialized_view():
    result = spark.read.parquet("s3://mv/sales_by_region/")
    return result.count()
# Performance comparison
print("=== Standard View Performance ===")
for i in range(3):
    start = time.time()
    count = query_standard_view()
    duration = time.time() - start
    print(f"Execution {i+1}: {duration:.2f}s")
print("\n=== Materialized View Creation ===")
start = time.time()
create_materialized_view()
creation_duration = time.time() - start
print(f"Initial materialization: {creation_duration:.2f}s")
print("\n=== Materialized View Query Performance ===")
for i in range(3):
    start = time.time()
    count = query_materialized_view()
    duration = time.time() - start
    print(f"Execution {i+1}: {duration:.2f}s")
# Analysis
"""
Results (typical):
Standard View:
- Execution 1: 5.2s (first execution, no caching)
- Execution 2: 4.8s
- Execution 3: 4.9s
- Average: 4.97s per execution
Materialized View:
- Initial materialization: 6.1s (one-time cost)
- Execution 1: 0.3s (table read)
- Execution 2: 0.3s
- Execution 3: 0.3s
- Average: 0.3s per execution
Break-even point: 6.1s / (4.97s - 0.3s) = 1.35 executions
After 2 executions: Materialized view wins
After 100 executions: Materialized view saves ~485 seconds
"""

Part Six: Azure Integration and Best Practices

6.1 Azure Synapse Materialized Views

Azure Synapse Analytics provides native materialized view support:

-- Azure Synapse materialized view
CREATE MATERIALIZED VIEW dbo.mv_fact_sales_summary
WITH (
    DISTRIBUTION = HASH(date_id),
    INDEX = CLUSTERED COLUMNSTORE INDEX
)
AS
SELECT 
    f.date_id,
    f.product_id,
    f.customer_id,
    f.store_id,
    SUM(f.sales_amount) as total_sales,
    SUM(f.quantity) as total_quantity,
    COUNT(*) as transaction_count
FROM dbo.fact_sales f
GROUP BY 
    f.date_id,
    f.product_id,
    f.customer_id,
    f.store_id;

-- Synapse automatically maintains materialized view
-- Query optimizer recognizes and uses in execution plans
-- Monitor materialized view
SELECT 
    name,
    object_id,
    schema_id,
    definition,
    state_desc
FROM sys.views
WHERE name = 'mv_fact_sales_summary';
-- Refresh materialized view
ALTER MATERIALIZED VIEW dbo.mv_fact_sales_summary REBUILD;
-- Monitor refresh progress
SELECT 
    object_name(mv.object_id) as mv_name,
    mv.is_refreshing,
    mv.rows_returned,
    mv.creation_time
FROM sys.dm_materialized_view_details mv
WHERE object_id = OBJECT_ID('dbo.mv_fact_sales_summary');

6.2 Azure Data Factory Materialized View Refresh Orchestration

Automate materialized view refresh through Azure Data Factory:

# Azure Data Factory Pipeline for MV Refresh

from azure.identity import DefaultAzureCredential
from azure.data.datafactory import DataFactoryManagementClient
from azure.data.datafactory.models import *
# Configure Data Factory client
credential = DefaultAzureCredential()
adf_client = DataFactoryManagementClient(
    credential,
    subscription_id="your-subscription-id"
)
# Define pipeline for materialized view refresh
pipeline_name = "RefreshMaterializedViews"
activities = [
    ExecuteSparkNotebookActivity(
        name="PreRefreshValidation",
        notebook=LinkedServiceReference(name="databricks_notebook"),
        parameters={"notebook_path": "/Shared/pre_refresh_check"}
    ),
    CopyActivity(
        name="CopyDataToStaging",
        source=ParquetSource(dataset_reference=InputDataset("SourceData")),
        sink=ParquetSink(dataset_reference=OutputDataset("StagingData"))
    ),
    ExecuteSparkNotebookActivity(
        name="RefreshMaterializedViews",
        notebook=LinkedServiceReference(name="databricks_notebook"),
        parameters={"notebook_path": "/Shared/refresh_mv"}
    ),
    ExecuteSparkNotebookActivity(
        name="PostRefreshValidation",
        notebook=LinkedServiceReference(name="databricks_notebook"),
        parameters={"notebook_path": "/Shared/post_refresh_check"}
    )
]
# Create pipeline
pipeline_resource = PipelineResource(activities=activities)
pipeline = adf_client.pipelines.create_or_update(
    resource_group_name="your-resource-group",
    factory_name="your-adf-instance",
    pipeline_name=pipeline_name,
    pipeline=pipeline_resource
)
# Trigger pipeline execution
run_response = adf_client.pipelines.create_run(
    resource_group_name="your-resource-group",
    factory_name="your-adf-instance",
    pipeline_name=pipeline_name
)
print(f"Pipeline run initiated: {run_response.run_id}")

Conclusion

Materialized views represent a critical architectural pattern for building performant, scalable analytics systems. Whether implemented through SQL Server indexed views, Azure SQL simulated materialization, or PySpark persistent caching, the principles remain consistent: precompute expensive calculations during periods of lower demand, serve precomputed results to analytical queries during peak load, and manage refresh frequency to balance currency against performance gains.

The progression from analytical query optimization toward comprehensive materialization planning reflects the evolution from data engineer toward data architect.


메타데이터
post_id
d5439a014c8d
slug
day-16-of-32-days-of-sql-concepts-materialized-views-d5439a014c8d
url
https://medium.com/@krthiak/day-16-of-32-days-of-sql-concepts-materialized-views-d5439a014c8d
canonical_url
https://medium.com/@krthiak/day-16-of-32-days-of-sql-concepts-materialized-views-d5439a014c8d
author_url
https://medium.com/@krthiak
status
ok
fetched_at
2026-06-14 16:17:09