Featured image of post DuckDB Materialized Views Reimagined: CTAS + Parquet for Millisecond Data Products

DuckDB Materialized Views Reimagined: CTAS + Parquet for Millisecond Data Products

How to use DuckDB CTAS + Parquet instead of traditional materialized views for 9x faster queries. Build production-grade data product backends with pure SQL + Python, complete code and guide included

When developers migrate from PostgreSQL to DuckDB, the first thing they usually look for is “materialized views.” PostgreSQL’s MATERIALIZED VIEW syntax is clean, but in practice it comes with several pain points—schema changes require DROP and rebuild, refresh operations lock tables and hurt concurrency, and it doesn’t support columnar storage optimization.

DuckDB takes a different design philosophy: it doesn’t need traditional materialized views. Because DuckDB natively supports columnar storage (Parquet), the combination of CREATE TABLE AS SELECT (CTAS) + Parquet direct reads achieves a more flexible and efficient materialization scheme than traditional MVs.

Today, I’ll walk you through building a production-grade, high-performance query backend—from zero to one—using pure SQL + Python.

DuckDB CTAS + Parquet Architecture Diagram


Why DuckDB Doesn’t Need Traditional Materialized Views

To understand this, let’s look at three core weaknesses of traditional materialized views:

1. Schema changes are expensive In PostgreSQL, once an MV is created, changing a field requires DROP and rebuild—queries are unavailable during this process. In data product scenarios, customer requirements change constantly, and MV’s rigid structure slows down iteration.

2. Table locks during refresh When refreshing an MV, the database locks the underlying table. In high-concurrency scenarios, this becomes a bottleneck. Imagine 50 users checking your BI dashboard simultaneously while a refresh operation blocks the entire system.

3. No columnar advantages Traditional RDBMS stores data row-wise. Even with MVs, queries require full row scans. Parquet is a columnar compressed format—you only read the columns you need, achieving 5-10x performance differences.

DuckDB’s approach is simpler and more direct: store aggregation results directly as Parquet files. Reading Parquet files is 5-10x faster than reading row-based tables. Combined with CTAS pre-aggregation, query latency drops from seconds to milliseconds.


Complete Walkthrough: From Raw Data to Product in 4 Steps

Step 1: Export Data to Parquet (Zero ETL)

This is the most critical step—converting raw data to Parquet format. All subsequent queries are based on this file.

import duckdb
import time

conn = duckdb.connect(':memory:')

# Simulate e-commerce order data (1M rows)
conn.execute("""
 CREATE TABLE orders AS
 SELECT 
 gen AS order_id,
 CASE (gen % 5)
 WHEN 0 THEN 'Electronics'
 WHEN 1 THEN 'Clothing'
 WHEN 2 THEN 'Books'
 WHEN 3 THEN 'Home'
 ELSE 'Sports'
 END AS product_category,
 CAST(random() * 500 + 10 AS DOUBLE) AS amount,
 TIMESTAMP '2026-01-01' + (gen % 270) * INTERVAL '1 day' + 
 (gen % 24) * INTERVAL '1 hour' + 
 (gen % 60) * INTERVAL '1 minute' AS created_at
 FROM generate_series(1, 1000000) AS t(gen)
""")

# Write to parquet — this is the key!
parquet_path = '/tmp/orders.parquet'
conn.execute(f"COPY orders TO '{parquet_path}' (FORMAT PARQUET)")
conn.close()

print(f"Parquet file created: {parquet_path}")

Benefit analysis: Parquet is a columnar compressed format. 1M order rows compress from 80MB to 12MB. More importantly, subsequent queries let DuckDB read only the needed columns and skip irrelevant bytes—something row storage cannot do.

Step 2: CTAS Pre-Aggregation (MV Replacement)

With the Parquet file ready, create a pre-aggregation table. This step completely replaces traditional materialized view functionality.

conn = duckdb.connect(':memory:')

