Page under construction

Real-time analytics, end to end.

Explore a ClickHouse analytics pipeline, one stage at a time, from sources to dashboards.

Example 1 Β· Multi-tenant SaaS

Live usage dashboards for every customer #

Each customer sees its own live usage. Five stages take an event from its source to a customer's chart; pick one to see how.

Select a stage to explore

ClickHouse stages 2–4

01 Ingest Data comes in

Live events
1Kafka topicBuffer the eventsevent Β· $10event Β· $2012BatchGroup into one insert2 events β†’ 1 insert23events tableOne new part1 new part3
1Kafka topicBuffer the eventsevent Β· $10event Β· $2012BatchGroup into one insert2 events β†’ 1 insert23events tableOne new part1 new part3

The batch lands as one new part.

Docs β†—
Live events
KafkaRedpandaConfluent

A stream buffers events and can replay them.

Database changes
PostgreSQLMySQLMongoDBClickPipesPeerDBDebezium

Databases, and the tools that copy each change (CDC).

History files
Amazon S3Google Cloud StorageAzure Blob StorageApache ParquetApache IcebergDelta Lake

Files in object storage, or lake tables read in place.

SQL & details

One minute of this pipeline: every source arrives in batches, and each batch becomes a new part.

One minute of ingest
one minute0 s15 s30 s45 s60 sKafkabatch: 7.5 seventspart 18 parts a minute42 buyswaits 4.5 sPostgreSQLCDC, every 60 stenants42 β†’ pro7 deletedone part at 60 s, 2 rows: 42 pro, 7 deletedsync42 β†’ pro waits 55 sS3history, oncethe 90 days of history were loaded before this minute, a few days per insert, into the earlier partitions of events example intervals, before processing: 7.5 s event batch Β· 60 s CDC sync
one minute, with example batch and sync intervals0 s15 s30 s45 s60 sKafka Β· batch: 7.5 sevents Β· a new part per batch42 buyswaits 4.5 sPostgreSQL Β· CDC, every 60 stenants Β· one part per sync42 β†’ pro7 deletedone part at 60 s, 2 rows42 β†’ pro waits 55 sS3 Β· the history, loaded once, beforea few days per insert, into earlier partitions example: 7.5 s batch Β· 60 s sync + processing
Illustrative example
Live events (Kafka)
A batch every 7.5 s by default (stream_flush_interval_ms); on ClickHouse Cloud, ClickPipes batches for you.
Database changes (PostgreSQL)
ClickPipes (PeerDB) copies the table once, then adds each change as a new row, every 60 s by default.
History files (S3)
Loaded once with INSERT INTO events SELECT … FROM s3(…), a few days per insert: one insert may touch at most 100 partitions.
Small app writes
Asynchronous inserts batch them on the server, on by default since 26.2 (2026-02) (Guide 03).

02 Raw tables Kept as it arrives

Every event
1Two insertsOne part per insertpart 1 Β· $10part 2 Β· $2012Background mergeMerge in the background2 parts β†’ 123events tableEvery event keptevent Β· $10event Β· $203
1Two insertsOne part per insertpart 1 Β· $10part 2 Β· $2012Background mergeMerge in the background2 parts β†’ 123events tableEvery event keptevent Β· $10event Β· $203

MergeTree keeps every event.

Docs β†—
How long to keep

Keep raw events long enough to rebuild the totals, then expire them with TTL.

Retries

A retried insert must not count twice in the totals.

SQL & details

Each table engine decides what a background merge does with rows that share a sorting key. Until merges run, read with FINAL or aggregate (Guide 05).

