Loading learning content...
Pipelining is elegant and efficient, but it has fundamental limitations. Some operations simply cannot produce output until they've seen all their input. A sort cannot tell you the smallest element without examining every element. An aggregation cannot compute the average without summing all values. In these cases, databases must materialize—explicitly store intermediate results before proceeding.
Materialization is the counterpart to pipelining, representing a deliberate trade-off of memory and I/O for correctness or necessity. Understanding when materialization occurs, why it's sometimes preferable, and how to minimize its costs is essential for mastering query execution.
By the end of this page, you will understand the concept of materialization and when it's required, explore the trade-offs between materialization and pipelining, examine different materialization strategies and their use cases, and learn techniques for minimizing materialization overhead in query execution.
Materialization in query execution refers to the explicit storage of intermediate query results, either in memory or on disk, before passing them to the next operator. Unlike pipelining, where tuples flow through immediately, materialization captures and holds results.
Formal Definition:
An operator materializes its output when it stores the complete (or partial) result set before returning any tuples to its parent. The consumers then read from this materialized storage rather than directly from the producing operator.
Why Materialization Exists:
Materialization isn't binary—it exists on a spectrum. Full materialization stores everything to disk. Partial materialization buffers some tuples in memory. Even pipelined operators maintain minimal state (current tuple). Understanding where your query falls on this spectrum is key to performance analysis.
Some operators fundamentally require materialization because their algorithms cannot produce output until they've consumed all input. These are the blocking operators we introduced earlier, now examined from the materialization perspective.
Sort Operator:
Sorting is the canonical example of required materialization. To produce tuples in sorted order, the sort operator must see all input tuples to determine the global ordering.
123456789101112131415161718192021222324
Sort Operator: ORDER BY salary ASC Phase 1: Materialization (during Open())═══════════════════════════════════════════════════════════════ Input Stream → Materialized Buffer ───────────────────────────────────────── {salary: 65000} → [65000] {salary: 45000} → [65000, 45000] {salary: 55000} → [65000, 45000, 55000] {salary: 75000} → [65000, 45000, 55000, 75000] ... (all 10,000 tuples) → [buffer with 10,000 tuples] Sort buffer: [45000, 55000, 65000, 75000, ...] ↑ sorted order Phase 2: Output (during Next() calls)═══════════════════════════════════════════════════════════════ Next() → 45000 (reads from materialized buffer) Next() → 55000 (reads from materialized buffer) Next() → 65000 (reads from materialized buffer) ... Memory: O(n) where n = input tuplesHash Aggregate Operator:
Aggregation with grouping requires building a complete hash table of all groups and their accumulated values:
1234567891011121314151617181920212223
Hash Aggregate: SELECT dept, SUM(salary), COUNT(*) FROM emp GROUP BY dept Phase 1: Materialization (during Open())═══════════════════════════════════════════════════════════════ Input Hash Table State ───────────────────────────────────────────────────────── {dept:A, salary:50000} {A: {sum:50000, count:1}} {dept:B, salary:60000} {A: {...}, B: {sum:60000, count:1}} {dept:A, salary:55000} {A: {sum:105000, count:2}, B: {...}} {dept:C, salary:70000} {A: {...}, B: {...}, C: {sum:70000, count:1}} {dept:B, salary:65000} {A: {...}, B: {sum:125000, count:2}, C: {...}} ... (all employees) Complete hash table of all groups Phase 2: Output (during Next() calls)═══════════════════════════════════════════════════════════════ Iterate over hash table entries: Next() → {dept:A, sum:105000, count:2} Next() → {dept:B, sum:125000, count:2} Next() → {dept:C, sum:70000, count:1} ... Memory: O(g) where g = number of distinct groups| Operator | What Gets Materialized | Why It's Required |
|---|---|---|
| Sort | All input tuples | Must see all to determine order |
| Hash Aggregate | Group states (keys + accumulators) | Must accumulate all group values |
| Hash Join (Build) | Complete hash table of build relation | Must have complete hash table for probing |
| Hash Distinct | Set of all distinct values | Must see all to know what's duplicate |
| Window Functions (some) | Entire partition or frame | Frame may depend on future rows |
| Set Operations (Except/Intersect) | Complete set for comparison | Must see all to compute set difference/intersection |
Required materialization can consume enormous memory for large inputs. A sort of 10 million rows might need several gigabytes. When memory limits are exceeded, operators must spill to disk—dramatically increasing execution time due to I/O.
Not all materialization is algorithmically required. Sometimes, the query optimizer or executor chooses to materialize intermediate results for strategic reasons, even when pipelining would be correct. This elective materialization can improve overall performance.
Common Reasons for Elective Materialization:
Example: Multiple Consumers
Consider a query with a CTE (Common Table Expression) referenced twice:
12345678
WITH expensive_calc AS ( SELECT dept_id, AVG(salary) as avg_sal FROM employees GROUP BY dept_id)SELECT (SELECT COUNT(*) FROM expensive_calc WHERE avg_sal > 50000) as high_paying_depts, (SELECT COUNT(*) FROM expensive_calc WHERE avg_sal < 30000) as low_paying_depts;Example: Nested Loop Materialization
For nested loop joins where the inner relation is expensive to compute:
12345678910111213141516171819
Query: SELECT * FROM outer_table o, (complex_subquery) s WHERE o.id = s.ref_id Without Materialization:═══════════════════════════════════════════════════════════════ [Nested Loop Join] ├── [Scan: outer_table] (1000 rows) └── [Complex Subquery] ← EXECUTED 1000 TIMES! ❌ If subquery takes 100ms: 1000 × 100ms = 100 seconds With Materialization:═══════════════════════════════════════════════════════════════ [Nested Loop Join] ├── [Scan: outer_table] (1000 rows) └── [Materialize] ← EXECUTED ONCE ✓ └── [Complex Subquery] If subquery takes 100ms + scan materialized (1ms × 1000): ~1.1 secondsQuery optimizers use cost models to decide when elective materialization pays off. They compare the cost of recomputation versus the cost of materialization + rescanning. PostgreSQL's planner, for example, explicitly considers Materialize nodes when inner relations would otherwise be expensive to rescan.
Not all materialization is equal. Databases employ various strategies depending on the size of intermediate results, memory constraints, and access patterns.
Memory-Based Materialization:
The fastest option when results fit in memory. Tuples are stored in memory buffers and can be accessed with minimal overhead.
| Strategy | Storage | Speed | When Used |
|---|---|---|---|
| In-Memory Buffer | RAM only | Fastest | Small results within work_mem |
| Memory + Disk Spill | RAM + temp files | Moderate | Results exceed memory limits |
| Full Disk-Based | Temp tablespace | Slowest | Very large intermediate results |
| Hash Table | RAM (spills if needed) | Fast lookups | Hash joins, hash aggregates |
| Tuple Store | Efficient tuple packing | Compact | Materialize nodes, CTE storage |
Spilling to Disk:
When in-memory buffers are exhausted, operators must spill data to disk. This process significantly impacts performance:
123456789101112131415161718192021222324252627282930313233343536
External Merge Sort (when data exceeds memory): Phase 1: Run Generation═══════════════════════════════════════════════════════════════ work_mem = 64MB (can hold ~500K tuples) Input = 5M tuples (far exceeds memory) Fill memory buffer: Read 500K tuples → buffer full Sort in memory Write to temp file: run_0 Fill memory buffer again: Read next 500K tuples → buffer full Sort in memory Write to temp file: run_1 ... repeat until input exhausted ... Result: 10 sorted runs on disk (run_0 through run_9) Phase 2: Merge═══════════════════════════════════════════════════════════════ Open all run files Perform k-way merge: - Read smallest tuple from each run's buffer - Output the minimum - Refill that run's buffer as needed If too many runs to merge at once (k too large): - Do multi-pass merging - Merge groups of runs into larger runs - Repeat until single sorted output Cost: 2× I/O per tuple (write runs + read for merge) Potentially more for multi-pass mergesDisk-based materialization causes significant I/O amplification. Data is written to disk, then read back—often multiple times for merge passes. A query that could pipeline in 1 second might take 100+ seconds when forced to materialize to disk. Monitoring for disk spills is essential for performance tuning.
Effective memory management is critical for materialization performance. Databases use sophisticated techniques to balance memory across operators and minimize disk spillage.
Per-Operator Memory Limits:
Most databases assign memory budgets to blocking operators. PostgreSQL uses work_mem, which sets the per-operator memory limit:
1234567891011
-- PostgreSQL work_mem settingSET work_mem = '256MB'; -- Per-operation memory limit -- A query with multiple blocking operators:SELECT dept, COUNT(*), AVG(salary)FROM employeesGROUP BY dept -- Hash Aggregate: uses up to 256MBORDER BY dept; -- Sort: uses up to 256MB -- Total potential memory: 512MB (256MB × 2 blocking operators)-- If either exceeds 256MB individually, it spills to diskAdaptive Memory Allocation:
Modern databases increasingly use adaptive strategies that allocate memory dynamically based on actual needs:
| Database | Parameter | Description |
|---|---|---|
| PostgreSQL | work_mem | Per-operation memory for sorts and hashes |
| PostgreSQL | hash_mem_multiplier | Multiplier for hash operations (PG 13+) |
| MySQL | sort_buffer_size | Memory for sort operations |
| MySQL | join_buffer_size | Memory for join operations |
| Oracle | PGA_AGGREGATE_TARGET | Total working memory across all sessions |
| SQL Server | max server memory | Total buffer pool (includes workspace) |
When optimizing for large analytical queries, increasing work_mem (or equivalent) can dramatically improve performance by avoiding disk spills. However, be cautious in high-concurrency OLTP scenarios—if many sessions run large queries simultaneously, they might exhaust server memory.
Materialized views extend the concept of materialization beyond single query execution. They persistently store the results of a query, enabling fast access to expensive computations.
Materialized View Concept:
Unlike regular views (which are just stored queries), materialized views store the actual result data. This trades storage space for query performance.
12345678910111213141516171819202122
-- Create a materialized view for expensive aggregationCREATE MATERIALIZED VIEW sales_summary ASSELECT region, product_category, DATE_TRUNC('month', sale_date) as month, SUM(amount) as total_sales, COUNT(*) as transaction_count, AVG(amount) as avg_saleFROM salesWHERE sale_date >= '2024-01-01'GROUP BY region, product_category, DATE_TRUNC('month', sale_date); -- Query the materialized view (very fast - reads pre-computed data)SELECT * FROM sales_summary WHERE region = 'North America' AND month = '2024-06-01'; -- Refresh the materialized view when underlying data changesREFRESH MATERIALIZED VIEW sales_summary; -- Optionally refresh concurrently (PostgreSQL - no locks)REFRESH MATERIALIZED VIEW CONCURRENTLY sales_summary;Materialized View Trade-offs:
Refresh Strategies:
Different applications require different refresh strategies depending on freshness requirements versus computational cost:
| Strategy | When | Freshness | Cost |
|---|---|---|---|
| On-Demand | Manual trigger | Potentially stale | Control over timing |
| Scheduled | Cron/job scheduler | Predictable lag | Batch processing |
| On-Commit | After each write | Always fresh | Write overhead |
| Incremental | Delta/change processing | Near-fresh | Complex implementation |
Some advanced databases (Oracle, SQL Server) can automatically rewrite queries to use materialized views even when the query doesn't reference them directly. If a query is logically derivable from a materialized view, the optimizer substitutes the view for dramatic performance improvement.
Modern database research explores alternatives to the traditional pull-based iterator model. Push-based execution (or materialization-first) approaches offer different trade-offs that can significantly impact performance.
Pull-Based (Traditional):
In pull-based systems, parent operators pull tuples from children. Pipelining is the default; materialization happens only when blocking operators require it.
1234567891011121314151617181920212223242526272829303132
PULL-BASED (Volcano/Iterator Model):═══════════════════════════════════════════════════════════════ Control Flow: Parent calls Child.Next()Data Flow: Child returns tuple to Parent [Aggregate] ←── calls Next() ───┐ │ │ │ │ [Filter] ←── returns tuple ──┤ │ │ │ │ [Scan] ←── calls Next() ───┘ Execution: Pull on demand, lazy evaluation PUSH-BASED (Producer-Driven):═══════════════════════════════════════════════════════════════ Control Flow: Child calls Parent.consume(tuple)Data Flow: Child pushes tuple to Parent [Scan] ─── produces, pushes ───┐ │ │ ▼ │ [Filter] ─── pushes if passes ───┤ │ │ ▼ │ [Aggregate] ←── receives tuple ─────┘ Execution: Push through pipeline, producer-drivenPush-Based Advantages:
Full Materialization Model:
Some modern in-memory databases (like HyPer) use a full materialization approach where each operator completely materializes its output before the next operator runs. This seems counterintuitive but enables aggressive optimization:
| Model | Memory Usage | Latency | Throughput | Best For |
|---|---|---|---|---|
| Pull (Pipelining) | O(1) per tuple | Low first-tuple | Moderate | OLTP, interactive |
| Push (Pipelining) | O(1) per tuple | Low first-tuple | Better | Mixed workloads |
| Full Materialization | O(n) per operator | Higher first-tuple | Highest | Analytics, batch |
State-of-the-art systems like HyPer combine push-based execution with 'morsel-driven' parallelism—data is divided into small chunks (morsels) that workers pull from a shared pool, but within each morsel, execution is push-based. This combines the benefits of lazy morsel assignment with aggressive intra-morsel optimization.
While materialization is sometimes unavoidable, several techniques can minimize its performance impact. Understanding these strategies helps both query writers and DBAs optimize execution.
Strategy 1: Reduce Input to Blocking Operators
Filter and project as early as possible to minimize what blocking operators must materialize:
123456789101112131415161718192021222324252627282930
Query: SELECT dept, AVG(salary) FROM employees WHERE hire_date > '2020-01-01' GROUP BY dept ORDER BY dept Before Optimization:═══════════════════════════════════════════════════════ [Sort] ← Sorts all groups │ [Hash Aggregate] ← Aggregates 10M employees │ [Filter] ← Filters by hire_date │ [Scan: employees] ← Reads all 10M rows Materialization: Full 10M rows go into Hash Aggregate After Optimization (Predicate Pushdown):═══════════════════════════════════════════════════════ [Sort] ← Sorts all groups │ [Hash Aggregate] ← Aggregates only 1M rows ✓ │ [Scan + Filter] ← Reads and filters │ (physical filtering) [employees] ← 1M rows pass filter Materialization: Only 1M rows go into Hash AggregateSavings: 90% reduction in materialization!Strategy 2: Increase Memory Limits
Avoiding disk spills by providing adequate memory for blocking operators:
Strategy 3: Leverage Sorted Indexes
If data is already sorted, streaming operations can replace blocking ones:
12345678910111213
-- Without index: requires Hash Aggregate (blocks) + Sort (blocks)SELECT dept_id, COUNT(*), SUM(salary)FROM employeesGROUP BY dept_idORDER BY dept_id; -- Create sorted index on grouping columnCREATE INDEX idx_emp_dept ON employees(dept_id); -- With index: Stream Aggregate (pipelines) + no Sort needed!-- Index scan produces rows in dept_id order-- Aggregate can emit groups as they complete (streaming)-- Output already sorted, ORDER BY is freeSometimes rewriting queries can reduce materialization. For example, using LIMIT with an ordered subquery might enable Top-N sorting (materializes only N tuples) instead of full sorting. Understanding execution plan implications helps write more efficient SQL.
Materialization is the essential counterpart to pipelining in query execution. Let's consolidate the key concepts:
What's Next:
Now that we understand both pipelining and materialization, we'll explore the final frontier of query execution efficiency: Parallelism. Modern databases leverage multiple CPU cores and even multiple machines to dramatically accelerate query execution.
You now understand materialization—when and why databases store intermediate results. You can identify required versus elective materialization, recognize the impact of memory limits and disk spilling, and apply strategies to minimize materialization overhead. Next, we'll explore how parallelism takes query execution to the next level.