Things to get right early.

These decisions shape how ClickHouse stores, reads and transforms data. Understand them before production: they are easy to set early and costly to change once the data grows.

01 · Deployment

Where should ClickHouse run? #

ClickHouse Cloud

Run by ClickHouse, Inc.
clickhouse.com/cloud

Data

Object storage (S3, GCS or Azure Blob) with a local cache. Compute scales apart from storage.

Scaling

Built in, vertical and horizontal: bigger or more replicas. MergeTree runs as SharedMergeTree, and replicas share one copy of the data. No Keeper, shards or ON CLUSTER to manage.

You run

Tables and queries. Backups, upgrades and scaling are handled.

Pricing

Pay for what you use. Compute is billed while it runs and is the expensive part; storage in object storage is cheap.

Fits

Spiky or growing load, large and rarely read data, a fast start, a small team.

Open-source ClickHouse

Self-hosted, e.g. on Kubernetes with the open-source Altinity operator , or managed for you, e.g. by Altinity.Cloud

Data

Usually local NVMe or SSD on each server, with a full copy on every replica. Object storage and tiered setups are also possible.

Scaling

Replicas with ReplicatedMergeTree, coordinated by ClickHouse Keeper (typically 3 nodes). Optional shards use Distributed tables to spread data and I/O across servers.

You run

Self-hosted: everything, from upgrades and backups to monitoring. Managed: the vendor operates it in its cloud or yours.

Pricing

Follows the servers and storage underneath, yours or the vendor's. Local SSD plus full replica copies can cost more per stored byte than object storage.

Fits

Steady heavy load, low-latency local I/O, private infrastructure, data residency, control over versions and hardware.

02 · Inserts

How do I keep inserts fast? #

Batch rows, and avoid spreading one insert across many partitions. Here, PARTITION BY toDate(timestamp) gives each day one partition, where background merges keep combining the parts. Partitioning is mainly for data lifecycle; a real table may need coarser partitions, or none. See the official guide, Selecting an insert strategy .

Where each batch lands
Batches land in today's partitionPARTITION BY toDate(timestamp) · one partition per date; background merges combine the parts inside itpartition 2025-06-14 · yesterdayuntouched1 part · ≈ 150 M rows · ≈ 19 GiBInsertB1B1B2B2B3B3B4B4partition 2025-06-15 · todaybackground merges00:00–10:00 · merged earlier1 part · today so far8 k8 k8 k8 k16 k16 k32 kmerge 1merge 2merge 3merge 4 · may happen
Batches land in today's partitionPARTITION BY toDate(timestamp) · one per datepartition 2025-06-14 · yesterdayuntouched1 part · ≈ 150 M rowsInsertB1B1B2B2B3B3B4B4partition 2025-06-15 · todaybackground merges00:00–10:001 part · today so far8 k8 k8 k8 k16 k16 k32 kmerge 1merge 2merge 3merge 4 · may happen
Illustrative exampleMerge 4 is possible, not certain: it rewrites the whole 00:00–10:00 part to absorb 32 k rows, and ClickHouse prefers to merge parts of similar size.

03 · Storage layout

What are partitions, parts and granules? #

TOO_MANY_PARTS is a common production error: parts pile up faster than background merges can combine them. Sensible partition and sorting keys let merges run faster and use fewer resources, since merges never combine parts across partitions and well-sorted data compresses into fewer bytes to rewrite.

