ClickHouse for analytics
A columnar database that aggregates billions of events in seconds.
In 30 seconds
ClickHouse is an open-source columnar database built for analytics (OLAP). It stores each column separately and compressed, so a query that sums one column over billions of rows reads only that column. It is ideal for event data, logs, metrics and product analytics dashboards. It ships monthly releases with two LTS releases a year (for example 26.3 LTS in March 2026), self-hosted or as ClickHouse Cloud.
Not in my daily stack — I know the concepts and would learn the details when a project needs it.
Why it matters
- Analytics queries scan huge ranges and aggregate a few columns. A row database like MySQL or PostgreSQL reads every full row for that, and dashboards grind to a halt once events reach hundreds of millions.
- ClickHouse often compresses event data 5–20 times and processes billions of rows per second on one server, which makes real-time dashboards affordable.
- Teams pick it to move reporting off the transactional database, so heavy reports stop slowing down checkout or the API.
- It is a strong self-hosted or managed alternative to paying per event for third-party analytics, with full SQL and full ownership of the data.
How it works
01The idea: columns, not rows
Imagine a spreadsheet of a billion page views. To answer “views per country last week”, a row store must read every row with all its twenty columns. A column store keeps each column in its own file: it reads only date and country, which is a tiny fraction of the data. Values in one column are similar (many repeated countries), so they compress extremely well, and the CPU can process them in batches with vectorized instructions. The trade-off: fetching or updating a single complete row is slower, and ClickHouse is not designed for many small transactional updates. OLTP (orders, users) stays in your relational database; OLAP (questions over history) goes here.
- OLTP: many small reads and writes of single records, with transactions.
- OLAP: few large queries that scan and aggregate many records.
- Vectorized execution: processing a block of values per CPU operation instead of one at a time.
02MergeTree tables and the sorting key
Almost every table uses an engine from the MergeTree family. Each insert writes an immutable part sorted by the ORDER BY key, and background merges combine small parts into bigger ones. The primary index is sparse: it stores one entry per granule (8,192 rows by default), so it is small enough to stay in memory. When a query filters on the leading columns of ORDER BY, ClickHouse skips granules that cannot match. Choosing ORDER BY is therefore the most important design decision: put low-cardinality columns you always filter by first (tenant, event type), then time. PARTITION BY is for data management (dropping old months), not for speed; keep partitions coarse.
CREATE TABLE events
(
tenant_id UInt32,
event_type LowCardinality(String),
event_time DateTime64(3, 'UTC'),
user_id UInt64,
url String,
country LowCardinality(String),
properties JSON
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_type, event_time)
TTL toDateTime(event_time) + INTERVAL 13 MONTH DELETE;
03Inserting data the right way
ClickHouse loves big batches and hates tiny inserts. Every INSERT creates at least one part, and thousands of single-row inserts per second create thousands of parts, which triggers the famous “too many parts” error. Batch on the client (thousands to hundreds of thousands of rows per insert, roughly once per second) or enable asynchronous inserts so the server buffers small writes for you. Data typically arrives through a queue (Kafka, a Redis-backed worker, HTTP collectors) and is flushed in batches. Formats such as JSONEachRow, CSV, Parquet and the native format are supported over HTTP (port 8123) or the native protocol (9000).
# Batch insert over HTTP from a file of JSON lines
curl -u your_user:your_password \
'http://localhost:8123/?query=INSERT%20INTO%20events%20FORMAT%20JSONEachRow' \
--data-binary @events.ndjson
-- Or let the server batch small inserts
INSERT INTO events SETTINGS async_insert = 1, wait_for_async_insert = 1
FORMAT JSONEachRow {"tenant_id":1,"event_type":"page_view","event_time":"2026-11-05 10:00:00","user_id":7,"url":"/","country":"AM","properties":{}}
04Analytical SQL
ClickHouse speaks SQL with many analytical extras. uniq() and uniqExact() count distinct users (uniq is approximate and much faster), quantile() computes percentiles, countIf() and sumIf() filter inside aggregates, and windowFunnel() and retention() answer product questions directly. Arrays and maps are first-class, and the JSON type stores semi-structured properties as typed sub-columns. Always filter on the leading ORDER BY columns and the time range, select only the columns you need (never SELECT * on a wide table), and use LIMIT while exploring.
SELECT
toStartOfDay(event_time) AS day,
uniq(user_id) AS visitors,
countIf(event_type = 'purchase') AS purchases,
round(purchases / visitors * 100, 2) AS conversion_pct
FROM events
WHERE tenant_id = 1
AND event_time >= now() - INTERVAL 30 DAY
GROUP BY day
ORDER BY day;
05Materialized views for fast dashboards
A materialized view in ClickHouse is an insert trigger: when rows arrive in the source table, the view runs its SELECT on just that new batch and writes the result into a target table. Combined with SummingMergeTree or AggregatingMergeTree, this keeps pre-aggregated rollups (per hour, per tenant) always up to date, so dashboards read thousands of rows instead of billions. Use the -State combinators when writing and -Merge when reading, because partial aggregates from different parts must be merged. Remember that a view only sees new inserts; backfill historical data separately with INSERT INTO ... SELECT.
CREATE TABLE events_hourly
(
tenant_id UInt32, hour DateTime, event_type LowCardinality(String),
events UInt64, users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree ORDER BY (tenant_id, event_type, hour);
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT tenant_id, toStartOfHour(event_time) AS hour, event_type,
count() AS events, uniqState(user_id) AS users
FROM events GROUP BY tenant_id, hour, event_type;
-- Read: SELECT hour, sum(events), uniqMerge(users) FROM events_hourly WHERE tenant_id = 1 GROUP BY hour;
06Running it: single node, cluster or cloud
A single ClickHouse server handles far more than most teams expect, often billions of rows on one machine with fast NVMe disks. For high availability, use ReplicatedMergeTree tables coordinated by ClickHouse Keeper (the built-in replacement for ZooKeeper); for more capacity, shard data across nodes with a Distributed table. ClickHouse Cloud separates storage (object storage) from compute and removes most of this work, at a usage-based price. Whatever you choose: create users with least privilege, keep ports 8123 and 9000 off the public internet, set query limits (max_memory_usage, max_execution_time) per user, and back up with BACKUP TO S3 or your provider's snapshots.
Use it when
- Product analytics, clickstreams and event tracking with hundreds of millions to trillions of rows.
- Logs, metrics and traces where you filter by time and aggregate quickly.
- Customer-facing dashboards that must answer aggregate queries in under a second.
- Offloading heavy reporting from the transactional database.
Skip it when
- As the main database for an application with frequent single-row updates, deletes and transactions.
- Small datasets (a few million rows) where PostgreSQL with good indexes already answers in milliseconds.
- Workloads that need strict foreign keys and constraints between entities.
Alternatives
- PostgreSQL (plus TimescaleDB or columnar extensions)
Keeps one database; fine up to moderate volumes, slower for large scans.
- BigQuery / Snowflake
Fully managed warehouses, great for batch analytics; pay per query or compute, higher latency for live dashboards.
- Apache Druid / Apache Pinot
Real-time OLAP stores designed for streaming ingestion and user-facing analytics.
- DuckDB
Embedded columnar engine for local or single-process analytics over files like Parquet.
Best practices
- Design ORDER BY from your most common filters: low-cardinality columns first, then time.
- Insert in large batches or enable async inserts; never stream single-row inserts.
- Use LowCardinality(String) for repeated values and the smallest numeric types that fit.
- Keep partitions coarse (usually by month) and use TTL to drop or move old data automatically.
- Pre-aggregate dashboard queries with materialized views into AggregatingMergeTree tables.
- Set per-user limits on memory, execution time and result size to protect the cluster.
- Keep the source of truth for business entities in your OLTP database and sync to ClickHouse.
Common mistakes
- Partitioning by day or by user, which creates thousands of small partitions and too many parts.
- Using frequent ALTER TABLE ... UPDATE or DELETE mutations as if it were MySQL; they rewrite whole parts.
- Expecting a materialized view to include historical data that existed before it was created.
- Choosing ORDER BY with a high-cardinality column first (like user_id or a UUID), which defeats index skipping.
- Exposing the HTTP port publicly with the default user and no password.
Deep dive
07Parts, granules and how a query skips data
Each part on disk is a directory with one file per column (wide format) plus primary.idx and mark files. Marks point to the start of each granule inside the compressed column files. At query time ClickHouse uses primary.idx to binary search the range of granules whose ORDER BY values can match, reads only the marks for those granules, and decompresses only those blocks of the requested columns. Data-skipping indexes (minmax, set, bloom_filter) add per-granule summaries for columns outside the sorting key. Use EXPLAIN indexes = 1 to see how many parts and granules were selected out of the total; a filter that selects 95% of granules is a full scan in disguise.
EXPLAIN indexes = 1
SELECT count() FROM events
WHERE tenant_id = 1 AND event_time >= now() - INTERVAL 1 DAY;
-- A skipping index for a column outside ORDER BY
ALTER TABLE events ADD INDEX idx_url url TYPE bloom_filter GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_url;
08Deduplication and changing data: ReplacingMergeTree and friends
ClickHouse does not enforce unique keys. ReplacingMergeTree keeps the latest row per ORDER BY key, chosen by a version column, but only after merges, which happen at an unknown time. Queries that need exact results use FINAL (which merges at read time; it is much faster in recent versions but still costs) or aggregate with argMax(value, version). CollapsingMergeTree and VersionedCollapsingMergeTree handle updates by inserting a cancelling row with sign -1. Inserts into replicated tables are deduplicated by block hash for a window, so retrying the same batch after a timeout is safe, as long as the batch is identical.
CREATE TABLE users_dim
( user_id UInt64, plan LowCardinality(String), updated_at DateTime64(3) )
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;
SELECT plan, count() FROM users_dim FINAL GROUP BY plan;
09Joins, dictionaries and projections
ClickHouse executes joins, and its join planner has improved a lot in recent releases, but the default hash join still builds the right-hand table in memory, so keep the smaller table on the right and filter both sides before joining. For small dimension tables that change slowly (countries, plans, product names), dictionaries are better: they are loaded into memory and queried with dictGet(), often far faster than a join. Projections store an extra, differently sorted or pre-aggregated copy of a table inside the same parts, and the optimizer uses them automatically when a query matches. They cost disk and insert time, so add them for proven hot queries only.
10Scaling out and protecting the cluster
Scale up first: more cores and fast NVMe give near-linear gains. When you need more, shard by a key that matches tenancy (for example cityHash64(tenant_id)) so most queries hit one shard, and replicate each shard with ReplicatedMergeTree and ClickHouse Keeper (three Keeper nodes for quorum). A Distributed table fans queries out and merges results. For multi-tenant analytics, protect the cluster with settings profiles and quotas: max_memory_usage, max_execution_time, max_rows_to_read and max_concurrent_queries per user, plus row policies so each tenant's user only sees its rows. Monitor system.query_log, system.parts (parts per partition) and system.merges, and alert on part counts and replication lag.
Real-world scenarios
“Too many parts” errors during a traffic spike
Problem
A tracking service inserts each event into ClickHouse as it arrives. During a marketing campaign, inserts start failing with “Too many parts” and dashboards lag.
How to solve it
Every single-row insert creates a part, and merges cannot keep up. Short term, enable async_insert = 1 with wait_for_async_insert = 1 for the tracking user so the server batches writes. Properly, put a buffer in front: the tracking endpoint writes to a queue and a worker inserts batches of 10,000–100,000 rows every second or two. Check the table's PARTITION BY: daily or per-tenant partitions multiply parts, so switch to monthly. Watch system.parts for active parts per partition and alert before thresholds. After the spike, OPTIMIZE is not needed; merges catch up on their own.
Dashboard queries slow down as data grows
Problem
A customer dashboard showing daily active users per tenant took 200 ms at launch and now takes 6 seconds with three billion events.
How to solve it
Check EXPLAIN indexes = 1: if ORDER BY starts with event_time, every tenant's query scans all tenants. Rebuild the table with ORDER BY (tenant_id, event_type, event_time) using INSERT INTO new SELECT FROM old and an atomic EXCHANGE TABLES. Then add a materialized view into an AggregatingMergeTree table at hourly grain with uniqState(user_id), backfill history, and point the dashboard to the rollup with uniqMerge. The dashboard now reads thousands of pre-aggregated rows instead of billions of raw events, and raw data stays available for drill-down.
GDPR erasure request in an append-only store
Problem
A user asks for their data to be deleted. Their events are spread across 13 months of partitions in a multi-billion-row table.
How to solve it
Use a lightweight DELETE FROM events WHERE user_id = 12345, which masks rows immediately and physically removes them during later merges; run it in a background job and log the request. If erasure requests are frequent, batch them daily into one statement with WHERE user_id IN (...). Make sure rollup tables do not contain personal identifiers in the first place: aggregate counts and approximate uniques without raw user IDs. Remember backups: document retention so deleted data ages out of backups too. Long term, consider pseudonymous IDs in events with the mapping stored in the OLTP database, so deleting the mapping anonymises history.
Cheat sheet
- Run a query from the shell
clickhouse-client --query "SELECT version()"- Table size and compression
SELECT table, formatReadableSize(sum(data_compressed_bytes)) AS c, formatReadableSize(sum(data_uncompressed_bytes)) AS u FROM system.parts WHERE active GROUP BY table- Active parts per partition
SELECT partition, count() FROM system.parts WHERE table = 'events' AND active GROUP BY partition- Check index usage
EXPLAIN indexes = 1 SELECT ...- Slowest recent queries
SELECT query_duration_ms, query FROM system.query_log WHERE type = 'QueryFinish' ORDER BY query_duration_ms DESC LIMIT 10- Drop an old month instantly
ALTER TABLE events DROP PARTITION 202410- Lightweight delete
DELETE FROM events WHERE user_id = 12345- Swap tables atomically
EXCHANGE TABLES events AND events_new- Backup to S3
BACKUP TABLE events TO S3('https://bucket.s3.amazonaws.com/backups/events', 'KEY_ID', 'SECRET')
Questions & answers
Q1Why is ClickHouse so much faster than MySQL for analytics?
Three reasons work together. It stores data by column, so a query reads only the columns it needs instead of whole rows. Those columns are compressed heavily, often ten times or more, so less data comes from disk. And it executes queries in vectorized form, processing blocks of thousands of values per CPU instruction across all cores. The sparse primary index then lets it skip whole granules that cannot match. MySQL is optimised for the opposite workload: finding and updating individual rows by index with transactions, which ClickHouse does poorly. Each is fast at what it was built for.
Q2How do you choose the ORDER BY key?
Start from the queries. List the filters used by most dashboards and API calls. Put the columns that almost every query filters by with equality first, ordered from low to high cardinality, for example tenant_id and event_type, then the time column used for ranges. This lets the sparse index skip most granules. Avoid putting a UUID or user_id first unless most queries filter by one user. The ORDER BY also affects compression, because similar values end up next to each other. If one table must serve very different query patterns, add projections or a second materialized table with a different order.
Q3Can you update or delete data in ClickHouse?
Yes, but you should design so that you rarely need to. Classic ALTER TABLE UPDATE and DELETE are mutations: they rewrite the affected parts in the background, which is heavy. Lightweight DELETE FROM marks rows as deleted with a hidden mask and is much cheaper, and recent versions add lightweight updates too. For data that changes, the usual pattern is ReplacingMergeTree: insert a new version of the row and let merges keep the latest, reading with FINAL or argMax when you need exact results. For retention, use TTL and DROP PARTITION, which are instant.
Q4How do you get data from the application into ClickHouse?
Avoid writing to ClickHouse synchronously in the request path for every event. The app or a tracking endpoint pushes events to a buffer, such as Kafka, a Redis-backed queue or a small collector service, and a worker inserts batches every second or every few thousand rows. ClickHouse can also consume Kafka directly with the Kafka table engine or ClickPipes in the cloud. For syncing relational tables, use change data capture or periodic incremental exports by updated_at. If you must send small inserts, turn on async_insert so the server batches them. The goal is few, large inserts.
Q5What is a materialized view in ClickHouse and how is it different from PostgreSQL?
In PostgreSQL, a materialized view stores the result of a query and you refresh it on demand, recomputing everything. In ClickHouse, a classic materialized view is an insert trigger: each new block inserted into the source table is transformed by the view's query and appended to a target table. That makes it incremental and always current, perfect for rollups. The catch is that it only sees new data, so historical rows need a manual backfill, and aggregations must use state and merge functions because partial results arrive per insert. ClickHouse also has refreshable materialized views for periodic full recomputation.
Q6Is ClickHouse a replacement for a data warehouse?
For many teams it can be. It handles star schemas, joins, window functions and huge volumes, and its speed makes it a good engine for both internal BI and customer-facing analytics. Compared with BigQuery or Snowflake, it usually costs less at steady load and answers interactive queries faster, but you manage more yourself unless you use ClickHouse Cloud, and very large many-to-many joins need more care in modelling. Warehouses shine for ad-hoc analysis by many analysts across many sources. A common setup is ClickHouse for real-time event analytics next to the warehouse for finance and batch reports.
Senior-level questions
S1How would you design multi-tenant, customer-facing analytics on ClickHouse?
I would use shared tables with tenant_id as the first ORDER BY column, so each tenant's queries read a narrow index range, and shard by a hash of tenant_id if one node is not enough. Each tenant's queries run through a dedicated user or a settings profile with row policies (tenant_id = X), quotas and limits on memory, execution time and rows read, so one heavy customer cannot starve others. Dashboards read from materialized rollups, not raw events, and the API layer whitelists query shapes instead of passing raw SQL. Very large tenants can move to dedicated shards. The trade-off is operational: shared tables are efficient but need strict guardrails; table-per-tenant isolates better but explodes the number of parts and tables.
S2ClickHouse or a cloud warehouse like BigQuery for a new analytics platform?
It depends on the workload shape. For user-facing dashboards with sub-second latency, high query concurrency and a steady stream of events, ClickHouse is usually faster and cheaper, because you pay for provisioned compute rather than per byte scanned. For ad-hoc analysis by many analysts across dozens of sources, with spiky usage and heavy joins, BigQuery or Snowflake are simpler: no capacity planning, mature governance and ecosystem. ClickHouse requires more modelling discipline (sorting keys, rollups, no frequent updates). Many companies use both: the warehouse for finance and batch BI, ClickHouse for product analytics and observability, fed from the same event stream.
S3How do you guarantee exactly-once ingestion from Kafka into ClickHouse?
True end-to-end exactly-once is hard; aim for idempotent at-least-once. Option one: the Kafka table engine with a materialized view, which commits offsets after inserts but can duplicate on failures. Option two: your own consumer that inserts identical batches on retry, so ReplicatedMergeTree's block deduplication drops repeats (or set insert_deduplication_token per batch). Option three: ClickHouse Kafka Connect sink or ClickPipes, which offer exactly-once semantics within their design. In all cases, add an event_id and use ReplacingMergeTree or argMax queries for tables where duplicates would change numbers, and reconcile counts against the source periodically. The trade-off is complexity versus accuracy; for approximate product metrics, at-least-once is often acceptable.
Glossary
- OLAP
- Online analytical processing: large queries that scan and aggregate many rows.
- Columnar storage
- Storing each column separately so queries read only the columns they need.
- MergeTree
- ClickHouse's main table engine family: sorted immutable parts merged in the background.
- Part
- An immutable chunk of a table on disk, created by each insert and merged over time.
- Granule
- The smallest block the sparse index points to, 8,192 rows by default.
- Sparse primary index
- One index entry per granule, used to skip blocks that cannot match a filter.
- Materialized view
- In ClickHouse, an insert trigger that transforms new rows into another table.
- LowCardinality
- A column wrapper that dictionary-encodes repeated values for less space and faster queries.
- ClickHouse Keeper
- The built-in coordination service for replication, compatible with ZooKeeper.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.