- Learn
- ClickHouse
- ClickHouse for OLAP
ClickHouse for OLAP
Why ClickHouse is built for analytical queries and how to get the most out of it.
What is OLAP?
OLAP stands for Online Analytical Processing. It describes workloads where the goal is to analyze large volumes of data rather than process individual transactions. Think dashboards, reports, aggregations over millions of rows, and time-series analysis.
The opposite is OLTP (Online Transaction Processing), which handles things like inserting a new order, updating a user's email, or deleting a single record. Most traditional databases (PostgreSQL, MySQL) are designed for OLTP first.
OLAP queries tend to share a few traits:
- They scan many rows but only a few columns
- They aggregate data with GROUP BY, COUNT, SUM, AVG
- They filter by time ranges or categorical dimensions
- They care more about read throughput than write latency
ClickHouse was built from the ground up for this kind of work.
Why columnar storage matters
Traditional row-oriented databases store each row as a contiguous block on disk. If you have a table with 50 columns and you run SELECT AVG(price) FROM orders, the database still reads all 50 columns from disk for every row. Most of that I/O is wasted.
Columnar databases flip this around. Each column is stored separately. When you query AVG(price), ClickHouse reads only the price column. The other 49 columns stay untouched on disk.
This has several benefits for analytical queries:
- Less I/O. Reading one column instead of 50 means roughly 1/50th of the disk reads.
- Better compression. Values in a single column tend to be similar (all integers, all timestamps, all from a small set of categories). Similar data compresses well. ClickHouse routinely achieves 5-10x compression ratios.
- CPU cache efficiency. Processing a contiguous array of integers is far faster than jumping between fields in a row-oriented layout. Modern CPUs process columnar data with SIMD instructions.
The tradeoff is that inserting or updating individual rows is more expensive. Each insert touches every column file. This is fine for OLAP workloads, where data arrives in batches and single-row updates are rare.
The MergeTree engine family
ClickHouse supports multiple table engines, but the MergeTree family is what you will use for almost everything in production. It is the backbone of ClickHouse's analytical performance.
Basic MergeTree
MergeTree stores data in sorted "parts." When you insert data, ClickHouse writes a new part. In the background, it merges smaller parts into larger ones (hence the name). This merge process keeps data sorted and compacted.
The ORDER BY clause defines the primary sort order. This is the single most important decision in your schema design, because it determines which queries can skip large chunks of data.
ReplacingMergeTree
OLAP databases generally do not support traditional UPDATE operations. ReplacingMergeTree provides a workaround. It deduplicates rows with the same primary key during background merges.
When merges happen, ClickHouse keeps only the row with the highest updated_at for each user_id. Note that deduplication is eventual, not immediate. Until a merge runs, you may see duplicate rows. Use the FINAL keyword to get deduplicated results at query time:
SummingMergeTree
SummingMergeTree automatically sums numeric columns during merges for rows with the same primary key. This is useful for pre-aggregated counters.
Insert raw events, and ClickHouse collapses them into summed rows over time:
AggregatingMergeTree
AggregatingMergeTree takes this concept further. Instead of summing, it stores intermediate aggregation states. This is the engine behind ClickHouse's materialized views that maintain running aggregates.
You insert into this table using -State combinator functions and query using -Merge combinators:
Data types and schema design for analytics
Choosing the right data types in ClickHouse has a direct impact on storage size and query speed. Smaller types mean less data to read from disk, better compression, and faster scans.
Use the smallest integer type that fits
ClickHouse offers UInt8, UInt16, UInt32, UInt64, Int8, Int16, Int32, and Int64. If a value fits in a UInt16 (0 to 65,535), don't use UInt64.
LowCardinality for repeated strings
Strings with a limited set of values (country names, status codes, categories) should use LowCardinality(String). ClickHouse dictionary-encodes these values, storing integer IDs instead of full strings.
This can reduce storage by 10x or more for columns like country codes, browser names, or event types.
Dates and times
Use Date for date-only values and DateTime for timestamps with second precision. If you need sub-second precision, use DateTime64(3) for milliseconds or DateTime64(6) for microseconds.
Avoid storing timestamps as strings or large integers. ClickHouse's native date types enable time-based functions and partitioning.
Nullable: use sparingly
Nullable(T) wraps any type to allow NULL values, but it adds storage overhead (an extra bitmask column) and makes queries slower. If you can represent missing data with a default value (0, empty string, a sentinel date), prefer that over Nullable.
Denormalization
In OLTP databases, normalization reduces redundancy. In OLAP databases, joins are expensive. ClickHouse can do joins, but they are not its strongest feature. Denormalize your data where practical.
Instead of joining an orders table with a products table on every query, store the product name and category directly in the orders table. The extra storage cost is small compared to the join cost at query time.
Ordering keys and primary keys
The ORDER BY clause in a MergeTree table definition is the most important performance lever you have. It determines how data is physically sorted on disk, and ClickHouse uses this sort order to skip irrelevant data during queries.
How data skipping works
ClickHouse divides each column into "granules" (default 8,192 rows). It maintains a sparse index that records the minimum and maximum values of the ORDER BY columns for each granule. When a query filters on an ORDER BY column, ClickHouse checks the index and skips granules that cannot contain matching rows.
For example, with ORDER BY (country, event_date):
Choosing the right ordering key
Put the columns you filter on most frequently first. Within those, put low-cardinality columns before high-cardinality ones.
Good ordering key design:
| Column position | Rationale |
|---|---|
| First | Most frequently filtered, low cardinality (e.g., event_type with 10 values) |
| Second | Frequently filtered, medium cardinality (e.g., country with 200 values) |
| Third | Time column for range scans (e.g., event_date) |
| Fourth | High-cardinality column for point lookups (e.g., user_id) |
A common mistake is putting a high-cardinality column first. If user_id is the first column, ClickHouse can only skip data efficiently when filtering by user_id. Queries that filter by event_type or country without specifying user_id will scan everything.
Primary key vs ordering key
In ClickHouse, PRIMARY KEY and ORDER BY are separate concepts, though they often match. The primary key defines which columns go into the sparse index. The ordering key defines the physical sort order. If you omit PRIMARY KEY, it defaults to ORDER BY.
You can set a shorter primary key than the ordering key to reduce index size:
Partitioning
Partitioning splits a table into separate physical units based on a partition expression. The most common approach is to partition by month.
Each partition is a separate directory on disk. This gives you two main benefits:
- Partition pruning. Queries that filter on the partition key skip entire partitions. A query for March 2026 data does not touch January or February partitions.
- Data management. You can drop old partitions instantly, without a heavy DELETE operation.
Partitioning guidelines
Keep the number of partitions reasonable. Having thousands of partitions creates overhead. A good rule: aim for partitions that contain at least a few million rows each.
- Monthly partitioning works well for most use cases
- Daily partitioning is fine if you ingest tens of millions of rows per day
- Avoid partitioning by high-cardinality columns (like user_id)
Do not confuse partitioning with ordering. Partitioning controls physical file organization at a coarse level. Ordering controls row sorting within each partition.
Materialized views for pre-aggregation
Materialized views in ClickHouse work differently from materialized views in PostgreSQL or other databases. They are not periodic snapshots. They are triggers that transform and insert data incrementally as new rows arrive.
How materialized views work
When you insert data into a source table, ClickHouse runs the materialized view's SELECT query against the newly inserted block and inserts the result into a target table. This happens at insert time, not at query time.
Now every insert into raw_events automatically populates hourly_event_stats. Querying the aggregated table is orders of magnitude faster than scanning raw data.
Using AggregateFunction states
For more complex aggregations (percentiles, approximate counts, averages), combine AggregatingMergeTree with -State and -Merge functions:
This pattern lets you maintain running percentiles, distinct counts, and other stateful aggregations without re-scanning raw data.
When to use materialized views
Materialized views are best when:
- You have a known set of queries or dashboards that run frequently
- Raw data is too large to aggregate on the fly at acceptable latency
- You can tolerate slight delays in aggregation (insert-time processing)
They are not a replacement for ad-hoc querying. Keep the raw data around for exploratory analysis, and use materialized views to speed up known, repeated query patterns.
Query optimization tips
ClickHouse is fast out of the box, but there are several ways to make your queries even faster.
Filter early and filter on ordering key columns
The biggest performance gains come from reducing the amount of data scanned. Filters on ordering key columns enable granule skipping. Filters on partition key columns enable partition pruning. Always include these filters when possible.
Select only the columns you need
Columnar storage means ClickHouse reads only the columns you reference. A SELECT * reads all columns. If you only need three columns, name them.
Use PREWHERE for additional filtering
PREWHERE is a ClickHouse-specific clause that filters data before reading non-filtered columns. ClickHouse applies PREWHERE automatically in many cases, but you can force it:
ClickHouse first reads only the duration_ms column, eliminates rows below 1000ms, and only then reads user_id and event_type for surviving rows. This reduces I/O when the PREWHERE filter eliminates a large percentage of rows.
Approximate functions
For dashboards where exact precision is not critical, approximate functions are much faster:
uniq() uses HyperLogLog under the hood. For even faster results with lower accuracy, try uniqHLL12() or uniqCombined().
Sampling
For exploratory queries on massive tables, ClickHouse supports sampling. You need to define a sampling key in the table:
This reads approximately 10% of the rows, giving you faster results for exploratory analysis. ClickHouse adjusts the result to estimate the full-table answer.
Avoid large JOINs
ClickHouse can perform JOINs, but they work differently from traditional databases. The right side of a JOIN is loaded into memory by default. Large right-side tables can cause memory issues.
Strategies to minimize JOIN costs:
- Denormalize data at ingestion time (preferred)
- Use dictionaries for dimension lookups
- Put the smaller table on the right side of the JOIN
- Use
JOINwithANYinstead ofALLwhen you only need one matching row
Use LIMIT and OFFSET wisely
ClickHouse processes data in a streaming fashion. Adding LIMIT to an aggregation query does not reduce scan time, because the aggregation must process all matching rows first. But LIMIT on a non-aggregated query can short-circuit early.
ClickHouse vs OLTP databases for analytics
If you are coming from PostgreSQL, MySQL, or another OLTP database, the shift to ClickHouse involves a few mental model changes.
What ClickHouse does better
| Capability | OLTP database | ClickHouse |
|---|---|---|
| Scan billions of rows | Minutes to hours | Seconds |
| Compression ratio | 2-3x typical | 5-10x typical |
| Aggregation throughput | Limited by row-oriented reads | Optimized for columnar scans |
| Time-series queries | Needs careful indexing | Native support with ordering keys |
| Concurrent analytical queries | Degrades under load | Scales with parallel execution |
A query that takes 30 seconds in PostgreSQL might take 200 milliseconds in ClickHouse, given the same data and hardware. The difference grows with data volume.
What OLTP databases do better
| Capability | OLTP database | ClickHouse |
|---|---|---|
| Single-row inserts | Sub-millisecond | Discouraged (batch instead) |
| UPDATE and DELETE | Native support | Limited, eventual consistency |
| Transactions (ACID) | Full support | No transactions |
| Row-level lookups | Fast with B-tree indexes | Slower without ordering key match |
| Small result sets from normalized tables | Optimized with JOINs | JOINs are memory-intensive |
ClickHouse is not a replacement for your OLTP database. It is a complement. A common architecture has PostgreSQL or MySQL handling transactional workloads, with data replicated or streamed into ClickHouse for analytics.
Common migration patterns
When moving analytical workloads from an OLTP database to ClickHouse:
- Identify slow analytical queries. Look for queries that scan large amounts of data, use GROUP BY extensively, or aggregate over time ranges.
- Design ClickHouse tables around those queries. Choose ordering keys based on the most common WHERE clauses. Denormalize JOINs.
- Set up data ingestion. Use tools like Kafka, Debezium, or custom ETL to move data from your OLTP database to ClickHouse.
- Create materialized views for your most common dashboard queries.
- Keep raw data in ClickHouse for ad-hoc analysis, with TTL policies to expire old data automatically.
Common OLAP patterns in ClickHouse
Here are several query patterns that show up frequently in analytical workloads. These examples demonstrate how to structure common analyses.
Time-series aggregation
Funnel analysis
The windowFunnel function tracks how far each user progressed through the funnel within a 24-hour window (86400 seconds). Level 0 means the user did not trigger the first event. Level 3 means they completed all three steps.
Retention analysis
Top-N analysis
The topK(5) function returns the 5 most frequent values in the country column for each group.
Rolling averages
Sessionization
TTL: automatic data lifecycle management
ClickHouse can automatically delete or move data based on age. This is essential for OLAP systems where you want to keep recent data hot and expire old data.
Rows older than 90 days are deleted during background merges. You can also use TTL to move data between storage tiers:
Inserting data efficiently
ClickHouse performs best with batch inserts. Inserting one row at a time creates many small parts that ClickHouse must merge later, causing overhead. Aim for inserts of at least 1,000 rows, ideally 10,000 to 100,000 at a time.
For streaming use cases, consider using a buffer table that batches inserts automatically:
Monitoring query performance
ClickHouse provides tools to understand how your queries perform. Tools like DB Pro give you a visual interface for monitoring database performance, but you can also query ClickHouse's system tables directly.
The query log
EXPLAIN for query analysis
System metrics
Putting it all together
A well-designed ClickHouse OLAP setup follows a pattern:
- Ingest raw data into a MergeTree table with a carefully chosen ordering key and monthly partitioning.
- Create materialized views that pre-aggregate data for your most common queries and dashboards.
- Use appropriate data types. Favor small integers, LowCardinality strings, and native date types.
- Set TTL policies to manage data lifecycle automatically.
- Batch your inserts. Avoid single-row inserts. Use buffer tables or external batching if needed.
- Monitor performance using the query log and system tables. Look for queries that scan too many rows and add or adjust ordering keys.
ClickHouse handles OLAP workloads at a speed that traditional databases cannot match. By understanding how columnar storage, ordering keys, and materialized views work together, you can build analytical systems that return answers in milliseconds over billions of rows.