DS DevShelfHub Projects · AI tools
Cheatsheets / ClickHouse
Cheatsheet · Dev tooling

ClickHouse Cheatsheet: MergeTree, Materialized Views and OLAP Reference

By DevShelfHub

MergeTree family, ORDER BY / PARTITION BY, projections, materialized views, -State/-Merge, dictionaries, TTLs, async inserts — the columnar OLAP surface.

117 items 9 min MergeTree Columnar MVs

Start hereQuick start · 6 you’ll reach for daily

Connectclickhouse-client -h host -d db
Create MergeTreeENGINE = MergeTree ORDER BY (ts)
Bulk insertINSERT INTO t FORMAT CSV
Approx uniqueuniq(user_id)
Async insertSETTINGS async_insert=1
Force mergeOPTIMIZE TABLE t FINAL

Target versions · paceVersions

Targets: clickhouse ≥ 24.x clickhouse-client matches server clickhouse-connect ≥ 0.7 (Python)

ClickHouse releases monthly under a date-based version (e.g. 24.8 LTS). Pick an LTS for production; LTS gets bug fixes for ~1 year. Recent flagship features: refreshable MVs, lightweight DELETE, projections, JSON type (experimental), parallel replicas, typed parameters in EXPLAIN. Run the client matching the server major to avoid protocol drift.

Docker · clientSetup

bash
# Docker — single-node dev instance
docker run -d --name ch \
    -p 8123:8123 -p 9000:9000 \
    -e CLICKHOUSE_USER=dev -e CLICKHOUSE_PASSWORD=dev \
    clickhouse/clickhouse-server:24

# Native client (binary protocol on 9000)
docker exec -it ch clickhouse-client --user dev --password dev

# HTTP — handy from curl / scripts (port 8123)
curl 'http://dev:dev@localhost:8123/?query=SELECT+version()'

# Local-only — in-process tool, no server needed
clickhouse-local -q "SELECT count() FROM file('events.parquet')"

# Python — official driver
pip install "clickhouse-connect>=0.7"

python - <<'PY'
import clickhouse_connect
client = clickhouse_connect.get_client(host='localhost', username='dev', password='dev')
print(client.query('SELECT version()').result_rows[0][0])
PY

The shellclickhouse-client

clickhouse-clientInteractive shell. Native protocol on port 9000.
clickhouse-client -h host -d db -u user --passwordFlag-form connection. Prompt for password.
clickhouse-client --port 9440 --secureTLS native port.
clickhouse-client -q "SELECT count() FROM t"One-shot query, exits after.
clickhouse-client --multiquery < script.sqlRun a script file.
clickhouse-client -mMulti-line input mode — treat newlines as continuation.
clickhouse-client --format=JSONEachRowSwitch the output format (~80 supported).
clickhouse-client --progressLive progress bar with rows/sec.
clickhouse-local -q "SELECT * FROM file('a.parquet')"In-process tool. No server needed.
clickhouse-benchmark -c 16 -i 100 -q "SELECT count() FROM t"Concurrent benchmark from the CLI.
SHOW TABLES · DESCRIBE TABLE t · SHOW CREATE TABLE tIntrospection inside the shell.

MergeTree family & friendsTable engines

The engine determines storage layout, merge behaviour, replication and read pattern. 99% of analytical tables are MergeTree or a specialised subclass.

ENGINE = MergeTreeWorkhorse. Sorted parts merged in the background.
ENGINE = ReplacingMergeTree([version])Background dedup on the sort key. Pick latest by version col.
ENGINE = SummingMergeTree([cols])Sum numeric cols on merge. Pre-aggregation in storage.
ENGINE = AggregatingMergeTreeHolds AggregateFunction(...) states; merges them.
ENGINE = CollapsingMergeTree(sign)+1/-1 sign rows cancel each other — updates without rewrite.
ENGINE = VersionedCollapsingMergeTree(sign, version)Out-of-order-safe variant.
ENGINE = Replicated*MergeTree(zk_path, replica)HA via ClickHouse Keeper / ZooKeeper.
ENGINE = Distributed(cluster, db, table[, sharding_key])Fan queries out across shards.
ENGINE = Buffer(db, table, layers, ...)In-memory write buffer in front of a target table.
ENGINE = Memory / Log / TinyLogScratch / append-only no-index. Tests & tiny tables.
ENGINE = NullBlack-hole sink. Useful for table-as-trigger when feeding multiple MVs.
ENGINE = Kafka(...) / S3(...) / URL(...) / PostgreSQL(...)External-source / sink engines.

Numerics · strings · compositesData types