Shard optional · open-source ClickHouse
A slice of a table's rows kept on its own servers. Sharding spreads disk I/O across servers and can speed up large reads, but a single server works fine without it. ClickHouse Cloud uses shared storage, so there are no shards for users to manage.
Partition
A logical group of parts that share the same partition-key value. Parts from different partitions are never merged.
Part
A self-contained, immutable unit of sorted columnar data. Inserts create new parts; background merges replace smaller parts with larger ones within the same partition. Small parts can use Compact format, while larger parts typically use Wide format.
Granule
A consecutive range of rows within a part, ordered by the sorting key. It is the smallest unit ClickHouse reads—usually 8,192 rows by default, though the size can vary.
Indexes
The sparse primary index records sorting-key values at granule boundaries. Data-skipping indexes store metadata for one or more granules. Together they rule out granules that cannot match a query before reading column data. Index data is analyzed in RAM; depending on settings, it may stay resident or be loaded and cached on demand.
Partition → part → granule
1Partitions hold partsPARTITION BY toDate(timestamp) · each insert writes a new part into the partition of its date1partition 2025-06-132 days agowide · ≈ 120 M rowswide · ≈ 80 M rowspartition 2025-06-14yesterdaywide · ≈ 150 M rowspartition 2025-06-15 · todayabout every 4.7 s: 8 k rows → 1 compact partprojecting to end of day: B13 … B18,432B1B2B3B4B5B6B7B8B9B10B11B12…B1–B4 · 32 k rows · wideB1–B10 · 80 k rows · wideB1–B100 · ≈ 0.8 M rowsB1–B1,000 · ≈ 8 M rowsB1–B10,000 · ≈ 82 M rowsB1–B18,432 · ≈ 151 M rows · wideevent_type.bin, timestamp.bin, … one file per columncompact: all columns in one file, under the 10 MiB threshold used herewide: one file per column2Index analysis in RAMper part: primary.idx holds the first key of every granule; the skip index holds min/max revenue per granule2WHERE event_type = 'purchase' AND revenue > 500In RAMloaded or cached as neededprimary.idxevent_typetimestampskip · revenuemark 0click00:00:00⋮mark 5,528click23:59:43mark 5,529purchase00:00:03⋮mark 5,563purchase23:18:46mark 5,564view00:00:00⋮mark 18,431view23:59:530 – 0⋮0 – 0✗0.50 – 2,310✓⋮1.20 – 980✓0 – 0⋮0 – 0On diskpart B1–B18,432, sorted by event_type, then timestamp18,432 granules of 8,192 rows · ⋮ = granules not drawnclick …granule 0✗ primary⋮ click5,527 more✗ primaryclick …granule 5,528✗ skip idxpurchasegranule 5,529✓ read⋮ purchase33 more22 ✓ · 11 ✗purchasegranule 5,563✓ readview …granule 5,564✗ primary⋮ view12,866 more✗ primaryview …granule 18,431✗ primary24 / 18,432granules read · primary.idx keeps 36 (the 35 purchase granules + granule 5,528) · the skip index drops 12: max revenue ≤ 500
1Partitions hold partsPARTITION BY toDate(timestamp) · one per date1partition 2025-06-13 · 2 days agowide · ≈ 120 M rowswide · ≈ 80 M rowspartition 2025-06-14 · yesterdaywide · ≈ 150 M rowspartition 2025-06-15 · todayevery ≈ 4.7 s: 8 k rowsday's end: B13 … B18,432B1B2B3B4B5B6B7B8B9B10B11B12…B1–B4 · 32 k rows · wideB1–B10 · 80 k rows · wideB1–B100 · ≈ 0.8 M rowsB1–B1,000 · ≈ 8 M rowsB1–B10,000 · ≈ 82 M rowsB1–B18,432 · ≈ 151 M rows · wide1 file per columncompact: one file (under 10 MiB here)wide: one file per column2Index analysis in RAMper part: primary.idx and the skip index2WHERE event_type = 'purchase'AND revenue > 500In RAMloaded or cached as neededm0click00:00:00⋮m5,528click23:59:43m5,529purchase00:00:03⋮m5,563purchase23:18:46m5,564view00:00:00⋮m18,431view23:59:530 – 0⋮0 – 0✗0.50 – 2,310✓⋮1.20 – 980✓0 – 0⋮0 – 0On diskpart B1–B18,432, sorted by event_type, then timestamp18,432 granules of 8,192 rows · ⋮ = not drawnclick …g0✗ primary⋮ click5,527 more✗ primaryclick …g5,528✗ skip idxpurchaseg5,529✓ read⋮ purchase33 more22 ✓ · 11 ✗purchaseg5,563✓ readview …g5,564✗ primary⋮ view12,866 more✗ primaryview …g18,431✗ primary24 / 18,432granules read · primary keeps 36, skip index drops 12
Illustrative exampleB1 here is the day's first batch. The 10 MiB compact-part threshold is configurable.

04 · Deduplication

Why does FINAL matter? #

FINAL gives exact results before merges catch up. Duplicates stay until their parts merge; FINAL drops them while reading: cheap on one merged part, costly on overlapping ones.