Two inserts, a merge, per engine
insert 1insert 2after a mergemay happenOnly addedMergeTreeeventsread as istenant_idevent_typetimestamp42click10:00tenant_idevent_typetimestamp42purchase10:05tenant_idevent_typetimestamp42click10:0042purchase10:05ReplacedReplacingMergeTreetenantsread with FINALtenant_idplan_peerdb_version42free09:12tenant_idplan_peerdb_version42pro10:05tenant_idplan_peerdb_version42pro10:05the newest versionAdded upSummingMergeTreeusage_hourlyread with sum(revenue)tenant_idhourrevenue4210:0040.00tenant_idhourrevenue4210:0030.00tenant_idhourrevenue4210:0070.0040.00 + 30.00Filled inCoalescingMergeTreetenant_profileread with FINALtenant_idplanlast_seen42proNULLtenant_idplanlast_seen42NULL10:05tenant_idplanlast_seen42pro10:05column by columnDeleted, by CDCReplacingMergeTreetenantsread with FINAL, _peerdb_is_deleted = 0tenant_id_peerdb_is_deleted70tenant_id_peerdb_is_deleted71tenant_id_peerdb_is_deleted71the delete stays
two inserts, then a merge that may happenOnly added Β· MergeTreeevents Β· read as istenant_idevent_typetimestampinsert 142click10:00insert 242purchase10:05merged42click10:0042purchase10:05Replaced Β· ReplacingMergeTreetenants Β· read with FINALtenant_idplan_peerdb_versioninsert 142free09:12insert 242pro10:05merged42pro10:05Added up Β· SummingMergeTreeusage_hourly Β· read with sum(revenue)tenant_idhourrevenueinsert 14210:0040.00insert 24210:0030.00merged4210:0070.00Filled in Β· CoalescingMergeTreetenant_profile Β· read with FINALtenant_idplanlast_seeninsert 142proNULLinsert 242NULL10:05merged42pro10:05Deleted, by CDC Β· ReplacingMergeTreetenants Β· read with FINAL, _peerdb_is_deleted = 0tenant_id_peerdb_is_deletedinsert 170insert 271merged71
Illustrative example
What counts as a duplicate
Rows combine only when their whole ORDER BY matches, so tenants is sorted by its key alone.
Other engines
AggregatingMergeTree for distinct counts and percentiles, CollapsingMergeTree (both in Guide 02); Null stores nothing but still feeds views.
Fixing a few rows
UPDATE writes a small patch part when the table has enable_block_number_column and enable_block_offset_column.

03 Transform Summed ahead of time

Totals on every insert
1New eventsAn insert arrivesevent Β· $10event Β· $2012Materialized viewSum the blocksum = $3023Hourly totalsAdd up the totalsearlier: $40now: $40 + $30 = $703
1New eventsAn insert arrivesevent Β· $10event Β· $2012Materialized viewSum the blocksum = $3023Hourly totalsAdd up the totalsearlier: $40now: $40 + $30 = $703

Totals add up as they land: $40 + $30 = $70.

Docs β†—
Other ways to transform
dbtApache AirflowDagsterApache Flink

Scheduled SQL jobs, or stream processing before ClickHouse.

SQL & details

A materialized view runs on every insert, so the hourly totals are ready as soon as the data lands. The figure sets this against a join refreshed every hour.

