Seventh in the series. PARTITION BY is the setting people get wrong most
often because it looks like an indexing decision and isn’t — it’s a data
lifecycle decision. Getting it wrong doesn’t throw an error; it just makes
everything slowly worse until you’re debugging too many
parts or a merge storm
months later.
What PARTITION BY is actually for
ClickHouse merges parts within a partition, never across partitions. A
partition is the unit you DROP or move to cold storage wholesale — it exists
for data lifecycle management (expire old months, move a tenant’s data to
different storage), not for query speed. Query speed comes from ORDER BY
(the primary key / sparse index), not from PARTITION BY. Confusing the two
is the root of most partition key mistakes.
Mistake 1: partitioning like it’s an index
-- Wrong: partitioning by a high-cardinality query filter
PARTITION BY user_id
-- Wrong: partitioning by every dimension you filter on
PARTITION BY (event_date, event_type, region)
Partitioning by user_id (or any high-cardinality column) creates one
partition per distinct value — potentially millions of them. Every insert
touches exactly one partition, and a table’s total part count is roughly
partitions × parts-per-partition, so more partitions directly multiplies
part-count pressure across the whole table. Filtering on user_id should
happen through ORDER BY, with a skip index if needed — not through
PARTITION BY.
Mistake 2: too fine a granularity
-- Often too fine for high-volume tables
PARTITION BY toYYYYMMDD(event_date)
-- Usually the right default
PARTITION BY toYYYYMM(event_date)
Daily partitioning is only worth it when you routinely DROP or move
individual days and the table stays under roughly 1,000 active partitions
total. On a high-volume table, daily partitioning multiplies partition count
12x over monthly for no query-speed benefit — merges still can’t cross
partition boundaries, so you end up with more, smaller, less-merged parts.
Check what you actually have
SELECT count(DISTINCT partition) AS partition_count
FROM system.parts
WHERE active AND database = {database:String} AND table = {table:String}
More than a few hundred active partitions on one table is worth a second look; more than ~1,000 reliably starts degrading insert and merge performance — each insert only touches one partition, so many partitions means many small, slowly-merging parts spread thin. Break it down by partition to see the shape:
SELECT
partition,
count() AS part_count,
formatReadableQuantity(sum(rows)) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS size
FROM system.parts
WHERE active AND database = {database:String} AND table = {table:String}
GROUP BY partition
ORDER BY partition DESC
LIMIT 30
A healthy shape has roughly consistent row/byte counts per partition. Wildly uneven partitions (some near-empty, some huge) usually mean the partition expression doesn’t match how data actually arrives — e.g. partitioning by a column that’s mostly one value with a long tail.
Fixing an existing bad partition key
PARTITION BY can’t be changed with ALTER TABLE on an existing table — it’s
fixed at creation. Changing it means creating a new table with the right key
and migrating data:
CREATE TABLE events_new AS events
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, event_type);
INSERT INTO events_new SELECT * FROM events;
RENAME TABLE events TO events_old, events_new TO events;
Budget disk space for both tables to exist simultaneously during the
migration, and validate row counts match before dropping events_old.
How chmonitor surfaces this
Tables Overview shows
parts_count per table so an over-partitioned table stands out immediately,
and the AI agent’s schema-design advisor inspects the actual partition
distribution (the query above) before recommending a coarser key — it never
issues the ALTER/CREATE for you.
chmonitor does this for you
chmonitor tracks partition and part counts per table continuously, so a partition key mistake shows up as a trend on a chart long before it becomes an incident.
docker run -d --name chmonitor -p 3000:3000 \
-e CLICKHOUSE_HOST=https://clickhouse.example.com:8443 \
-e CLICKHOUSE_USER=default \
-e CLICKHOUSE_PASSWORD=change-me \
ghcr.io/chmonitor/chmonitor:latest
Or skip setup and try the live demo.
Related
- Docs: Tables feature — parts, storage, and per-table breakdowns
- Previous in the series: Projections vs materialized views — a decision tree
- Next in the series: Escaping MEMORY_LIMIT_EXCEEDED without buying a bigger box