agentleFS
SkillMedevSkillMedev/data-engineering-pipeline/skills/clickhouse-analytics/SKILL.md

clickhouse-analytics

Skill0 starsChanged 38 days agoMITView on GitHub

Designs ClickHouse schemas and queries for fast analytics - MergeTree engine selection, ORDER BY key design, partitioning, materialized views, and projections. Use when someone asks "why is my ClickHouse query slow", "how should I order my sorting key", "should I use a materialized view or projection", "how do I deduplicate events", or is modeling an event or metrics table for OLAP. Do NOT use for tuning row-store OLTP databases like Postgres or MySQL - use sql-query-optimizer instead; for general relational schema design use database-schema; for reading query plans on traditional databases use explain-plan-reader; for the streaming ingestion side use kafka-pipelines.

3 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

10 rules. 0 are common, 5 are distinctive, and 5 are very specific to this project.

  • 0

    Common

    Most of the 3 edited versions we read keep these.

  • 5

    Distinctive

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Avoid Nullable unless NULL is semantically required - it adds a mask column to every read.

    line 64
  2. Very specific:

    Never SELECT *; storage is columnar, every column named is I/O paid.

    line 97
  3. Very specific:

    Use PREWHERE for highly selective filters on small columns - it reads the filter column first and only fetches the rest for surviving rows.

    line 99
  4. Very specific:

    Prefer approximate functions (uniq, uniqHLL12, quantile) when exactness is not required.

    line 100
  5. Distinctive:

    Put the smaller table on the right side of a JOIN.

    line 101
  6. Distinctive:

    Do not front-load a high-cardinality column in ORDER BY - it destroys skipping for every other filter.

    line 109
  7. Distinctive:

    Do not rely on ReplacingMergeTree for read-time correctness.

    line 110
  8. Distinctive:

    Do not partition by day "for granularity" on multi-year data - partition count, not partition size, is what hurts.

    line 111
  9. Very specific:

    Do not use mutations (ALTER ...

    line 112
  10. Distinctive:

    Do not add a projection and an MV for the same rollup.

    line 113

“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

ClickHouse Analytics

ClickHouse is fast only when the schema matches the queries: columnar, sorted storage means the ORDER BY key does the work a row-store's indexes would. The expensive mistake is designing the table like Postgres - wrong sorting key, per-row inserts, SELECT * - and concluding ClickHouse is slow when it is scanning every granule because nothing let it skip.

Operating procedure

Design in this order because the sorting key depends on the queries, the engine on the write semantics, and everything downstream (partitions, MVs, projections) on those two.

Step 1: gather inputs

Collect before writing any DDL, labeling estimates as estimates:

  • The top 5-10 queries by frequency and pain: which columns they filter on, group by, and over what time ranges.
  • Ingest shape: rows/second, batch or streaming, and whether the source can send duplicates or updates.
  • Retention: how long data lives and whether deletion happens by time.
  • Cardinality of candidate key columns (uniq(col) on a sample).

Step 2: choose the engine from write semantics

  • MergeTree - the default workhorse for append-only analytical tables.
  • ReplacingMergeTree - when the source sends duplicates or row versions; dedup happens during background merges, so it is eventual. Read deduped with FINAL (costly) or argMax-style aggregation.
  • SummingMergeTree / AggregatingMergeTree - pre-aggregate on merge; pair with materialized views for rollups.
  • ReplicatedMergeTree variants - production replication; assume them for anything serving real traffic.

Step 3: design ORDER BY - it IS the index

There is no traditional secondary index by default; the sorting key defines the sparse primary index (one mark per index_granularity rows, default 8192) and the physical sort. Queries that filter on a prefix of the key skip granules; everything else scans.

Rules: put the most frequently filtered columns first, ordered low-cardinality to high - a low-cardinality first column keeps runs long so later columns stay locally sorted and skippable. Keep the key to 3-4 columns; columns after the point where each prefix combination matches fewer than ~8192 rows add cost without adding skipping.

Bad / good contrast for a query load dominated by WHERE event_type = X AND event_date BETWEEN ...:

-- Bad: high-cardinality first; event_type filters skip nothing
ORDER BY (user_id, event_date, event_type)

-- Good: filters match the key prefix, granules skip
CREATE TABLE events (
  event_date Date,
  user_id UInt64,
  event_type LowCardinality(String),
  value Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_type, event_date, user_id);

Step 4: partition coarsely

Partition by a coarse time column - toYYYYMM(date) is the standard. Partitions buy cheap DROP PARTITION retention and whole-partition pruning. Red lines: keep total partitions in the low hundreds (over ~1000 degrades merges and startup), and never partition by anything high-cardinality like user_id - each insert should touch a single-digit number of partitions.

Step 5: pick types that compress

  • LowCardinality(String) for columns under roughly 10,000 distinct values - it dictionary-encodes them.
  • The smallest integer type that fits; DateTime (not strings) for timestamps.
  • Avoid Nullable unless NULL is semantically required - it adds a mask column to every read.

Step 6: batch inserts

Insert in batches of 10,000-500,000 rows, at most about one insert per second per table. Per-row inserts create a part per insert and merges fall behind ("too many parts" errors are this). If the producer cannot batch, use async_insert or buffer through kafka-pipelines.

Step 7: add rollups only after the base table is right

A materialized view is an insert trigger - it transforms rows as they arrive into a target table; it never backfills history by itself:

CREATE MATERIALIZED VIEW daily_mv
TO daily_agg AS
SELECT event_date, event_type, count() AS cnt, sum(value) AS total
FROM events GROUP BY event_date, event_type;

Combine with an AggregatingMergeTree target and -State / -Merge combinators to maintain rollups incrementally. Use a projection instead when you need an alternative sort order or aggregation inside the same table, chosen automatically per query:

ALTER TABLE events ADD PROJECTION by_user
( SELECT user_id, count() GROUP BY user_id );

Rule of thumb: projection for a second access path on one table; MV when the rollup feeds different consumers, needs its own retention, or joins.

Step 8: verify with the query log

  • EXPLAIN indexes = 1 shows granules selected vs total - the skipping ratio is the number that matters.
  • system.query_log shows parts and rows read per query; a "fast" query reading 90% of the table is a schema problem waiting for data growth.

Query rules

  • Never SELECT *; storage is columnar, every column named is I/O paid.
  • Filter on sorting-key prefixes to enable granule skipping.
  • Use PREWHERE for highly selective filters on small columns - it reads the filter column first and only fetches the rest for surviving rows.
  • Prefer approximate functions (uniq, uniqHLL12, quantile) when exactness is not required; uniqExact on billions of rows is a memory bomb.
  • Put the smaller table on the right side of a JOIN; ClickHouse builds the hash table from the right.

Deliverable

Produce a schema document containing: the DDL (engine, ORDER BY, PARTITION BY, column types) with a one-line justification per choice tied to a named query, the insert batching plan, any MV/projection definitions, and an EXPLAIN indexes = 1 result for the top queries showing granules skipped.

Do NOT

  • Do not front-load a high-cardinality column in ORDER BY - it destroys skipping for every other filter.
  • Do not rely on ReplacingMergeTree for read-time correctness; merges are eventual, so reads must use FINAL or aggregation if duplicates matter.
  • Do not partition by day "for granularity" on multi-year data - partition count, not partition size, is what hurts.
  • Do not use mutations (ALTER ... UPDATE/DELETE) as an operational write path; they rewrite parts and are for rare corrections only.
  • Do not add a projection and an MV for the same rollup; pick one and name why.

Quality bar

Ship only when: every top query filters on an ORDER BY prefix or has a projection that covers it; EXPLAIN indexes = 1 shows meaningful granule skipping (not a full scan) on the top queries; inserts are batched; total partition count stays bounded in the low hundreds for the data's lifetime; and dedup semantics are stated explicitly (exact at read, or eventual and tolerated).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.