Force the merge
Background merges may leave a closed day in several parts. Force one part with min_age_to_force_merge_seconds plus min_age_to_force_merge_on_partition_only, or a nightly OPTIMIZE TABLE … PARTITION … FINAL per closed day. ClickHouse advises against OPTIMIZE FINAL, but exact results on frequent queries need it.
The same FINAL query on 6 parts vs 1 merged part
SELECT device_type, sum(revenue)FROM events FINALWHERE toDate(timestamp) = '2025-06-12'GROUP BY device_typethe same query on both1Not merged enough✓ donepartition 2025-06-12 · 6 parts, their key ranges overlap1the whole day on disk, one bar per part, length = rowspart 160 Mpart 240 Mpart 325 Mpart 415 Mpart 58 Mpart 63 M 151 M events + 10 M duplicate rowsreads 5 of 7 columns: the query's 3, plus 2 only for the mergetenant_idtimestampdevice_typerevenuepayloadevent_idevent_typeIn RAMmerge-sort 6 streams, 161 M rows, drop 10 M duplicates2Fully merged✓ donepartition 2025-06-12 · merged into 1 part2the whole day on diskpart 1151 M151 M events; the merge removed the duplicatesreads 3 of 7 columns: only what the query needsevent_idtenant_idevent_typetimestampdevice_typerevenuepayloadIn RAMnothing to mergeRAM peak (illustrative) · read buffers, plus a block of every part while mergingquery time (illustrative) · both start together16 parts, not merged enoughevery merge thread holds a block of all 6 parts≈ 660 MiB21 fully merged parteach read thread holds one block≈ 60 MiB16 parts, not merged enoughreads 2.5 GiB (5 of 7 columns) · merges 161 M rows in RAM≈ 3 s21 fully merged partreads 95 MiB (3 of 7 columns) · nothing to merge≈ 0.2 s11× less RAM15× fasterboth return the same exact result; only the work differs
SELECT device_type, sum(revenue)FROM events FINALWHERE toDate(timestamp) = '2025-06-12'GROUP BY device_type1Not merged enough✓ done2025-06-12 · 6 parts, key ranges overlap1the whole day on disk, one bar per partpart 160 Mpart 240 Mpart 325 Mpart 415 Mpart 58 Mpart 63 M 151 M events + 10 M duplicate rowsreads 5 of 7 columns, 2 only for the mergetenant_idtimestampdevice_typerevenuepayloadevent_idevent_typeIn RAMmerge-sort 161 M rows, drop duplicates2Fully merged✓ done2025-06-12 · merged into 1 part2the whole day on diskpart 1151 M151 M events; the merge removed the duplicatesreads 3 of 7 columnsevent_idtenant_idevent_typetimestampdevice_typerevenuepayloadIn RAMnothing to mergeRAM peak (illustrative)query time (illustrative) · both start together16 parts, not merged enougha block of all 6 parts per merge thread≈ 660 MiB21 fully merged partone block per read thread≈ 60 MiB16 parts, not merged enoughreads 2.5 GiB · merges 161 M rows≈ 3 s21 fully merged partreads 95 MiB · nothing to merge≈ 0.2 s11× less RAM · 15× fastersame exact result on both
Illustrative exampleLate and re-delivered events make a day's parts overlap.
The example table
CREATE TABLE events (event_id UUID, …)ENGINE = ReplacingMergeTreePARTITION BY toDate(timestamp)ORDER BY (event_type, timestamp, event_id)PRIMARY KEY (event_type, timestamp)
Merge in RAM
Overlapping parts are merge-sorted by the whole sorting key while reading.
More data read
The rest of the sorting key, here event_type and the UUID event_id, plus every duplicate row.
Sorting key vs primary key

ORDER BY sets the row order and which rows count as duplicates, so it ends with event_id.

PRIMARY KEY sets what the sparse index stores, by default the whole sorting key.

PRIMARY KEY (event_type, timestamp) leaves the 16-byte UUID out of every index entry and does not change the row order.

Each partition on its own

Two copies of an event share its timestamp, so they land in the same day's partition.

FINAL then merges within one partition only, and a day that is one merged part needs no merge.

This is do_not_merge_across_partitions_select_final, automatic since 23.12 when the partition key uses only primary-key columns, as here.

05 · Transformations

How do I join big tables without running out of memory? #

ClickHouse has a rich set of functions for all kinds of transformations. However, performance problems, above all MEMORY_LIMIT_EXCEEDED errors, happen often when pipelines are not optimized.

Parallel hash join
The default in current releases. It builds in-memory hash tables from the right-hand tables and streams the left one through them; if configured, oversized hash data can spill to disk.
One tenant per run
Every table is filtered with WHERE tenant_id = t before the join, so only that tenant reaches memory. Each run appends to the same destination table. This lowers peak memory but adds repeated query overhead, so benchmark the real pipeline.
tenant_id first
With ORDER BY (tenant_id, …), a tenant is one range in each table, so a run reads only its own granules. Together, the runs read each table about once, plus a few boundary granules.
One big join vs. one tenant at a time
1One join, all tenants at once✓ doneINSERT INTO dest SELECT … FROM a JOIN b … JOIN c …1a, b, c · ORDER BY (tenant_id, …)t1t2t3t4t5abcjoin, then appenddest · one table for every tenant2One tenant at a time✓ doneWHERE tenant_id = t on a, b and c, for t = 1 … 52a, b, c · ORDER BY (tenant_id, …): each tenant is one ranget1t2t3t4t5abcjoin, then appenddest · one table for every tenantRAM peak · hash tables of b and c, held while a streams throughtime (illustrative) · both start together1one joint1t2t3t4t545 GiB2per tenantt1t2t3t4t515 GiB1one join60 s2per tenant30 s3× less RAM2× fastermemory peaks at the biggest tenant, t2: 15 GiB vs 45 GiB for the one join
1One join, all tenants✓ doneINSERT … FROM a JOIN b … JOIN c …1t1t2t3t4t5abcdest2One tenant at a time✓ doneWHERE tenant_id = t for t = 1 … 52t1t2t3t4t5abcdestRAM peak · hash tables of b and ctime (illustrative) · both start together1one joint1t2t3t4t545 GiB2per tenantt1t2t3t4t515 GiB1one join60 s2per tenant30 s3× less RAM · 2× fasterthe peak is the biggest tenant, t2: 15 GiB
Illustrative example
Other join algorithms
grace_hash and full_sorting_merge need less memory, but they are usually a compromise: they pay for it with disk I/O or a sort.