# One-time creation of materialized table
conn.execute("""
 CREATE TABLE IF NOT EXISTS mv_daily_stats AS
 SELECT 
 DATE_TRUNC('day', created_at) AS dt,
 product_category,
 COUNT(*) AS order_count,
 ROUND(SUM(amount), 2) AS total_revenue,
 ROUND(AVG(amount), 2) AS avg_order_value,
 COUNT(DISTINCT order_id) AS unique_customers
 FROM read_parquet('/tmp/orders.parquet')
 WHERE created_at >= CURRENT_DATE - INTERVAL '90' DAY
 GROUP BY 1, 2
""")

# Add index to accelerate filtered queries
conn.execute("CREATE INDEX IF NOT EXISTS idx_mv_dt ON mv_daily_stats(dt)")

conn.close()

Key design points:

  • WHERE created_at >= CURRENT_DATE - INTERVAL '90' DAY controls data range, preventing unbounded table growth
  • CREATE INDEX accelerates high-frequency filtered queries
  • Table name prefixed with mv_ for easy identification and management

Step 3: Performance Benchmark (Real Results)

Run the same query across three approaches:

import time

conn = duckdb.connect(':memory:')

# Warm up: ensure Parquet file is cached in memory
conn.execute("SELECT COUNT(*) FROM read_parquet('/tmp/orders.parquet')")

# Test 1: Parquet direct read aggregation
start = time.time()
for _ in range(10):
 conn.execute("""
 SELECT 
 DATE_TRUNC('day', created_at) AS dt,
 product_category,
 COUNT(*) AS order_count,
 ROUND(SUM(amount), 2) AS total_revenue
 FROM read_parquet('/tmp/orders.parquet')
 WHERE created_at >= CURRENT_DATE - INTERVAL '90' DAY
 GROUP BY 1, 2
 """).fetchall()
parquet_time = (time.time() - start) / 10

# Test 2: CTAS materialized table query
conn.execute("""
 CREATE TABLE IF NOT EXISTS mv_daily_stats AS
 SELECT 
 DATE_TRUNC('day', created_at) AS dt,
 product_category,
 COUNT(*) AS order_count,
 ROUND(SUM(amount), 2) AS total_revenue
 FROM read_parquet('/tmp/orders.parquet')
 WHERE created_at >= CURRENT_DATE - INTERVAL '90' DAY
 GROUP BY 1, 2
""")

start = time.time()
for _ in range(10):
 conn.execute("""
 SELECT * FROM mv_daily_stats
 WHERE dt >= CURRENT_DATE - INTERVAL '30' DAY
 ORDER BY total_revenue DESC
 """).fetchall()
ctas_time = (time.time() - start) / 10

# Test 3: CTAS + index query
conn.execute("CREATE INDEX idx_mv_dt ON mv_daily_stats(dt)")

start = time.time()
for _ in range(10):
 conn.execute("""
 SELECT * FROM mv_daily_stats
 WHERE dt >= CURRENT_DATE - INTERVAL '30' DAY
 ORDER BY total_revenue DESC
 """).fetchall()
idx_time = (time.time() - start) / 10

print(f"Parquet direct read: {parquet_time:.4f}s")
print(f"CTAS materialized: {ctas_time:.4f}s ({parquet_time/ctas_time:.1f}x faster)")
print(f"CTAS + index: {idx_time:.4f}s ({parquet_time/idx_time:.1f}x faster)")
conn.close()

Results (DuckDB 1.5.5, 1M rows):

Parquet direct read: 0.0302s
CTAS materialized: 0.0033s (9.2x faster)
CTAS + index: 0.0027s (11.1x faster)

Interpretation: CTAS materialized table is 9x faster than Parquet direct read. With an index added, it’s even faster. At data volumes above 1M rows, the gap widens further.

Step 4: Encapsulate as a Data Product Backend

Wrap the logic into a reusable Python class—this is the critical step toward productization.

import duckdb
import json
from datetime import datetime