A join every hour vs. views on every insert
1A join, refreshed every hourREFRESH EVERY 1 HOUR Β· events JOIN tenants10:0010:3011:00PostgreSQL42 β†’ proKafka42 buysrefreshnext refreshnowtenant_idplanlast_seentenant_profile at 10:3042freefree09:5809:58as of the 10:00 run2Two views, on every insertplan_mv, seen_mv β†’ CoalescingMergeTree10:0010:3011:00PostgreSQL42 β†’ proKafka42 buysplan_mvseen_mvnowtenant_idplanlast_seentenant_profile at 10:3042freepro09:5810:20FINAL, currentexample intervals Β· excludes processing, source lag and client refreshrows read per hour1full refreshuntil the next refresh60 min2two viewsthe CDC sync60 s1full refreshall of events, at every run13.6 B2two viewseach new event, once6.3 M60Γ— shorter2,000Γ— fewerviews avoid a scheduled refresh wait; insert work and source lag still affect freshness
1A join, every hourREFRESH EVERY 1 HOUR Β· events JOIN tenants10:0010:3011:00PostgreSQL42 β†’ proKafka42 buysrefreshnext refreshnowtenant_idplanlast_seentenant_profile at 10:3042freefree09:5809:58as of the 10:00 run2Two views, every insertplan_mv, seen_mv β†’ CoalescingMergeTree10:0010:3011:00PostgreSQL42 β†’ proKafka42 buysplan_mvseen_mvnowtenant_idplanlast_seentenant_profile at 10:3042freepro09:5810:20FINAL, currentexample intervals Β· excludes processing and lagrows read per hour1full refreshuntil the next refresh60 min2two viewsthe CDC sync60 s1full refreshall of events13.6 B2two viewseach new event, once6.3 M60Γ— shorter interval Β· 2,000Γ— fewer rowsviews avoid waiting for a scheduled refresh;source lag and insert work still affect freshness
Illustrative example
The view behind the hourly totals
CREATE TABLE usage_hourly (tenant_id UInt32, hour DateTime, …)ENGINE = SummingMergeTreePARTITION BY toYYYYMM(hour)ORDER BY (tenant_id, hour, device_type) CREATE MATERIALIZED VIEW usage_mv TO usage_hourly ASSELECT tenant_id, toStartOfHour(timestamp) AS hour, device_type,  count() AS events, sum(revenue) AS revenueFROM events GROUP BY tenant_id, hour, device_type
Partial totals add up
SummingMergeTree adds up the partial rows when parts merge, and queries still sum(). For distinct counts, use AggregatingMergeTree.
Joins inside a view
They use the other table as it was at insert time. For the current plan, join when a chart asks, in the metrics layer.
One row per tenant
The figure's second way: two views fill one CoalescingMergeTree row, which keeps each column's latest value.
When a schedule still fits
Work that needs all the data at once, such as rankings: a refreshable view, REFRESH EVERY 1 HOUR.
Backfill
A new view sees only new inserts: fill the past with one INSERT … SELECT per tenant (Guide 06).

04 Serving table What the charts read

Read one customer only
1Pre-summed tableEverything storedall monthsall customers12Partition + sorting keySkip what is not neededthis month onlycustomer 42 only23What is readRead the resta few granules3
1Pre-summed tableEverything storedall monthsall customers12Partition + sorting keySkip what is not neededthis month onlycustomer 42 only23What is readRead the resta few granules3

Only a few granules, and only the needed columns, are read.

Docs β†—
Sort order

Put the filters every chart uses first: customer, then time.

Separate workloads

Keep heavy internal queries away from customer charts.

SQL & details

Today's chart for tenant 42 needs at most 120 totals: 24 hours Γ— 5 devices. Each cut in the figure leaves less to read.

What the chart reads
SELECT hour, sum(revenue) FROM usage_hourlyWHERE tenant_id = 42 AND hour >= '2025-06-15 00:00:00'GROUP BY hourwhat Cube sends for tenant 42's chartleft to read Β· granules of up to 8,192 rows90 days7 parts in 4 monthly partitions7,9571partitionsthis month: 4 of 7 parts1,2802primary indextenant 42's day: 1 granule in each part43columns3 of 5tenant_idhourdevice_typeeventsrevenuereads 4 granules, at most 32,768 rows, and returns 11 rows: one per houranother question Β· revenue by hour across all tenants, this month Β· rows read1no projectionevery tenant's rows of this month10.5 M2projectionby_hour: a few rows per hour, kept in the table34930,000Γ— fewerthe same chart again within 60 s, with the query cache on: nothing is read, and rows that arrived since are missed
SELECT hour, sum(revenue) FROM usage_hourlyWHERE tenant_id = 42Β Β AND hour >= '2025-06-15 00:00:00'GROUP BY hourleft to read Β· granules90 days7 parts7,9571partitionsthis month, 4 parts1,2802primary index1 granule per part43columns Β· 3 of 5 readtenant_idhourdevice_typeeventsrevenuereads 4 granules, at most 32,768 rowsreturns 11 rows: one per houranother question Β· all tenants by hourthis month Β· rows read1no projectionevery tenant10.5 M2projectionby_hour34930,000Γ— fewer rowsthe same chart within 60 s, query cache on:nothing read, newer rows missed
Illustrative example
Check the cuts
EXPLAIN indexes = 1 prints them: 4 of 7 parts, then 4 of 1,280 granules (Overview).
Pre-summed
Once merged, usage_hourly has at most 720 k rows a day, against 151 M events.
All tenants at once
The figure's projection, by_hour, keeps that rollup inside the table; on SummingMergeTree it needs deduplicate_merge_projection_mode = 'rebuild'.
Repeated charts
The query condition cache is on by default; the query cache is opt-in and keeps results for 60 s (Guide 07).

