ClickHouse for Analytics Workloads
ClickHouse is the fastest analytical database I have used in production. That sentence requires qualification: fastest for the workloads it was designed for — wide tables, aggregations over hundreds of millions of rows, time-series scans, high-cardinality GROUP BY operations — and genuinely terrible for everything else. The speed is not magic. It is the result of a coherent set of architectural decisions that trace back to one foundational choice: column-oriented storage. Understanding why that choice matters, and what trade-offs it forces, is the prerequisite for using ClickHouse well.
This guide is written from production experience. It covers the internal storage model, the MergeTree engine family in depth, the subtle but critical distinction between sorting key and primary key, materialized views for pre-aggregation, query profiling tools, replication with ClickHouse Keeper, distributed sharding, and an honest assessment of when you should reach for ClickHouse versus when you should stay with PostgreSQL.
As of this writing, the current ClickHouse LTS release is 25.8, with the latest stable release at 26.3. The version numbering follows a year.month scheme — 25.8 means the eighth release of 2025. ClickHouse Keeper is the recommended coordination layer for new deployments; ZooKeeper remains supported but is being phased out in favor of the native C++ implementation.
Why Columnar Storage Wins for Analytics
To understand columnar storage, start with how a row-oriented database like PostgreSQL stores data on disk. Each row is written as a contiguous sequence of bytes: all columns of row 1, then all columns of row 2, and so on. This layout is ideal for OLTP workloads. Fetching a single order by its primary key means reading one contiguous block from disk. Updating a row means overwriting one location. Inserting a row means appending bytes at the end of a heap file.
The problem appears when you run analytics queries. Consider this table:
orders(order_id, customer_id, product_id, quantity, unit_price, discount,
shipping_cost, tax_amount, revenue, status, created_at, updated_at)
Now run: SELECT sum(revenue) FROM orders WHERE created_at >= '2025-01-01'
In a row-oriented layout, to compute that sum, the database must read every column of every qualifying row — order_id, customer_id, product_id, quantity, unit_price, discount, shipping_cost, tax_amount — even though only two columns matter (revenue and created_at). At 100 bytes per row and 500 million rows, you are reading 50 GB of data to extract maybe 3 GB of actual signal.
Row-Oriented Storage (PostgreSQL)
==================================
Block 1:
[order_id=1][customer_id=42][product_id=7][quantity=2][unit_price=49.99]
[discount=0.0][shipping_cost=5.99][tax_amount=4.00][revenue=99.98]
[status="shipped"][created_at=2025-01-15][updated_at=2025-01-16]
[order_id=2][customer_id=108][product_id=3][quantity=1][unit_price=19.99]
[discount=2.00][shipping_cost=0.00][tax_amount=1.44][revenue=17.99]
[status="pending"][created_at=2025-01-15][updated_at=2025-01-15]
... (more rows interleaved)
To compute sum(revenue): must read ENTIRE block including all unused columns
Bytes read for 2 rows: ~200 bytes
Bytes actually needed: ~16 bytes (revenue + created_at)
Column-Oriented Storage (ClickHouse)
======================================
revenue.bin:
[99.98][17.99][34.50][129.00][8.99][55.49]...
(all revenue values, contiguous, nothing else)
created_at.bin:
[2025-01-15][2025-01-15][2025-01-16][2025-01-14][2025-01-16][2025-01-17]...
(all timestamps, contiguous, nothing else)
order_id.bin: (not touched for this query)
customer_id.bin: (not touched for this query)
product_id.bin: (not touched for this query)
...
To compute sum(revenue): read revenue.bin and created_at.bin ONLY
I/O reduction: 10-column table = ~10x less data read
This I/O reduction compounds with compression. Columns contain values of the same type with often similar magnitudes — revenue values are all floating-point numbers in a similar range, timestamps cluster together, status is one of a handful of strings. This homogeneity compresses dramatically. ClickHouse achieves 5-15x compression ratios versus the 2-3x typical of row stores. LZ4 is the default codec — extremely fast decompression, good compression ratio. ZSTD trades some CPU for better compression (typically 30-40% better than LZ4 at similar decompression speeds), useful for archival data or when I/O is the bottleneck.
Beyond I/O, ClickHouse’s execution engine is designed around the columnar layout. Processing happens on vectors — contiguous arrays of column data — rather than row-by-row. Modern CPUs support SIMD (Single Instruction, Multiple Data) instructions that operate on 16, 32, or even 64 values simultaneously. When your revenue column arrives as a packed array of Float64 values, the CPU can sum 4 or 8 of them in a single instruction. This vectorized execution is why ClickHouse benchmarks show it processing billions of rows per second on commodity hardware.
None of this applies to OLTP workloads. If your application needs to fetch a single user’s profile, update an order status, or enforce foreign key constraints in a transaction, columnar storage is the wrong choice. Reading one row means touching every column file on disk. There are no real transactions. UPDATE and DELETE are implemented as background mutations, not in-place modifications. ClickHouse is an append-only system by design, and you should treat it that way.
ClickHouse Architecture
A single ClickHouse node is a single binary that runs everything: the query parser, planner, execution engine, storage engine, and embedded HTTP and native TCP servers. There is no separate process for storage, no buffer pool manager in the traditional sense. This simplicity makes single-node ClickHouse easy to operate — install the package, write a config file, start the service.
The fundamental storage unit is a part. When you execute INSERT INTO orders VALUES (...), ClickHouse does not write rows directly into an existing file. Instead, it creates a new part — an immutable directory on disk containing one binary file per column, plus an index file and a checksums file. Parts are small after an insert; they accumulate until the background merge process combines them into larger parts. This is conceptually similar to an LSM tree (Log-Structured Merge-tree), though the details differ significantly.
INSERT path:
Client INSERT (batch of rows)
|
v
New Part created (immutable directory)
/var/lib/clickhouse/data/mydb/orders/
20250115_1_1_0/ <- new part
order_id.bin
revenue.bin
created_at.bin
primary.idx
checksums.txt
Background merge picks up multiple small parts:
20250115_1_1_0/
20250115_2_2_0/
20250115_3_3_0/
|
v (merge process)
20250115_1_3_1/ <- merged part (spans rows 1-3, merge level 1)
(all column files merged and re-sorted)
The merge runs continuously in the background. ClickHouse processes queries against the current set of parts (including unmerged ones) at all times — there is no maintenance window, no VACUUM, no manual optimization required for correctness. You can inspect the current parts with:
|
|
The active = 1 filter shows only current (non-superseded) parts. After a merge completes, the source parts are marked inactive and eventually deleted. You can force a full merge with OPTIMIZE TABLE orders FINAL, which is occasionally useful in testing or after bulk loads, but should be avoided on production tables with tens of billions of rows — it will saturate I/O and potentially run for hours.
The MergeTree Engine Family
MergeTree is not one engine — it is a family of storage engines, each with different merge-time behavior. The base MergeTree does nothing special during merges except sort and compact data. The variants intercept the merge process to perform deduplication, pre-aggregation, or partial state accumulation.
MergeTree (Base Engine)
|
|
ORDER BY defines the sorting key — within each part, rows are physically sorted by these columns. PARTITION BY splits data into separate directories per month. The combination enables two tiers of pruning: partition pruning skips entire monthly directories if the query predicate excludes that month, and then the sparse primary index skips granules within the relevant partition.
The index is sparse, not a B-tree. ClickHouse stores one index entry per 8,192 rows (the index_granularity). When a query arrives with WHERE customer_id = 42, ClickHouse binary-searches the sparse index to find the granule range where customer_id=42 could appear, reads only those 8,192-row blocks, and discards rows that do not match. This sparse index is typically a few megabytes even for tables with billions of rows, fits entirely in memory, and is far more cache-friendly than a B-tree page structure.
ReplacingMergeTree
|
|
During merges, ReplacingMergeTree deduplicates rows that share the same ORDER BY key, keeping only the row with the highest value of the optional version column (or the last-inserted row if no version column is specified). This makes it suitable for CDC (change data capture) patterns: write a new row for every update to an order, and let the engine converge on the latest state eventually.
The critical caveat: deduplication happens at merge time, not at insert time. Before a merge, a query may return multiple versions of the same order_id. You have two options for query-time correctness:
|
|
The argMax(value, ordering_column) idiom returns the value corresponding to the maximum of the ordering column, effectively picking the latest state. This pattern is faster than FINAL on large tables because it remains parallelizable across parts.
SummingMergeTree
|
|
During merges, SummingMergeTree collapses rows with the same ORDER BY key by summing the specified numeric columns (or all numeric columns if none are specified). This is the simplest pre-aggregation pattern: instead of storing 1 billion raw pageview events, you store per-page per-hour counts. Queries that ask “how many views did /blog/post-1 get this month?” now scan a few thousand rows instead of millions.
As with ReplacingMergeTree, pre-aggregation is not instantaneous — it happens at merge time. Queries should always include a final SUM to handle the case where multiple parts have not yet been merged:
|
|
AggregatingMergeTree
AggregatingMergeTree is the most powerful and most complex variant. Instead of summing integers, it stores partial aggregate states — intermediate objects that can be finalized by a merge function. This allows any aggregate function (count, avg, uniq, quantile, histogram) to be pre-computed incrementally.
|
|
You almost never insert into an AggregatingMergeTree table directly. Instead, you pair it with a materialized view that uses *State aggregate functions to generate the partial states:
|
|
Queries against the AggregatingMergeTree use *Merge functions to finalize:
|
|
This pattern scales to genuinely large datasets. The partial state for a uniqExact aggregate is a HyperLogLog sketch — it can represent millions of distinct values in a few kilobytes, and multiple sketches can be merged in constant time. You can aggregate a year of hourly data into a single result in milliseconds.
MergeTree Engine Family Reference
| Engine | Merge Behavior | Primary Use Case | Key Gotcha |
|---|---|---|---|
| MergeTree | Sort + compact only | Raw event storage, general analytics | No dedup, no pre-aggregation |
| ReplacingMergeTree | Deduplicate by ORDER BY key, keep latest version | CDC upserts, mutable dimension tables | Eventual dedup — use FINAL or argMax for correctness |
| SummingMergeTree | Sum numeric columns for same ORDER BY key | Pre-aggregated counters, time-series rollups | Always SUM again at query time; rows may not be fully merged |
| AggregatingMergeTree | Merge partial aggregate states | Complex pre-aggregation with uniq/quantile/histogram | Requires *State/*Merge function pairs; most complex to operate |
| CollapsingMergeTree | Cancel rows with sign=+1 and sign=-1 | Event sourcing with explicit delete markers | Order-sensitive; concurrent inserts can corrupt state |
| VersionedCollapsingMergeTree | Like Collapsing, but version-aware | CDC with out-of-order updates | Complex; usually AggregatingMergeTree or ReplacingMergeTree serves better |
Primary Key vs. Sorting Key: The Critical Distinction
In PostgreSQL, the primary key is an immutable unique identifier — it drives a B-tree index, enforces uniqueness, and is used by foreign keys. In ClickHouse, the concepts split into two separate things that happen to be the same by default.
The sorting key (ORDER BY) defines the physical sort order of rows within each part. When ClickHouse writes a part to disk, it sorts all rows by the sorting key before writing the column files. This determines which range queries are fast: a query with WHERE customer_id = 42 AND created_at >= '2025-01-01' is a sorted range scan, blazing fast. A query with WHERE product_id = 99 on a table sorted by (customer_id, created_at) is a full scan — ClickHouse must read every granule.
The primary key (PRIMARY KEY) defines which columns are included in the sparse index. By default, the primary key equals the sorting key. But you can set them independently:
|
|
Here, data is sorted by (customer_id, created_at, order_id) — the sort order includes order_id for uniqueness within a customer-day. But the sparse index only covers (customer_id, created_at) — the index is smaller, and order_id being in the sort key still guarantees efficient range scans on the first two columns. Adding order_id to the sort key (but not the primary key index) ensures rows within the same customer-day granule are consistently ordered, which can improve compression and scan predictability.
Practically speaking, the sorting key should be chosen based on your most common query patterns:
WHEREclauses that use equality or range predicates on specific columns- The leftmost columns are most important — ClickHouse uses a prefix of the sorting key for index lookups
- High-cardinality columns that appear often in WHERE clauses belong early in the key
- Time columns (created_at, event_date) often appear second, after a customer or entity ID, because most queries are “for customer X in time range Y”
- Do not put a UUID as the first column — UUIDs are essentially random, which destroys sort order and prevents any useful range scans
Sorting key cardinality principle:
customer_id -> ~10M distinct values (good first column: good range scan)
created_at -> continuous range (good second column: time ranges)
order_id -> unique per row (good for dedup; no range benefit)
Queries that benefit from ORDER BY (customer_id, created_at):
WHERE customer_id = X <- prefix match
WHERE customer_id = X AND created_at > Y <- prefix + range
WHERE customer_id BETWEEN X AND Z <- prefix range scan
Queries that DO NOT benefit:
WHERE created_at > Y (without customer_id) <- not a prefix match
WHERE revenue > 100 <- revenue not in sort key
Data Types and Compression Codecs
Choosing the right data types is not a micro-optimization in ClickHouse — it directly affects storage size, compression ratio, and execution speed. The wrong types can double your storage footprint.
Integer types: ClickHouse has a full range — UInt8, UInt16, UInt32, UInt64, Int8 through Int64. Always use the smallest type that fits. A boolean stored as UInt8 (values 0 and 1) compresses to near nothing. An event_type stored as UInt32 when there are only 50 event types wastes three bytes per row.
LowCardinality(String): For any string column with fewer than roughly 10,000 distinct values — status, country_code, event_type, currency, device_type — use LowCardinality(String) instead of plain String. ClickHouse stores these as dictionary-encoded integers internally, reducing storage dramatically and enabling faster comparisons and GROUP BY operations. The dictionary is per-block, so cardinality should genuinely be low (the name is accurate).
Nullable(T): Avoid this when possible. Nullable types store a separate null bitmap alongside every row, add overhead to every aggregation, and disable certain optimizations. If you can represent “no value” with a sentinel (0 for integers, empty string, a Unix epoch of 0 for timestamps), prefer that.
DateTime vs DateTime64: DateTime stores Unix timestamps with second precision in 4 bytes. DateTime64(3) stores milliseconds in 8 bytes. Use DateTime where second precision is sufficient — most created_at columns, date partitions, hourly aggregations. Use DateTime64 for event logs where millisecond timestamps matter for ordering and analysis.
Per-column codecs allow you to specify the compression algorithm and any pre-processing transformation per column:
|
|
Delta pre-processes monotonic integer sequences (timestamps, sequence IDs) by storing differences rather than absolute values — timestamps that increment by 1-60 seconds become tiny differences that compress to near nothing. Gorilla is the Facebook Gorilla float compression algorithm, designed for time-series floating-point data with small delta-of-delta changes — typical metrics workloads. ZSTD(level) trades more CPU for better compression; level 3 is a reasonable balance. These codecs stack: CODEC(Delta, LZ4) applies Delta transformation first, then LZ4 compression.
A production-quality events table combining these techniques:
|
|
Materialized Views
ClickHouse materialized views are fundamentally different from PostgreSQL materialized views. In PostgreSQL, a materialized view is a cached query result that you refresh manually with REFRESH MATERIALIZED VIEW. In ClickHouse, a materialized view is an insert trigger: it fires on every INSERT into its source table, runs the view’s query against the newly inserted rows, and writes the results to a target table. Historical data is not processed — the view only captures data inserted after the view is created.
This distinction matters operationally. When you add a new materialized view to a live table, you must manually backfill the target table from existing historical data:
|
|
The TO table syntax (used above) is strongly preferred over the implicit .inner. table syntax. With TO, the target table exists independently — you can query it directly, truncate it for backfills, or attach a different view to it later. The .inner. table is owned by the view and is dropped when the view is dropped, which has caused more than one production incident.
The AggregatingMergeTree + Materialized View Pattern
For complex aggregations — especially distinct counts, quantiles, and histograms — the combination of AggregatingMergeTree and materialized views with *State functions is the canonical ClickHouse pre-aggregation pattern:
|
|
uniqHLL12 uses a HyperLogLog sketch with 12-bit precision — approximately 2% error for distinct counts, but the partial states are tiny (about 2KB each) and merge in constant time. For exact distinct counts on large datasets, uniqExact is available but stores a full hash set as its state, which is far larger. Choose based on whether approximate is acceptable.
Query Profiling and EXPLAIN
ClickHouse ships with a rich set of profiling tools. The most important are not in external monitoring systems — they are built into the database itself.
EXPLAIN Variants
|
|
The EXPLAIN ESTIMATE output is the most actionable for performance troubleshooting. It shows:
rows— estimated rows to be scannedmarks— number of index granules to be read
If a query is reading 100,000 marks on a table with index_granularity = 8192, that means 819 million rows are being scanned. If the query returns 1,000 rows, something is wrong with your partition pruning or sorting key choice. Either the WHERE clause does not align with the partition key, the sort key columns are not being used effectively, or the query is hitting data across many partitions.
system.query_log
system.query_log is the primary production profiling tool. Every completed query is logged here with full timing and resource usage:
|
|
The is_initial_query = 1 filter excludes sub-queries issued by distributed tables, which would otherwise show the same query multiple times. read_rows divided by query_duration_ms gives you rows-per-millisecond throughput — a well-optimized ClickHouse query should be reading tens to hundreds of millions of rows per second on modern hardware.
For investigating a specific slow query, look at the marks read:
|
|
A high marks_read relative to result_rows means the query is reading far more data than it returns. Investigate whether:
- The WHERE clause predicate aligns with the partition key (partition pruning)
- The WHERE clause columns appear in the ORDER BY key (granule pruning via sparse index)
- There are secondary indexes (bloom filter, minmax) that could help
Secondary indexes in ClickHouse (INDEX name expr TYPE bloom_filter GRANULARITY 4) can help for low-cardinality filters on columns not in the sort key, but they are not a substitute for good sort key design.
clickhouse-benchmark
For load testing, ClickHouse ships a built-in benchmarking tool:
|
|
This runs the query 100 times with 10 concurrent connections and reports percentile latencies (p50, p95, p99), QPS, and rows-per-second. Useful for measuring the impact of schema changes, index additions, or configuration tuning before deploying to production.
Replication with ClickHouse Keeper
ClickHouse replication is implemented at the table level, not the server level. A non-replicated MergeTree table exists only on the node where it was created. To make a table replicated, you use the ReplicatedMergeTree engine family — any MergeTree variant prefixed with “Replicated”:
|
|
The path /clickhouse/tables/{shard}/orders is a node in the Keeper coordination tree. All replicas of the same table share the same path prefix — this is how they find each other. The {shard} and {replica} placeholders are filled from the server’s macros configuration:
|
|
ClickHouse Keeper
ZooKeeper was the original coordination service for ClickHouse replication. Managing a separate Java-based ZooKeeper ensemble alongside ClickHouse adds operational overhead — separate deployment, separate monitoring, separate JVM tuning. ClickHouse Keeper is a native C++ ZooKeeper-compatible replacement that ships as part of the ClickHouse binary and implements the Raft consensus algorithm directly.
As of ClickHouse 25.x/26.x, ClickHouse Keeper is fully production-ready and is the recommended choice for all new deployments. ZooKeeper remains supported for existing installations, but new features (such as S3Queue) may require Keeper specifically. Migration from ZooKeeper to Keeper requires a cluster stoppage — it is not a live migration.
To configure Keeper, add a keeper_server section to ClickHouse’s configuration:
|
|
A Keeper ensemble should have an odd number of nodes (3 or 5) to maintain quorum. For small clusters, it is common to co-locate Keeper on the ClickHouse nodes themselves — one Keeper process per node, three nodes total.
Replication Topology
3-Shard x 2-Replica Cluster with ClickHouse Keeper
====================================================
Keeper Ensemble (3 nodes, Raft quorum):
+------------------+ +------------------+ +------------------+
| clickhouse-01 | | clickhouse-03 | | clickhouse-05 |
| Keeper (id=1) |<->| Keeper (id=2) |<->| Keeper (id=3) |
+------------------+ +------------------+ +------------------+
| | |
| coordinates replication metadata |
v v v
+------------------+ +------------------+ +------------------+
| clickhouse-01 | | clickhouse-03 | | clickhouse-05 |
| shard=01 | | shard=02 | | shard=03 |
| replica=ch-01 | | replica=ch-03 | | replica=ch-05 |
+------------------+ +------------------+ +------------------+
| | |
replicates to replicates to replicates to
| | |
+------------------+ +------------------+ +------------------+
| clickhouse-02 | | clickhouse-04 | | clickhouse-06 |
| shard=01 | | shard=02 | | shard=03 |
| replica=ch-02 | | replica=ch-04 | | replica=ch-06 |
+------------------+ +------------------+ +------------------+
Each shard pair replicates all data for that shard.
Distributed table fans out queries to all 3 shards.
Total capacity = 3 shards; fault tolerance = 1 replica per shard.
Monitoring replication health:
|
|
A non-zero last_exception in the replication queue is worth investigating immediately — it usually indicates a network partition, a missing part, or a Keeper connectivity issue.
Distributed Tables and Sharding
A single ClickHouse node can handle tens of billions of rows and hundreds of terabytes of data if you have the hardware for it. Sharding becomes necessary when the working set exceeds a single node’s I/O, memory, or compute capacity — or when you need write throughput higher than one node can absorb.
The Distributed table engine is a virtual table that sits in front of a set of local tables across shards. Queries to the Distributed table are fanned out to all shards in parallel, and results are merged before being returned to the client.
Cluster Configuration
The cluster topology is defined in ClickHouse’s XML configuration:
|
|
Creating the Distributed Table
|
|
The sharding key determines which shard receives each row. Two common choices:
rand()— uniform random distribution. Even shard sizes, but related rows (same customer) are split across shards, making customer-level JOINs and GROUP BY operations require cross-shard data movement.cityHash64(customer_id)— deterministic per customer. All rows for a given customer land on the same shard. Cross-shard JOINs on customer_id become local. The tradeoff is that large customers can create data skew — a customer with 100M events overwhelms one shard while others sit idle.
For most analytics workloads, cityHash64(customer_id) (or whatever your primary entity key is) produces better query performance than rand(). Monitor shard sizes with:
|
|
Write Path Considerations
Writing to the Distributed table is convenient but introduces overhead — the receiving node writes the data to a local spool directory, then asynchronously routes it to the target shard. This adds latency and creates a replay queue that can back up under high write load. For high-throughput ingestion pipelines, write directly to the local table on each shard and bypass the Distributed routing layer:
|
|
When ClickHouse Beats PostgreSQL (and When It Doesn’t)
The honest answer is that ClickHouse and PostgreSQL solve different problems, and using the wrong one for your workload is costly in either direction.
Where ClickHouse Wins
Aggregations over large tables. A query like SELECT country, sum(revenue), count() FROM orders WHERE created_at >= '2025-01-01' GROUP BY country against 10 billion rows will finish in under a second on a reasonably sized ClickHouse cluster. The same query in PostgreSQL would run for minutes or require pre-aggregation through a separate pipeline.
Time-series analytics. Event logs, metrics, access logs, click streams — ClickHouse was built for exactly this shape of data. Partitioning by date and sorting by (entity_id, timestamp) aligns perfectly with the “show me all events for user X between time A and B” access pattern.
High-cardinality GROUP BY. ClickHouse’s hash table implementation for GROUP BY is extremely fast. Grouping 1 billion rows by 10 million distinct user IDs is a workload where ClickHouse outperforms virtually every other database.
Dashboard queries that must be fast. Materialized views and pre-aggregated tables make sub-second responses over petabyte-scale datasets achievable. Business intelligence tools like Grafana, Metabase, and Superset work well against ClickHouse.
Where PostgreSQL Wins
Transactions and row-level updates. If your application needs ACID transactions, foreign key enforcement, or true UPDATE/DELETE semantics, PostgreSQL is the right choice. ClickHouse mutations (ALTER TABLE orders UPDATE status = 'cancelled' WHERE order_id = 42) are background operations that rewrite entire parts — they complete eventually, not immediately, and cannot be rolled back.
Complex JOIN workloads. ClickHouse JOINs require the right-side table to fit in memory per query (or per shard in distributed mode). Multi-table JOINs with multiple large tables are difficult. PostgreSQL’s hash join and merge join implementations are far more flexible. ClickHouse is best used with denormalized tables or with the right-side table small enough to broadcast.
Small datasets. For tables under 100M rows, PostgreSQL with appropriate indexes is competitive with ClickHouse and far simpler to operate. The columnar storage advantage shrinks on small tables where the data fits in PostgreSQL’s buffer cache.
Full-text search, geospatial. PostgreSQL with pg_trgm, tsvector, and PostGIS handles text search and geospatial queries natively. ClickHouse has some text search functions but is not competitive with dedicated search systems or PostgreSQL’s mature extensions.
Operational simplicity. A single PostgreSQL instance needs nothing except itself. A production ClickHouse cluster needs ClickHouse nodes, a Keeper ensemble, shared storage or block device replication, and careful configuration management. The operational burden is real.
The Sweet Spot: ClickHouse as Analytics Sidecar
The most common production pattern is not ClickHouse replacing PostgreSQL — it is ClickHouse running alongside PostgreSQL. Operational data lives in PostgreSQL: the orders table, the user table, the product catalog. For reporting and analytics, data is replicated to ClickHouse using CDC tools (Debezium, Airbyte, ClickHouse’s built-in MaterializeMySQL/PostgreSQL table engines) or a batch ETL process.
This gives you OLTP where you need it (PostgreSQL) and OLAP where you need it (ClickHouse), without trying to force one system to do both well.
Comparison Table
| ClickHouse | PostgreSQL | DuckDB 1.5 | BigQuery / Snowflake | |
|---|---|---|---|---|
| Storage model | Columnar | Row-oriented | Columnar | Columnar |
| Primary use case | OLAP, event analytics | OLTP, general purpose | Local analytics, data science | Cloud-scale OLAP, data warehouse |
| Scale | Billions to trillions of rows (distributed) | Millions to low billions of rows | Millions to low billions (single-node, in-process) | Effectively unlimited (cloud-managed) |
| Transactions | No | Full ACID | Single-writer (DML is transactional) | No (BigQuery); limited (Snowflake) |
| UPDATE/DELETE | Background mutations, expensive | Native, efficient | Native, efficient | Limited, expensive |
| JOINs | Right side must fit in memory | Flexible (hash, merge, nested loop) | Flexible (in-process) | Flexible (cloud resources) |
| Operational complexity | High (cluster + Keeper) | Low | Very low (embedded) | Very low (managed service) |
| Self-hosted cost | Hardware + ops overhead | Hardware + ops overhead | Effectively free | No infra cost; per-query billing |
| Query latency (cold) | Milliseconds to seconds | Seconds to minutes (large data) | Seconds (depends on disk) | Seconds to minutes |
| Best for | Production analytics systems, dashboards, logs | Web applications, APIs, OLTP | Notebooks, local ad-hoc analysis, ETL scripts | Teams without ops capacity |
DuckDB 1.5 (released March 2026) deserves a specific mention. For single-machine analytics — data science notebooks, local data exploration, ETL scripts that aggregate Parquet files — DuckDB offers ClickHouse-class columnar query performance with zero operational overhead. It runs in-process as a library. It is not a server, cannot be shared across multiple clients, and tops out at the memory and storage of one machine. But for the workloads it targets, it is exceptional. The choice between DuckDB and ClickHouse is usually the choice between single-analyst local work and production multi-user systems.
Operational Considerations
A few things learned the hard way:
INSERT batch size matters. ClickHouse creates one part per INSERT call. Inserting one row at a time creates thousands of tiny parts, and the merge process cannot keep up. The minimum recommended batch size is 1,000 rows; 10,000-100,000 is more typical for production pipelines. Use a buffer layer (Kafka, a Kafka consumer with local batching, or ClickHouse’s own Buffer table engine) to aggregate small writes.
Too many partitions is a problem. If you partition by day and your table has three years of data, that is over 1,000 partitions. ClickHouse loads partition metadata at startup and query planning time — too many partitions increases planning overhead. Monthly partitioning is more common than daily for large tables. Partition on whatever time granularity you actually use for pruning in queries, not finer.
Backup strategy. ClickHouse has a built-in BACKUP TABLE command (since 22.x) and supports backup to S3, GCS, and local disk. For production systems, combine clickhouse-backup (the open-source tool) with S3 for full and incremental backups. Test restores — a backup you have never restored is not a backup.
Memory limits. ClickHouse is aggressive about memory use by default. Set max_memory_usage at the user or query level to prevent runaway queries from OOM-killing the server. A reasonable starting point is 80% of available RAM for queries, with the remaining 20% reserved for merges and OS cache.
|
|
ClickHouse rewards careful schema design and query pattern analysis far more than most databases. The performance ceiling is extremely high, but reaching it requires understanding the storage model, choosing sorting keys deliberately, and building your materialized view pipeline around your actual query patterns. When you get it right, the query times feel almost implausible — seconds on tables where Postgres takes hours. When you get it wrong, you are scanning entire datasets for every query and wondering why it is not faster than Postgres.
The investment in understanding the system pays off quickly in production.
Comments