class DataProductBackend:
 """Data product backend built with DuckDB + CTAS, ~50 lines of code"""
 
 def __init__(self, parquet_path):
 self.conn = duckdb.connect(':memory:')
 self.parquet_path = parquet_path
 self._build()
 
 def _build(self):
 """Build materialized table"""
 self.conn.execute(f"""
 CREATE TABLE IF NOT EXISTS mv_stats AS
 SELECT 
 DATE_TRUNC('day', created_at) AS dt,
 product_category,
 COUNT(*) AS order_count,
 ROUND(SUM(amount), 2) AS total_revenue,
 ROUND(AVG(amount), 2) AS avg_order_value,
 COUNT(DISTINCT order_id) AS unique_customers
 FROM read_parquet('{self.parquet_path}')
 WHERE created_at >= CURRENT_DATE - INTERVAL '90' DAY
 GROUP BY 1, 2
 """)
 self.conn.execute("CREATE INDEX IF NOT EXISTS idx_dt ON mv_stats(dt)")
 
 def refresh(self):
 """Refresh materialized table (rebuild)"""
 self.conn.execute("DROP TABLE IF EXISTS mv_stats")
 self._build()
 print(f"[{datetime.now()}] Materialized table refreshed")
 
 def top_categories(self, days=30, limit=10):
 """Query top N categories"""
 return self.conn.execute(f"""
 SELECT * FROM mv_stats
 WHERE dt >= CURRENT_DATE - INTERVAL '{days}' DAY
 ORDER BY total_revenue DESC LIMIT {limit}
 """).fetchall()
 
 def daily_report(self, days=7):
 """Generate daily report"""
 return self.conn.execute(f"""
 SELECT dt, 
 SUM(order_count) AS orders,
 SUM(total_revenue) AS revenue,
 AVG(avg_order_value) AS avg_value
 FROM mv_stats
 WHERE dt >= CURRENT_DATE - INTERVAL '{days}' DAY
 GROUP BY 1 
 ORDER BY 1 DESC 
 LIMIT {days}
 """).fetchall()
 
 def close(self):
 self.conn.close()

Usage example:

backend = DataProductBackend('/tmp/orders.parquet')

# Query top 5 categories
print("Top 5 Categories (Last 30 Days):")
for row in backend.top_categories(days=30, limit=5):
 print(f" {row[0]} | {row[1]:12s} | Orders:{row[2]:5d} | Revenue:¥{row[3]:>12,.2f}")

# Generate daily report
print("\nLast 7 Days Report:")
for row in backend.daily_report(days=7):
 print(f" {row[0]} | Orders:{row[1]:5d} | Revenue:¥{row[2]:>12,.2f}")

backend.close()

Three Refresh Strategies for Production

In production, source data keeps updating. How do you refresh the materialized table? Here are three strategies:

Strategy A: Scheduled Full Rebuild (Simplest, daily updates)

def daily_refresh(parquet_path):
 """Rebuild materialized table every day at midnight"""
 conn = duckdb.connect(':memory:')
 conn.execute(f"""
 CREATE TABLE mv AS
 SELECT 
 DATE_TRUNC('day', created_at) AS dt,
 product_category,
 COUNT(*) AS order_count,
 SUM(amount) AS total_revenue
 FROM read_parquet('{parquet_path}')
 WHERE created_at >= CURRENT_DATE - INTERVAL '90' DAY
 GROUP BY 1, 2
 """)
 return conn

Best for: Data < 5M rows, daily updates, relaxed real-time requirements. Rebuild typically takes < 1 second.

Strategy B: Incremental Merge (Hourly updates)

conn = duckdb.connect(':memory:')