05 Semantic Β· BI What people see

Charts for customers
1Signed-in customerWho is askingcustomer 4212Metrics layerAdd metric and filtersum(revenue)tenant_id = 4223ChartOnly their rows10:00 β†’ $703
1Signed-in customerWho is askingcustomer 4212Metrics layerAdd metric and filtersum(revenue)tenant_id = 4223ChartOnly their rows10:00 β†’ $703

Each customer sees only its own rows.

Docs β†—
Metrics & access rules
CubeLightdashLookerOmniHolisticsRill
native connector

Define each metric once, and which rows each customer may see.

Charts for customers
Luzmo
ClickHouse official connector
ExploDatabrain
native connector

Embed a dashboard tool, or build your own UI on the metrics layer.

Dashboards for your team
MetabasePower BITableauApache SupersetGrafana
ClickHouse official connector
ThoughtSpot
native connector

Connect directly; choose live queries or imported data.

SQL & details

The metrics layer turns a chart into SQL, adds the customer's filter and caches answers. system.query_log records how long ClickHouse took.

Chart requests, Cube's cache and query_log
Your producttenant 42's dashboardchart: revenue today, by hourCube Β· metrics layerrevenue = sum(revenue), defined onceadds tenant_id from the viewer's tokenexample cache refresh key: 10 sClickHouseusage_hourlylogs each query in system.query_logchart requests, as they arrivein ClickHousequery_log0 stenant 42ClickHouse Β· reads 32,768 rows15 ms1 row3 stenant 42Cube's cache Β· nothing sentβ€”nothing5 stenant 7ClickHouse2 ms1 row8 stenant 1001ClickHouse Β· a slow run98 ms1 row10 sExample: cached results expire12 stenant 42ClickHouse again2 ms1 rowsystem.query_log Β· normalized_query_hash groups statements with the same query shapechartrunsp95_msmax_rowsSELECT hour, sum(revenue) FROM usage_hourly WHERE tenant_id = 42 …485.632,768sample p95 is database time; add application, network and browser timing for full chart latency
Your producttenant 42'sdashboardCubeadds tenant_idcache: 10 s checkClickHouselogs eachquerychart requests, as they arrive0 s Β· tenant 42ClickHouse Β· 32,768 rows15 ms3 s Β· tenant 42Cube's cache Β· nothing sentβ€”5 s Β· tenant 7ClickHouse2 ms8 s Β· tenant 1001ClickHouse Β· slow98 ms10 sExample: cache expires12 s Β· tenant 42ClickHouse again2 mssystem.query_log, by normalized_query_hashSELECT hour, sum(revenue) … tenant_id = 42 …runsp95_msmax_rows485.632,768p95 is DB time; add app + browser timing
Illustrative example
Charts for customers
Take the tenant from the signed-in user, on the server; never trust a filter sent by the browser.
Dashboards for your team
Give the tool its own database user, with limits. Power BI stays live in DirectQuery mode.
Speed: the slowest charts
SELECT normalized_query_hash, any(query) AS chart,  quantile(0.95)(query_duration_ms) AS p95_ms,  max(read_rows) AS max_rows, count() AS runsFROM system.query_logWHERE type = 'QueryFinish' AND user = 'cube'  AND event_time > now() - INTERVAL 1 HOURGROUP BY normalized_query_hashORDER BY p95_ms DESC LIMIT 10

p95 shows the slow runs an average hides. Cache hits never reach ClickHouse, so also time the page in the browser.

Freshness
A new event reaches a chart in about 18 s (7.5 s batch, then up to 10 s in the cache), a plan change in about 70 s. Alert when now() - max(timestamp) grows past that.