UInt8/16/32/64/128/256 · Int8/.../256Width-explicit ints. Wider = more bytes per row.
Float32 · Float64IEEE-754. Avoid for money.
Decimal(p, s)Fixed precision. Money, rates.
StringUTF-8, no length limit.
FixedString(n)Padded with \0. Use for hashes / 2-letter codes.
LowCardinality(String)Preferred Dictionary-encoded. Big win for event/country-style columns.
Nullable(T)Wraps a type. Adds a null bitmap; avoid in hot columns — use a sentinel value where possible.
Array(T)Variable length. Unnest with ARRAY JOIN.
Tuple(a UInt8, b String)Named heterogeneous fields. Like a STRUCT.
Map(K, V)Key-value pairs of one type each.
Enum8('a'=1, 'b'=2)Bounded set stored as tiny int.
Date · DateTime · DateTime64(p, [tz])Day / second / sub-second precision.
UUID · IPv4 · IPv6 · JSONSpecialised fixed types. JSON is experimental in 24.x.

ORDER BY · PARTITION BYDDL

CREATE TABLE t (...) ENGINE = MergeTree ORDER BY (a, b)Required: ORDER BY = sort key + sparse index.
PARTITION BY toYYYYMM(ts)Coarse partition key (months / weeks). Never user-level.
PRIMARY KEY (a)Optional, must be a prefix of ORDER BY. Mark-cache resident.
SETTINGS index_granularity = 8192Rows per granule. 8k is the standard default.
SAMPLE BY intHash32(user_id)Enables SAMPLE 0.1 on the table.
INDEX idx_e event TYPE set(0) GRANULARITY 4Skip index. Set of distinct values per granule.
INDEX idx_u user_id TYPE minmax GRANULARITY 4Min/max skip index for monotonic-ish columns.
INDEX idx_b col TYPE bloom_filter(0.01) GRANULARITY 4Bloom for set-membership filters.
PROJECTION p_by_date (SELECT * ORDER BY date)Alternate physical layout managed alongside the main table.
CREATE TABLE ... ON CLUSTER cDDL fans out to every node in the cluster.
sql
-- Canonical MergeTree table for an events stream.
-- Rules of thumb: ORDER BY puts low-cardinality cols first;
-- PARTITION BY is coarse (months/days), never user-level.
CREATE TABLE events
(
    ts        DateTime CODEC(DoubleDelta, LZ4),
    user_id   UInt64,
    event     LowCardinality(String),           -- dict-encoded → small + fast
    country   LowCardinality(FixedString(2)),
    revenue   Decimal(18, 4),
    props     Map(LowCardinality(String), String),

    -- Skip indexes: planner can prune granules cheaply
    INDEX idx_event event TYPE set(0)        GRANULARITY 4,
    INDEX idx_user  user_id TYPE minmax      GRANULARITY 4
)
ENGINE = MergeTree
ORDER BY (event, toStartOfHour(ts), user_id)    -- primary key prefix
PARTITION BY toYYYYMM(ts)                       -- one part dir per month
TTL ts + INTERVAL 90 DAY                        -- drop old parts automatically
SETTINGS index_granularity = 8192;

-- Bulk insert (always batch — VALUES one-by-one is a smell)
INSERT INTO events FROM INFILE 'events.parquet' FORMAT Parquet;

-- Force a merge after a backfill (rarely needed on hot tables)
OPTIMIZE TABLE events PARTITION '202605' FINAL;

Bulk loads · mutationsINSERT & ALTER

INSERT INTO t (a, b) VALUES (1, 'x')Single-row inserts: fine in dev, awful in prod (one part each).
INSERT INTO t FROM INFILE 'data.parquet' FORMAT ParquetBulk load from a file. Auto-detect schema with FORMAT Parquet.
INSERT INTO t FORMAT JSONEachRow {...}JSONL stream over the wire / HTTP.
INSERT INTO t SELECT ... FROM sourceServer-side copy. Use for backfills.
SETTINGS async_insert = 1, wait_for_async_insert = 0Server buffers small inserts into bigger blocks. Great with many writers.
OPTIMIZE TABLE t [PARTITION p] [FINAL]Force merge. FINAL = collapse all parts to one.
ALTER TABLE t ADD COLUMN col String AFTER existingCheap unless backfilled.
ALTER TABLE t MODIFY COLUMN col Decimal(18, 4)Type change — rewrites every part.
ALTER TABLE t UPDATE col = ... WHERE ... · ... DELETE WHERE ...Mutations — asynchronous background rewrite.
DELETE FROM t WHERE ...Lightweight delete (24.x+). Marks rows, merged out later.
SELECT * FROM system.mutations WHERE table = 't' AND not is_doneWatch in-flight mutations.
TRUNCATE TABLE t [ON CLUSTER c]Fast empty — drops all parts.

PREWHERE · ARRAY JOIN · FINALQueries

SELECT ... PREWHERE filter_col = 1 WHERE other_col > 0Read filter_col first — skip the rest if it doesn’t match.
SELECT ... FROM t SAMPLE 0.110% statistical sample. Requires SAMPLE BY on the table.
SELECT ... FROM t FINALForce-merge pending parts at read time. Slow — only for ReplacingMergeTree dedup.
SELECT ... FROM t ARRAY JOIN tags AS tagUnnest an Array column into rows.
SELECT a, b FROM t1 JOIN t2 USING uidDefault join builds RHS hash table in RAM.
SETTINGS join_algorithm = 'partial_merge'Spill-to-disk join for big RHS.
SELECT ... LIMIT 10 BY uidTop-N per group, no window function.
SELECT ... WITH FILL FROM today()-7 TO today() STEP INTERVAL 1 DAYFill missing dates with NULL rows.
WITH (SELECT count() FROM t) AS total SELECT ... / total FROM tScalar subquery via WITH.
SELECT t1.* FROM t1 GLOBAL JOIN t2 ON t1.id = t2.idIn Distributed queries: build RHS once on initiator, broadcast.

Combinators · HLL · t-digestAggregations

uniq(x)HLL approximate count distinct. ~1% error, default.
uniqExact(x)Exact. RAM heavy — avoid on big sets.
uniqCombined(x)Stronger HLL. ~0.5% error.
quantileTDigest(0.95)(x)Approximate p95 via t-digest.
quantilesTDigest(0.5, 0.95, 0.99)(x)Multiple quantiles, one pass.
groupArray(x) · groupUniqArray(x)Collect into Array; distinct variant.
argMax(label, value)Label of the row with the largest value.
topK(10)(x)Approximate top-K most frequent.
sumIf(x, cond) · avgIf(x, cond)-If combinator — per-aggregate filter.
uniqState(x) · uniqMerge(state)-State / -Merge — the MV pre-agg pattern.
sql
-- The -State / -Merge / -MergeState combinator family.
-- Pattern: pre-aggregate now (cheap on write), finalize on read (cheap on query).

-- A bare aggregate column — the "finalized" result
SELECT uniq(user_id) FROM events;            -- → 1234567

-- -State: same function, but returns the internal aggregator state
SELECT uniqState(user_id) FROM events;       -- → AggregateFunction(uniq, UInt64) blob

-- Store states in a column (often via an AggregatingMergeTree MV)
CREATE TABLE u_daily
(
    day  Date,
    uniq AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree ORDER BY day;

INSERT INTO u_daily
SELECT toDate(ts) AS day, uniqState(user_id) FROM events GROUP BY day;

-- -Merge: combine multiple states back into a finalized value
SELECT day, uniqMerge(uniq) AS unique_users
FROM u_daily WHERE day >= today() - 30
GROUP BY day ORDER BY day;

-- -MergeState: combine into ANOTHER state (handy when downsampling MVs)
SELECT toStartOfWeek(day) AS w, uniqMergeState(uniq) AS uniq
FROM u_daily GROUP BY w;

INSERT triggers + target tablesMaterialized views

CREATE MATERIALIZED VIEW mv TO target_table AS SELECT ...Preferred Incremental MV with a separately managed target.
CREATE MATERIALIZED VIEW mv ENGINE=AggregatingMergeTree ... AS SELECT ...MV-owned table. Convenient for demos, harder to evolve.
CREATE MATERIALIZED VIEW mv REFRESH EVERY 5 MINUTE AS SELECT ...Refreshable / snapshot MV (24.x+). Snapshot, not incremental.
SYSTEM REFRESH VIEW mvTrigger a refreshable MV manually.
ALTER TABLE mv MODIFY QUERY SELECT ...Edit the MV body. Existing target rows stay.
SELECT ... FROM target_tableRead the target, not the MV — the MV is just a trigger.
sql
-- Incremental MV: hourly rollup of an events stream.
-- The MV is a trigger on INSERT — it runs on every new block, writes
-- partial aggregates into the target table, and queries combine them.

CREATE TABLE events_hourly
(
    hour        DateTime,
    event       LowCardinality(String),
    visits      AggregateFunction(uniq, UInt64),  -- HLL state
    revenue_sum SimpleAggregateFunction(sum, Decimal(18, 4))
)
ENGINE = AggregatingMergeTree
ORDER BY (event, hour)
PARTITION BY toYYYYMM(hour);

CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT toStartOfHour(ts)     AS hour,
       event,
       uniqState(user_id)    AS visits,           -- -State suffix
       sum(revenue)          AS revenue_sum
FROM events
GROUP BY hour, event;

-- Read with -Merge to combine partial states
SELECT event,
       hour,
       uniqMerge(visits)     AS unique_users,
       sum(revenue_sum)      AS revenue
FROM events_hourly
WHERE hour >= now() - INTERVAL 24 HOUR
GROUP BY event, hour
ORDER BY hour DESC, revenue DESC;

In-memory lookup tablesDictionaries

CREATE DICTIONARY d (...) PRIMARY KEY id SOURCE(MYSQL(...)) LAYOUT(HASHED()) LIFETIME(MIN 300 MAX 600)Define a dictionary against a remote source.
LAYOUT(FLAT() / HASHED() / CACHE(SIZE_IN_CELLS ...) / DIRECT())Storage strategy — HASHED for < 100M rows, CACHE for huge.
SOURCE(POSTGRESQL(...) / MYSQL(...) / FILE(...) / HTTP(...) / CLICKHOUSE(...))Where rows are loaded from.
dictGet('d', 'name', toUInt64(uid))Fast lookup. Replaces a JOIN.
dictHas('d', key) · dictGetOrDefault('d', 'col', key, '-')Membership + default helpers.
SYSTEM RELOAD DICTIONARY 'd'Force refresh.

Retention · per-column compressionTTL & codecs

TTL ts + INTERVAL 30 DAYDrop a part once every row satisfies the predicate.
TTL ts + INTERVAL 90 DAY TO VOLUME 'cold'Move to a tiered storage volume.
TTL ts + INTERVAL 7 DAY DELETE WHERE status = 'ok'Conditional TTL.
TTL ts + INTERVAL 30 DAY GROUP BY uid SET amount = sum(amount)Rollup on TTL — collapse old rows.
CODEC(ZSTD(6))General-purpose, strongest default.
CODEC(DoubleDelta, LZ4) on DateTimeTime-series friendly. Delta + general LZ4.
CODEC(Gorilla, ZSTD) on Float64Time-series floats — great for sensor data.
SELECT * FROM system.parts WHERE table='t'Inspect part sizes, compression, rows.

EXPLAIN · system tablesProfiling

EXPLAIN SYNTAX SELECT ...Rewritten query after macro / view expansion.
EXPLAIN PLAN SELECT ...Logical plan tree.
EXPLAIN PIPELINE SELECT ...Processor pipeline (per-stage threads).
EXPLAIN ESTIMATE SELECT ...Estimated rows / parts the planner expects to read.
SELECT * FROM system.query_log ORDER BY event_time DESC LIMIT 10Every executed query: duration, read_rows, memory.
system.parts · system.merges · system.mutationsOperational state — what’s on disk and what’s running.
SET send_logs_level = 'trace'Server-side trace, attached to the current session.

clickhouse-connectPython driver

import clickhouse_connect; client = clickhouse_connect.get_client(host='...', secure=True)HTTP / HTTPS client.
client.query('SELECT 1').result_rowsList of tuples.
client.query_df('SELECT ...', parameters={'k': v})Returns a pandas.DataFrame. Bound params, not f-strings.
client.insert_df('t', df)Bulk insert. Column names map to the table.
with client.query_rows_stream(sql) as s: for batch in s: ...Stream a huge result without loading into memory.
client.command('OPTIMIZE TABLE t FINAL')No-result DDL / DML.
python
import clickhouse_connect
import pandas as pd

client = clickhouse_connect.get_client(
    host='clickhouse.internal',
    port=8443, secure=True,
    username='reader', password='****',
    database='analytics',
)

# Server-side parameters — bound, not string-formatted
df: pd.DataFrame = client.query_df(
    """
    SELECT event,
           uniqMerge(visits) AS uniq,
           sum(revenue_sum)  AS revenue
    FROM events_hourly
    WHERE hour BETWEEN {start:DateTime} AND {end:DateTime}
    GROUP BY event
    ORDER BY revenue DESC
    """,
    parameters={'start': '2026-05-01 00:00:00', 'end': '2026-05-19 00:00:00'},
)

# Bulk insert — pass a DataFrame; column names map to the table
client.insert_df('events_raw', df)

# Streaming a huge result without materializing in RAM
with client.query_rows_stream('SELECT * FROM events WHERE date = today()') as stream:
    for batch in stream:                  # batches are list[tuple]
        process(batch)

# Native HTTP for ad-hoc — useful in notebooks
client.command('OPTIMIZE TABLE events_hourly FINAL')

Full pipeline · ~30 linesEnd-to-end · Events analytics

Raw event stream into a MergeTree, incremental MV that maintains an hourly rollup with HLL state, and a dashboard query that finalizes the state via uniqMerge.

sql
-- End-to-end: raw event stream → hourly rollup MV → dashboard query.

-- 1. Raw event table (the firehose).
CREATE TABLE events
(
    ts       DateTime CODEC(DoubleDelta, LZ4),
    user_id  UInt64,
    event    LowCardinality(String),
    revenue  Decimal(18, 4)
)
ENGINE = MergeTree
ORDER BY (event, toStartOfHour(ts), user_id)
PARTITION BY toYYYYMM(ts)
TTL ts + INTERVAL 180 DAY;

-- 2. Hourly rollup target — keeps HLL state + running sums.
CREATE TABLE events_hourly
(
    hour        DateTime,
    event       LowCardinality(String),
    visits      AggregateFunction(uniq, UInt64),
    revenue_sum SimpleAggregateFunction(sum, Decimal(18, 4))
)
ENGINE = AggregatingMergeTree
ORDER BY (event, hour) PARTITION BY toYYYYMM(hour);

-- 3. MV that wires (1) → (2) incrementally on every INSERT.
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT toStartOfHour(ts) AS hour, event,
       uniqState(user_id) AS visits, sum(revenue) AS revenue_sum
FROM events GROUP BY hour, event;

-- 4. Dashboard query — finalized via -Merge.
SELECT hour, event,
       uniqMerge(visits) AS unique_users,
       sum(revenue_sum)  AS revenue
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY hour, event ORDER BY hour DESC, revenue DESC;

Best practiceGood to know

Wrap dimensions in LowCardinality. Anything < ~10k distinct values — event, country, status — gets dictionary-encoded. Tables shrink, scans speed up, joins are happier.
Aggregate on write with an MV, not on every read. AggregatingMergeTree + -State/-Merge lets a sub-second dashboard query span billions of raw rows. Build the rollup once; query it forever.
Use async_insert when you have many small writers. ClickHouse hates tiny inserts (one part per insert). async_insert=1 lets the server batch them server-side without changing the client code.

Common trapsWatch out for

ClickHouse is not an OLTP database. Row-level updates and deletes are mutations — asynchronous background rewrites. Don’t use it for shopping-cart-style workloads.
FINAL is a footgun. It forces ad-hoc merges at read time and undoes a lot of ClickHouse’s speed. Use FINAL only with ReplacingMergeTree and only when you absolutely need de-duplicated reads.
Don’t partition on something high-cardinality. A PARTITION BY user_id creates millions of part directories and ruins merges. Pick coarse keys — toYYYYMM(ts) or weekly buckets.

Go deeperSee also

ClickHouse FAQ

What is ClickHouse best used for?

ClickHouse excels at analytical queries (OLAP) on large datasets — think event logs, time-series metrics, clickstream data, and business intelligence dashboards. It is column-oriented, so aggregations over a few columns of billions of rows are very fast, but it is not designed for frequent single-row updates.

What is the MergeTree engine in ClickHouse?

MergeTree is the primary storage engine. Data is written in sorted parts (by ORDER BY) that are merged asynchronously in the background. Specialised variants like ReplacingMergeTree, SummingMergeTree, and AggregatingMergeTree push deduplication, pre-aggregation, and state merges into the storage layer.

What is the difference between PARTITION BY and ORDER BY in ClickHouse?

PARTITION BY divides data into physical directory partitions (usually by month or day) for efficient pruning and data lifecycle management. ORDER BY is the sort key within each part and drives sparse indexing for point and range lookups. Most tables need both: PARTITION BY for time range and ORDER BY for query filters.

How do materialized views work in ClickHouse?

A materialized view runs a SELECT on insert and writes the result to a target table. This allows pre-aggregation at write time. Combine with AggregatingMergeTree and -State/-Merge aggregate functions to maintain running aggregations without scanning the raw table on every query.

What are async inserts in ClickHouse?

Async inserts (SETTINGS async_insert=1) buffer small, frequent writes server-side and flush them as a single part, avoiding the small-parts proliferation that slows merges. They are useful when ingesting from many small producers. Set wait_for_async_insert=0 for fire-and-forget semantics.

Is ClickHouse open source and free?

Yes, ClickHouse is Apache-2.0 licensed and free to self-host. ClickHouse Cloud is the managed service with usage-based pricing. The self-hosted version has no feature restrictions — all engine types, replication, and distributed tables are available without a licence.