conn.execute("""
 CREATE TABLE mv_incremental AS
 SELECT dt, product_category,
 SUM(order_count) AS order_count, 
 SUM(total_revenue) AS total_revenue
 FROM (
 -- Historical data
 SELECT DATE_TRUNC('day', created_at) AS dt, product_category,
 COUNT(*) AS order_count, SUM(amount) AS total_revenue
 FROM orders 
 WHERE created_at >= CURRENT_DATE - INTERVAL '90' DAY
 GROUP BY 1, 2
 UNION ALL
 -- New data
 SELECT DATE_TRUNC('day', created_at), product_category,
 COUNT(*), SUM(amount) 
 FROM new_orders
 GROUP BY 1, 2
 ) t
 GROUP BY 1, 2
""")

Best for: Large data (> 5M rows), hourly updates needed. Note: incremental merge assumes data is append-only.

Strategy C: Partitioned Parquet + Multi-File Aggregation (TB-scale)

conn = duckdb.connect(':memory:')

# Read partitioned directory directly, DuckDB auto-detects partitions
conn.execute("""
 CREATE TABLE mv_yearly AS
 SELECT 
 DATE_TRUNC('month', created_at) AS month,
 product_category,
 COUNT(*) AS orders,
 ROUND(SUM(amount), 2) AS revenue
 FROM read_parquet('/data/orders/*.parquet')
 GROUP BY 1, 2
""")

Best for: TB-scale data with time-partitioned storage. DuckDB’s partition pruning automatically skips unnecessary partition files.


Key Design Principles

Follow these principles in production to avoid common pitfalls:

1. Parquet is the core storage format Export all raw data to Parquet first. DuckDB reads it zero-copy, avoiding ORM/ETL layer overhead. Don’t import data into a relational database first— that adds unnecessary steps.

2. CTAS is the simplest materialization No MV syntax needed—CREATE TABLE AS SELECT is the most朴素 materialized view. Its advantage is flexibility: you can change the SQL and rebuild anytime,不受 Schema 限制 (unrestricted by schema).

3. Add indexes only where needed Only index columns used in filters (e.g., date fields). DuckDB’s VACUUM auto-maintains indexes—no manual intervention needed.

4. Reuse connections Maintain long-lived connections in Python. Avoid repeated connect() calls—initialization has overhead that adds up.

5. Keep data range bounded Add WHERE created_at >= ... in CTAS to control data range and prevent unbounded table growth. 90 days is a proven rule of thumb, adjustable per business needs.


Comparison: Traditional MV vs DuckDB CTAS + Parquet

DimensionPostgreSQL MVDuckDB CTAS + Parquet
Creation syntaxCREATE TABLECREATE TABLE AS SELECT
Refresh methodREFRESH MATERIALIZED VIEWDROP + CREATE or incremental merge
Schema changesMust DROP and rebuildChange SQL and rebuild directly
Storage formatRow-wiseColumnar (Parquet)
Query performanceModerateHigh (columnar + vectorized)
Operational costHigh (locking, maintenance)Low (no locking, no dependencies)

Summary

DuckDB’s “materialization” philosophy is: Parquet stores data + CTAS stores results + indexes accelerate queries. This combination delivers 9x speedup on 1M rows, and the gap widens at larger scales.

The key insight: you don’t need any additional storage engine or scheduling tool. Pure SQL + Python gives you a production-grade data product backend. This is DuckDB’s core competitiveness compared to traditional approaches—simple to the extreme, powerful to the practical.

The complete code repository (including incremental refresh scripts, multi-file aggregation examples, and FastAPI integration examples) is published at duckdblab.org with more detailed steps and additional cases.

📖 Want to systematically learn more DuckDB实战 techniques? duckdblab.org has a complete tutorial series from beginner to advanced, covering CTAS materialization, Parquet optimization, and data product —continuously updated.


本文信息

项目内容
DuckDB 版本v1.5.x(部分功能基于 v2.0 Preview)
最后验证2026-09-12
测试环境Linux / x86_64 / 16GB RAM
官方文档DuckDB Documentation
GitHubpengzz9527/duckdb-blog

如发现错误,欢迎通过 GitHub Issue 或邮件 contact@duckdblab.org 反馈。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials