ClickHouse Materialized Views & ReplacingMergeTree: Sub-Second Real-Time Analytics

Table of Contents(9 sections)
ClickHouse excels at real-time analytical workloads. Achieving sub-second query latencies on high-cardinality, high-volume data streams often necessitates pre-aggregation and efficient deduplication. This guide details the architecture and implementation of ClickHouse Materialized Views with AggregatingMergeTree and ReplacingMergeTree engines to build a robust, real-time analytics pipeline.
Architectural Overview: Stream Aggregation & Deduplication
The core problem addressed is the need for real-time, aggregated metrics from an event stream, where events might arrive out-of-order or be duplicated. Our solution involves:
- Raw Event Table: An append-only table storing all incoming events. This serves as the source of truth.
- Deduplication Layer: A
ReplacingMergeTreetable to ensure event idempotency, handling late-arriving or replayed events. - Materialized View for Aggregation: An
AggregatingMergeTreetable, populated by a Materialized View, to pre-compute aggregates. This table stores stateful aggregates, significantly reducing query time.
This layered approach provides both data integrity and query performance.
Raw Event Ingestion with ReplacingMergeTree
First, define a raw events table. While a simple MergeTree could suffice, ReplacingMergeTree is crucial for idempotent ingestion, especially when dealing with event replay or at-least-once delivery semantics from message queues like Kafka.
CREATE TABLE IF NOT EXISTS events_raw (
event_id UUID,
user_id String,
event_type LowCardinality(String),
event_time DateTime64(3),
value Float64,
_version UInt64 DEFAULT 1 -- Version column for ReplacingMergeTree
) ENGINE = ReplacingMergeTree(_version)
ORDER BY (event_id, event_time)
PRIMARY KEY (event_id);
ReplacingMergeTree(_version): This engine ensures that for any givenORDER BYkey (here,event_id, event_time), only the row with the maximum_versionis kept during merges. If_versionis omitted, the last row by insertion order is kept.ORDER BY (event_id, event_time): Defines the sort key.ReplacingMergeTreeuses this to identify "duplicate" rows.PRIMARY KEY (event_id): Optimizes point lookups and range scans onevent_id.
Let's insert some sample data, including a duplicate event_id with a higher version.
INSERT INTO events_raw (event_id, user_id, event_type, event_time, value, _version) VALUES
('a0000000-0000-0000-0000-000000000001', 'user1', 'page_view', '2023-10-26 10:00:00.000', 1.0, 1),
('a0000000-0000-0000-0000-000000000002', 'user1', 'click', '2023-10-26 10:00:05.000', 0.5, 1),
('a0000000-0000-0000-0000-000000000003', 'user2', 'page_view', '2023-10-26 10:00:10.000', 1.0, 1),
('a0000000-0000-0000-0000-000000000001', 'user1', 'page_view', '2023-10-26 10:00:00.000', 1.2, 2); -- Duplicate event_id, higher version
To observe the deduplication, we need to force a merge or wait for background merges.
OPTIMIZE TABLE events_raw FINAL;
SELECT event_id, user_id, value, _version FROM events_raw ORDER BY event_id;
Output:
┌─event_id─────────────────────────────┬─user_id─┬─value─┬─_version─┐
│ a0000000-0000-0000-0000-000000000001 │ user1 │ 1.2 │ 2 │
│ a0000000-0000-0000-0000-000000000002 │ user1 │ 0.5 │ 1 │
│ a0000000-0000-0000-0000-000000000003 │ user2 │ 1.0 │ 1 │
└──────────────────────────────────────┴─────────┴───────┴──────────┘
Notice event_id a0000000-0000-0000-0000-000000000001 now has value 1.2 and _version 2, demonstrating the deduplication.
Materialized Views for Real-Time Aggregation
Materialized Views in ClickHouse are not pre-computed snapshots like in traditional RDBMS. Instead, they are triggers that execute a SELECT query on new data inserted into the source table and write the results into a target table. This makes them ideal for continuous, incremental aggregation.
AggregatingMergeTree for Stateful Aggregates
The target table for our Materialized View will use the AggregatingMergeTree engine. This engine stores states of aggregate functions, not their final values. When data parts are merged, these states are combined using the *Merge functions.
Define the aggregated table:
CREATE TABLE IF NOT EXISTS events_agg_mv (
event_date Date,
event_type LowCardinality(String),
user_id String,
total_value AggregateFunction(sum, Float64),
unique_users AggregateFunction(uniqHLL12, String),
event_count AggregateFunction(count)
) ENGINE = AggregatingMergeTree()
ORDER BY (event_date, event_type, user_id);
AggregateFunction(sum, Float64): This special data type stores the intermediate state of thesumaggregate function.uniqHLL12: A highly efficient approximate distinct count algorithm, suitable for high-cardinality data.ORDER BY (event_date, event_type, user_id): Defines the aggregation key. All rows with the same key will be merged, and their aggregate states combined.
Creating the Materialized View
Now, create the Materialized View that populates events_agg_mv from events_raw.
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_events_agg
TO events_agg_mv
AS SELECT
toDate(event_time) AS event_date,
event_type,
user_id,
sumState(value) AS total_value,
uniqHLL12State(user_id) AS unique_users,
countState() AS event_count
FROM events_raw
GROUP BY event_date, event_type, user_id;
TO events_agg_mv: Specifies the target table.sumState(value),uniqHLL12State(user_id),countState(): These are the "state" versions of aggregate functions. They return the intermediate state of the aggregation, whichAggregatingMergeTreestores.
Let's insert more data into events_raw and observe the Materialized View's effect.
INSERT INTO events_raw (event_id, user_id, event_type, event_time, value, _version) VALUES
('a0000000-0000-0000-0000-000000000004', 'user1', 'page_view', '2023-10-26 10:00:15.000', 1.0, 1),
('a0000000-0000-0000-0000-000000000005', 'user2', 'click', '2023-10-26 10:00:20.000', 0.8, 1),
('a0000000-0000-0000-0000-000000000006', 'user1', 'page_view', '2023-10-27 11:00:00.000', 1.0, 1);
Query the aggregated table. Note the use of *Merge functions to finalize the aggregates.
SELECT
event_date,
event_type,
user_id,
sumMerge(total_value) AS final_total_value,
uniqHLL12Merge(unique_users) AS final_unique_users,
countMerge(event_count) AS final_event_count
FROM events_agg_mv
GROUP BY event_date, event_type, user_id
ORDER BY event_date, event_type, user_id;
Output:
┌─event_date─┬─event_type─┬─user_id─┬─final_total_value─┬─final_unique_users─┬─final_event_count─┐
│ 2023-10-26 │ click │ user1 │ 0.5 │ 1 │ 1 │
│ 2023-10-26 │ click │ user2 │ 0.8 │ 1 │ 1 │
│ 2023-10-26 │ page_view │ user1 │ 2.2 │ 1 │ 2 │
│ 2023-10-26 │ page_view │ user2 │ 1.0 │ 1 │ 1 │
│ 2023-10-27 │ page_view │ user1 │ 1.0 │ 1 │ 1 │
└────────────┴────────────┴─────────┴───────────────────┴────────────────────┴───────────────────┘
The aggregates are correctly updated in real-time. The ReplacingMergeTree on events_raw ensures that if an event is re-sent with a higher version, the Materialized View will process the updated event, and the AggregatingMergeTree will correctly reflect the change upon subsequent merges.
Comparison: Normal View vs. Materialized View
| Feature | Normal View (e.g., CREATE VIEW) | Materialized View (e.g., CREATE MATERIALIZED VIEW) |
|---|---|---|
| Data Storage | No data stored, query executed on demand | Data pre-computed and stored in a target table |
| Query Performance | Depends on underlying tables, can be slow for complex aggregations | Extremely fast for pre-aggregated data, sub-second |
| Data Freshness | Always real-time | Real-time (as data is inserted into source table) |
| Resource Usage | Low storage, high query CPU/IO | High storage, low query CPU/IO |
| Use Case | Simple aliases, complex ad-hoc queries | Real-time dashboards, fixed reports, high-volume analytics |
Optimizing Sub-50ms Analytical Queries
Achieving sub-50ms latency requires careful consideration of table design, query patterns, and ClickHouse configuration.
AggregatingMergeTreeORDER BYKey: TheORDER BYclause inevents_agg_mvis critical. It should match the commonGROUP BYandWHEREclauses of your analytical queries. For example, if you frequently query byevent_dateandevent_type, these should be leading columns inORDER BY.PRIMARY KEY: ForAggregatingMergeTree, thePRIMARY KEYis typically a prefix of theORDER BYkey. It helps prune data parts quickly.LowCardinalityData Type: UseLowCardinality(String)for columns with a limited number of distinct values (e.g.,event_type). This significantly reduces storage and improves query performance due to dictionary encoding.DateTime64vsDateTime:DateTime64offers millisecond precision, which is often necessary for event streams. Ensure yourtoDate()ortoStartOfHour()functions align with your desired aggregation granularity.FINALKeyword: When queryingAggregatingMergeTreeorReplacingMergeTree,SELECT ... FROM table FINALensures all merges are completed and you get the fully aggregated/deduplicated result. However,FINALcan be slow as it forces merges. For real-time dashboards, you might tolerate slightly stale data and omitFINAL, relying on background merges. For critical reports,FINALis necessary.index_granularity: This setting (default 8192) determines how many rows are in a data block for indexing. Adjusting it can impact performance, but the default is often suitable.- Hardware: Sufficient RAM, fast NVMe SSDs, and CPU cores are paramount for ClickHouse performance.
- Distributed Tables: For very large datasets, use
Distributedtables on top ofAggregatingMergeTreeto scale horizontally.
Production Gotchas & Troubleshooting
-
Materialized View Lag:
- Symptom:
events_agg_mvis not updating quickly, or queries on it show old data. - Cause: High ingestion rate into
events_rawcombined with complex MV logic or resource constraints. Materialized Views process data synchronously on insertion. If the MV query is slow, it can block insertions. - Fix:
- Simplify the MV
SELECTquery. - Ensure
events_rawhas appropriateORDER BYandPRIMARY KEYfor efficient MV processing. - Scale ClickHouse resources (CPU, RAM, IO).
- Consider using an asynchronous Materialized View (by omitting
TO target_tableand letting the MV create its own.table, then creating a separateAggregatingMergeTreetable and inserting into it from the.table via a separate process or another MV). This decouples ingestion from aggregation but adds complexity. For most cases, synchronous MVs are preferred for simplicity and real-time guarantees.
- Simplify the MV
- Symptom:
-
ReplacingMergeTreeNot Deduplicating:- Symptom: Duplicate
event_ids persist even afterOPTIMIZE TABLE FINAL. - Cause: Incorrect
ORDER BYkey.ReplacingMergeTreededuplicates based on theORDER BYkey. Ifevent_idis not part of theORDER BYkey, or if the_versioncolumn is not correctly used, deduplication won't happen as expected. - Fix: Verify
ORDER BYincludes the unique identifier (e.g.,event_id) and the version column (e.g.,_version) is correctly populated and specified in the engine definition.
- Symptom: Duplicate
-
AggregatingMergeTreeQuery Performance:- Symptom: Queries on
events_agg_mvare slow, even with*Mergefunctions. - Cause:
GROUP BYclause in the query does not align with theORDER BYkey ofevents_agg_mv. This forces ClickHouse to read more data than necessary.- Too many distinct values in the
GROUP BYcolumns, leading to a large number of small data parts or high memory usage during merge. - Using
FINALunnecessarily.
- Fix:
- Refactor
events_agg_mvORDER BYto match common query patterns. - Ensure
PRIMARY KEYis a prefix ofORDER BY. - Avoid
FINALunless strictly necessary for correctness. - Consider pre-aggregating further if intermediate aggregates are still too granular.
- Refactor
- Symptom: Queries on
-
Disk Space Consumption:
- Symptom:
AggregatingMergeTreetables consume excessive disk space. - Cause:
AggregateFunctionstates can be larger than final values, especially for functions likeuniqHLL12StateorgroupArrayState. Also, ifORDER BYkey has high cardinality, it can lead to many small data parts. - Fix:
- Review aggregate functions.
uniqHLL12is space-efficient for distinct counts.groupArrayStatecan be very large. - Ensure
ORDER BYkey is chosen to balance aggregation granularity and data part size. - Implement a TTL (Time-To-Live) policy on
events_rawandevents_agg_mvto automatically delete old data.
- Review aggregate functions.
- Symptom:
-- Example TTL for events_raw (delete raw data after 30 days)
ALTER TABLE events_raw MODIFY TTL event_time + INTERVAL 30 DAY;
-- Example TTL for events_agg_mv (delete aggregates after 365 days)
ALTER TABLE events_agg_mv MODIFY TTL event_date + INTERVAL 365 DAY;
Frequently Asked Questions
-
Can I modify a Materialized View after creation? No, Materialized Views cannot be directly modified. You must
DROPandCREATEthem again. This is why it's crucial to design your MV carefully. If the target table schema changes, you must also drop and recreate the MV. -
What happens if data is deleted from the source table of a Materialized View? Materialized Views only react to
INSERToperations.DELETEorUPDATEoperations on the source table will not automatically propagate to the Materialized View's target table. If you need to handle deletions, you'd typically implement a "soft delete" mechanism (e.g., anis_deletedflag) and filter it in your queries, or use a more complexCollapsingMergeTreeorVersionedCollapsingMergeTreesetup. -
How does
ReplacingMergeTreehandle concurrent inserts with the sameORDER BYkey?ReplacingMergeTreehandles concurrent inserts by applying the replacement logic during merges. If two inserts with the sameORDER BYkey but different_versionvalues arrive concurrently, they will initially exist in separate data parts. When these parts are merged, the row with the highest_versionwill be retained. This ensures eventual consistency. -
When should I use
AggregatingMergeTreeversus a regularMergeTreewithGROUP BY? UseAggregatingMergeTreewhen you need to continuously aggregate data in real-time and query those aggregates frequently. It pre-computes and stores aggregate states, making queries significantly faster. Use a regularMergeTreewithGROUP BYfor ad-hoc aggregations on raw data where real-time performance isn't critical, or when the aggregation keys are highly dynamic and cannot be pre-defined.AggregatingMergeTreeis for fixed, known aggregation patterns. -
Are Materialized Views suitable for all types of aggregations? Materialized Views are best suited for aggregations that are additive or can be expressed using
AggregateFunctionstates. This includessum,count,min,max,uniqHLL12,avg(usingsumStateandcountState), etc. Aggregations requiring access to all raw rows (e.g.,quantile,medianwithout specificAggregateFunctionsupport) or complex window functions are generally not suitable for direct Materialized View pre-aggregation and are better run on the raw data or a more granular aggregate.
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

DuckDB-Wasm in Modern Web Apps: Client-Side OLAP, Parquet Streaming & Sub-Second Dashboards
Comprehensive guide covering duckdb-wasm in modern web apps: client-side olap, parquet streaming & sub-second dashboards with production-grade architecture and code examples.
Read more
ClickHouse vs DuckDB (2026): When to Use Each for OLAP Workloads
ClickHouse vs DuckDB 2026: DuckDB wins for embedded analytics and local queries; ClickHouse wins for distributed real-time OLAP at scale. Full benchmarks, architecture comparison, and decision guide.
Read more
PostgreSQL 17 Query Optimization: Execution Plans, Memory Tuning & EXPLAIN ANALYZE
Comprehensive guide covering postgresql 17 query optimization: execution plans, memory tuning & explain analyze with production-grade architecture and code examples.
Read more