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-client
Interactive shell. Native protocol on port 9000.
clickhouse-client -h host -d db -u user --password
Flag-form connection. Prompt for password.
clickhouse-client --port 9440 --secure
TLS native port.
clickhouse-client -q "SELECT count() FROM t"
One-shot query, exits after.
clickhouse-client --multiquery < script.sql
Run a script file.
clickhouse-client -m
Multi-line input mode — treat newlines as continuation.
clickhouse-client --format=JSONEachRow
Switch the output format (~80 supported).
clickhouse-client --progress
Live 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 t
Introspection 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 = MergeTree
Workhorse. 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 = AggregatingMergeTree
Holds AggregateFunction(...) states; merges them.
ENGINE = CollapsingMergeTree(sign)
+1/-1 sign rows cancel each other — updates without rewrite.
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 · JSON
Specialised 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 = 8192
Rows 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 4
Skip index. Set of distinct values per granule.
INDEX idx_u user_id TYPE minmax GRANULARITY 4
Min/max skip index for monotonic-ish columns.
INDEX idx_b col TYPE bloom_filter(0.01) GRANULARITY 4
Bloom for set-membership filters.
PROJECTION p_by_date (SELECT * ORDER BY date)
Alternate physical layout managed alongside the main table.
CREATE TABLE ... ON CLUSTER c
DDL 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 Parquet
Bulk load from a file. Auto-detect schema with FORMAT Parquet.
-- 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 mv
Trigger a refreshable MV manually.
ALTER TABLE mv MODIFY QUERY SELECT ...
Edit the MV body. Existing target rows stay.
SELECT ... FROM target_table
Read 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)
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.
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.