Background
Incremental Materialized Views (Materialized Views) allow you to shift the cost of computation from query time to insert time, resulting in fasterSELECT queries.
Unlike in transactional databases like Postgres, a ClickHouse materialized view is just a trigger that runs a query on blocks of data as they’re inserted into a table. The result of this query is inserted into a second “target” table. Should more rows be inserted, results will again be sent to the target table where the intermediate results will be updated and merged. This merged result is the equivalent of running the query over all of the original data.
The principal motivation for Materialized Views is that the results inserted into the target table represent the results of an aggregation, filtering, or transformation on rows. These results will often be a smaller representation of the original data (a partial sketch in the case of aggregations). This, along with the resulting query for reading the results from the target table being simple, ensures query times are faster than if the same computation was performed on the original data, shifting computation (and thus query latency) from query time to insert time.
Materialized views in ClickHouse are updated in real time as data flows into the table they’re based on, functioning more like continually updating indexes. This is in contrast to other databases where Materialized Views are typically static snapshots of a query that must be refreshed (similar to ClickHouse Refreshable Materialized Views).
Example
For example purposes we’ll use the Stack Overflow dataset documented in “Schema Design”. Suppose we want to obtain the number of up and down votes per day for a post.toStartOfDay function:
SELECT on data inserted into votes, with the results sent to up_down_votes_per_day:
TO clause here is key, denoting where results will be sent to i.e. up_down_votes_per_day.
We can repopulate our votes table from our earlier insert:
up_down_votes_per_day - we should have 1 row per day:
votes) to 5000 by storing the result of our query. What’s key here, however, is that if new votes are inserted into the votes table, new values will be sent to the up_down_votes_per_day for their respective day where they will be automatically merged asynchronously in the background - keeping only one row per day. up_down_votes_per_day will thus always be both small and up-to-date.
Since the merging of rows is asynchronous, there may be more than one vote per day when a user queries. To ensure any outstanding rows are merged at query time, we have two options:
- Use the
FINALmodifier on the table name. We did this for the count query above. - Aggregate by the ordering key used in our final table i.e.
CreationDateand sum the metrics. Typically this is more efficient and flexible (the table can be used for other things), but the former can be simpler for some queries. We show both below:
A more complex example
The above example uses Materialized Views to compute and maintain two sums per day. Sums represent the simplest form of aggregation to maintain partial states for - we can just add new values to existing values when they arrive. However, ClickHouse Materialized Views can be used for any aggregation type. Suppose we wish to compute some statistics for posts for each day: the 99.9th percentile for theScore and an average of the CommentCount. The query to compute this might look like:
posts table.
For the purposes of example, and to avoid loading the posts data from S3, we will create a duplicate table posts_null with the same schema as posts. However, this table won’t store any data and simply be used by the materialized view when rows are inserted. To prevent storage of data, we can use the Null table engine type.
/dev/null. Our materialized view will compute and store our summary statistics when our posts_null table receives rows at insert time - it’s just a trigger. However, the raw data won’t be stored. While in our case, we probably still want to store the original posts, this approach can be used to compute aggregates while avoiding storage overhead of the raw data.
The materialized view thus becomes:
State to the end of our aggregate functions. This ensures the aggregate state of the function is returned instead of the final result. This will contain additional information to allow this partial state to merge with other states. For example, in the case of an average, this will include a count and sum of the column.
Partial aggregation states are necessary to compute correct results. For example, for computing an average, simply averaging the averages of sub-ranges produces incorrect results.We now create the target table for this view
post_stats_per_day which stores these partial aggregate states:
SummingMergeTree was sufficient to store counts, we require a more advanced engine type for other functions: the AggregatingMergeTree.
To ensure ClickHouse knows that aggregate states will be stored, we define the Score_quantiles and AvgCommentCount as the type AggregateFunction, specifying the function source of the partial states and the type of their source columns. Like the SummingMergeTree, rows with the same ORDER BY key value will be merged (Day in the above example).
To populate our post_stats_per_day via our materialized view, we can simply insert all rows from posts into posts_null:
In production, you would likely attach the materialized view to theOur final query needs to utilize thepoststable. We have usedposts_nullhere to demonstrate the null table.
Merge suffix for our functions (as the columns store partial aggregation states):
GROUP BY here instead of using FINAL.
Other applications
The above focuses primarily on using Materialized Views to incrementally update partial aggregates of data, thus moving the computation from query to insert time. Beyond this common use case, Materialized Views have a number of other applications.Filtering and transformation
In some situations, we may wish to only insert a subset of the rows and columns on insertion. In this case, ourposts_null table could receive inserts, with a SELECT query filtering rows prior to insertion into the posts table. For example, suppose we wished to transform a Tags column in our posts table. This contains a pipe delimited list of tag names. By converting these into an array, we can more easily aggregate by individual tag values.
We could perform this transformation when running anOur materialized view for this transformation is shown below:INSERT INTO SELECT. The materialized view allows us to encapsulate this logic in ClickHouse DDL and keep ourINSERTsimple, with the transformation applied to any new rows.
Lookup table
You should consider their access patterns when choosing a ClickHouse ordering key. Columns which are frequently used in filter and aggregation clauses should be used. This can be restrictive for scenarios where users have more diverse access patterns which can’t be encapsulated in a single set of columns. For example, consider the followingcomments table:
PostId.
Suppose a user wishes to filter on a specific UserId and compute their average Score:
PostId for filtering column UserId. These values can then be used to perform an efficient lookup.
In this example, our materialized view can be very simple, selecting only the PostId and UserId from comments on insert. These results are in turn sent to a table comments_posts_users which is ordered by UserId. We create a null version of the Comments table below and use this to populate our view and comments_posts_users table:
Chaining / cascading materialized views
Materialized views can be chained (or cascaded), allowing complex workflows to be established. For more information see the guide “Cascading materialized views”.Materialized views and JOINs
Refreshable Materialized ViewsThe following applies to Incremental Materialized Views only. Refreshable Materialized Views execute their query periodically over the full target dataset and fully support JOINs. Consider using them for complex JOINs if a reduction in result freshness can be tolerated.
JOIN operations, but with one crucial constraint: the materialized view only triggers on inserts to the source table (the left-most table in the query). Right-side tables in JOINs don’t trigger updates, even if their data changes. This behavior is especially important when building Incremental Materialized Views, where data is aggregated or transformed during insert time.
When an Incremental materialized view is defined using a JOIN, the left-most table in the SELECT query acts as the source. When new rows are inserted into this table, ClickHouse executes the materialized view query only with those newly inserted rows. Right-side tables in the JOIN are read in full during this execution, but changes to them alone don’t trigger the view.
This behavior makes JOINs in Materialized Views similar to a snapshot join against static dimension data.
This works well for enriching data with reference or dimension tables. However, any updates to the right-side tables (e.g., user metadata) won’t retroactively update the materialized view. To see updated data, new inserts must arrive in the source table.
Example
Let’s walk through a concrete example using the Stack Overflow dataset. We’ll use a materialized view to compute daily badges per user, including the display name of the user from theusers table.
As a reminder, our table schemas are:
users table is pre-populated:
Grouping and Ordering AlignmentThe
GROUP BY clause in the materialized view must include DisplayName, UserId, and Day to match the ORDER BY in the SummingMergeTree target table. This ensures rows are correctly aggregated and merged. Omitting any of these can lead to incorrect results or inefficient merges.daily_badges_by_user table.
Best practices for JOINs in materialized views
-
Use the left-most table as the trigger. Only the table on the left side of the
SELECTstatement triggers the materialized view. Changes to right-side tables won’t trigger updates. - Pre-insert joined data. Ensure that data in joined tables exists before inserting rows into the source table. The JOIN is evaluated at insert time, so missing data will result in unmatched rows or nulls.
- Limit columns pulled from joins. Select only the required columns from joined tables to minimize memory use and reduce insert-time latency (see below).
- Evaluate insert-time performance. JOINs increase the cost of inserts, especially with large right-side tables. Benchmark insert rates using representative production data.
- Prefer dictionaries for simple lookups. Use Dictionaries for key-value lookups (e.g., user ID to name) to avoid expensive JOIN operations.
-
Align
GROUP BYandORDER BYfor merge efficiency. When usingSummingMergeTreeorAggregatingMergeTree, ensureGROUP BYmatches theORDER BYclause in the target table to allow efficient row merging. - Use explicit column aliases. When tables have overlapping column names, use aliases to prevent ambiguity and ensure correct results in the target table.
- Consider insert volume and frequency. JOINs work well in moderate insert workloads. For high-throughput ingestion, consider using staging tables, pre-joins, or other approaches such as Dictionaries and Refreshable Materialized Views.
Using source table in filters and joins
When working with Materialized Views in ClickHouse, it’s important to understand how the source table is treated during the execution of the materialized view’s query. Specifically, the source table in the materialized view’s query is replaced with the inserted block of data. This behavior can lead to some unexpected results if not properly understood.Example scenario
Consider the following setup:Explanation
In the above example, we have two Materialized Viewsmvw1 and mvw2 that perform similar operations but with a slight difference in how they reference the source table t0.
In mvw1, table t0 is directly referenced inside a (SELECT * FROM t0) subquery on the right side of the JOIN. When data is inserted into t0, the materialized view’s query is executed with the inserted block of data replacing t0. This means that the JOIN operation is performed only on the newly inserted rows, not the entire table.
In the second case with joining vt0, the view reads all the data from t0. This ensures that the JOIN operation considers all rows in t0, not just the newly inserted block.
The key difference lies in how ClickHouse handles the source table in the materialized view’s query. When a materialized view is triggered by an insert, the source table (t0 in this case) is replaced by the inserted block of data. This behavior can be leveraged to optimize queries but also requires careful consideration to avoid unexpected results.
Use cases and caveats
In practice, you may use this behavior to optimize Materialized Views that only need to process a subset of the source table’s data. For example, you can use a subquery to filter the source table before joining it with other tables. This can help reduce the amount of data processed by the materialized view and improve performance.IN (SELECT id FROM t0) subquery has only the newly inserted rows, which can help to filter t1 against it.
Example with stack overflow
Consider our earlier materialized view example to compute daily badges per user, including the user’s display name from theusers table.
badges table e.g.
users table using the user ids in the inserted badge rows:
2936484. This lookup is also optimized with a table ordering key of Id.
Materialized views and unions
UNION ALL queries are commonly used to combine data from multiple source tables into a single result set.
While UNION ALL isn’t directly supported in Incremental Materialized Views, you can achieve the same outcome by creating a separate materialized view for each SELECT branch and writing their results to a shared target table.
For our example, we’ll use the Stack Overflow dataset. Consider the badges and comments tables below, which represent the badges earned by a user and the comments they make on posts:
INSERT INTO commands:
badges or comments, a naive approach to this problem may be to try and create a materialized view with the previous union query:
comments table. For example:
badges table won’t trigger the view, causing user_activity to not receive updates:
comments table:
badges table are reflected in the user_activity table:
Parallel vs sequential processing
As shown in the previous example, a table can act as the source for multiple Materialized Views. The order in which these are executed depends on the settingparallel_view_processing.
By default, this setting is equal to 0 (false), meaning Materialized Views are executed sequentially in uuid order.
For example, consider the following source table and 3 Materialized Views, each sending rows to a target table:
target table while also including their name and insertion time.
Inserting a row into the table source takes ~3 seconds, with each view executing sequentially:
SELECT:
uuid of the views:
parallel_view_processing=1 enabled. With this enabled, the views are executed in parallel, giving no guarantees to the order at which rows arrive to the target table:
When to use parallel processing
Enablingparallel_view_processing=1 can significantly improve insert throughput, as shown above, especially when multiple Materialized Views are attached to a single table. However, it’s important to understand the trade-offs:
- Increased insert pressure: All Materialized Views are executed simultaneously, increasing CPU and memory usage. If each view performs heavy computation or JOINs, this can overload the system.
- Need for strict execution order: In rare workflows where the order of view execution matters (e.g., chained dependencies), parallel execution may lead to inconsistent state or race conditions. While possible to design around this, such setups are fragile and may break with future versions.
Historical defaults and stabilitySequential execution was the default for a long time, in part due to error handling complexities. Historically, a failure in one materialized view could prevent others from executing. Newer versions have improved this by isolating failures per block, but sequential execution still provides clearer failure semantics.
parallel_view_processing=1 when:
- You have multiple independent Materialized Views
- You’re aiming to maximize insert performance
- You’re aware of the system’s capacity to handle concurrent view execution
- Materialized Views have dependencies on one another
- You require predictable, ordered execution
- You’re debugging or auditing insert behavior and want deterministic replay
Materialized views and Common Table Expressions (CTE)
Non-recursive Common Table Expressions (CTEs) are supported in Materialized Views.Common Table Expressions aren’t materializedClickHouse doesn’t materialize CTEs; instead, it substitutes the CTE definition directly into the query, which can lead to multiple evaluations of the same expression (if the CTE is used more than once).
- If your CTE references a different table from the source table (i.e., the one the materialized view is attached to), and is used in a
JOINorINclause, it will behave like a subquery or join, not a trigger. - The materialized view will still only trigger on inserts into the main source table, but the CTE will be re-executed on every insert, which may cause unnecessary overhead, especially if the referenced table is large.