Claude Skill

clickhouse-analytics

Use when running a ClickHouse server for high-volume OLAP: choosing a MergeTree engine and ORDER BY/PARTITION BY keys, ingesting billions of event/log/metric rows, pre-aggregating with materialized views, or fixing a query that scans instead of pruning. NOT in-process file analyt

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_clickhouse-analytics-953fef5.zip · 16 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/clickhouse-analytics
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git git clone https://github.com/ericrisco/rsc-harness.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

ClickHouse analytics

ClickHouse is a multi-user, always-on, replicated columnar server built to ingest continuous high-volume writes and answer aggregation queries over billions of rows in milliseconds. You reach for it when the workload is "append a firehose of events/logs/metrics, then GROUP BY them for dashboards." Target 26.3 LTS (v26.3.12.3, 2026-05-22) — several defaults below changed in the 26.x line, so version matters.

The fork before you write any DDL: files on a laptop, in-process, no server, no concurrent writers → ../duckdb/SKILL.md; app CRUD, point updates, foreign keys, row locks, RLS, migrations → ../postgresdb/SKILL.md; clickhouse-server, replication, concurrent writers, 100M+ rows/s ingest → this skill.

Instrumenting capture (GA4/PostHog) is ../analytics/SKILL.md; charting the result for humans is ../dashboard/SKILL.md; deciding which metrics matter is ../kpi-framework/SKILL.md. ClickHouse is the engine underneath all three.

Pick the engine first

The engine decides dedup and merge behavior, and you cannot change ORDER BY/PARTITION BY later without a rebuild — so choose before typing CREATE TABLE.

Engine Use it for Dedup / merge behavior Gotcha
MergeTree Append-only events, logs, metrics No dedup of logical rows; inserts dedup'd by block since 26.2 The default and 90% of tables
ReplacingMergeTree(ver) Upserts / keep latest version per key Collapses duplicate ORDER BY keys eventually during merges Reads see dupes until merged; need FINAL to force — slow, keep off hot path
AggregatingMergeTree Pre-aggregated rollups fed by a materialized view Merges -State partials per ORDER BY key Only useful behind an MV; query with -Merge
SummingMergeTree Simple additive rollups (sum only) Sums numeric columns per ORDER BY key on merge Can't do uniq/quantile — use AggregatingMergeTree for those
Replicated* prefix High availability / multi-replica Same as base engine + ZooKeeper/Keeper replication Production HA wrapper; combine with any of the above

Default to MergeTree. Move to AggregatingMergeTree only when you are pre-aggregating through a materialized view. Full matrix and reasoning: references/schema-and-engines.md.

Schema rules

  1. ORDER BY is your single biggest perf lever — a good one cuts query time ~100x. It defines the sparse primary index that prunes which granules get read. Get this right above everything else.
  2. Order the key low-cardinality → high-cardinality, left to right, driven by WHERE/GROUP BY — never by join keys. 3–5 columns. The leftmost column should be the one you filter on most; cardinality rises as you go right. Timeseries: put the raw timestamp last, often (tenant_id, toStartOfDay(ts), event_type, ts).
  3. Treat ORDER BY and PARTITION BY as immutable. Changing either almost always means a new table + INSERT ... SELECT migration. Decide deliberately now.
  4. Partition coarsely — by month, or by day only at very high volume. Partitioning is for data lifecycle (TTL, DROP PARTITION), not query speed; the sparse index does speed. Per-hour or per-toYYYYMMDD on a high-cardinality stream creates thousands of partitions → too many parts → merge storms.
  5. Right-size types and use codecs. LowCardinality(String) for columns under ~10k distinct values (enum-like: country, event_type, status). Smallest int that fits. CODEC(Delta, ZSTD) for monotonic timestamps/counters; CODEC(ALP) for float columns (26.3, beats Gorilla on many workloads); native JSON type (GA in 26.3) for semi-structured payloads instead of stringly-typed blobs.
CREATE TABLE events
(
    tenant_id    UInt32,
    ts           DateTime64(3) CODEC(Delta, ZSTD),
    event_type   LowCardinality(String),
    user_id      UInt64,
    country      LowCardinality(String),
    revenue      Float64 CODEC(ALP),
    props        JSON
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)             -- monthly: coarse, for TTL/drops
ORDER BY (tenant_id, toStartOfDay(ts), event_type, ts)
TTL toDateTime(ts) + INTERVAL 18 MONTH;

Depth (cardinality math, codec table, type mapping, partition-count budget): references/schema-and-engines.md.

Ingestion rules

-- Bad: row-at-a-time. Each statement becomes its own tiny part.
INSERT INTO events VALUES (1, now(), 'click', 42, 'ES', 0, '{}');
INSERT INTO events VALUES (1, now(), 'view',  42, 'ES', 0, '{}');
-- ... 10k more single inserts -> 10k parts -> merges can't keep up
-- Good: one batch of many rows (aim 10k–100k+ per INSERT).
INSERT INTO events VALUES
  (1, now(), 'click', 42, 'ES', 0, '{}'),
  (1, now(), 'view',  42, 'ES', 0, '{}'),
  /* ...thousands more... */ ;

-- Or load straight from object storage, no client batching at all:
INSERT INTO events
SELECT * FROM s3('https://bucket.s3.amazonaws.com/events/2026/*.parquet', 'Parquet');
  • Async inserts are enabled by default starting 26.3 LTS. The server buffers small inserts in memory and flushes on a size/time threshold, so many client-side batchers become unnecessary. Flush fires on the first threshold hit: async_insert_max_query_number (default 450) or the adaptive busy timeout, between async_insert_busy_timeout_min_ms (default 50ms) and a data-rate-driven max (adaptive since 24.2).
  • Insert deduplication is on by default for all inserts as of 26.2 (previously sync-only), and works end-to-end across async inserts and dependent materialized views since 26.1. Net effect: retrying a failed insert is safe — an identical block won't double-count. Pass insert_deduplication_token when you want explicit control over what counts as identical.
  • Keep inserts synchronous when you must read-your-write immediately, or when you already batch large blocks yourself and want no buffering latency.

S3/Kafka/file recipes, async-insert tuning knobs, dedup tokens: references/ingestion-and-mvs.md.

Materialized views and pre-aggregation

For anything beyond raw sum/count (uniq, quantiles, argMax), pre-aggregate incrementally with AggregatingMergeTree + a materialized view storing -State partials, queried back with -Merge.

CREATE TABLE events_hourly
(
    tenant_id  UInt32,
    hour       DateTime,
    users      AggregateFunction(uniq, UInt64),
    revenue    AggregateFunction(sum, Float64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(hour)
ORDER BY (tenant_id, hour);            -- MV GROUP BY MUST match this

CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT tenant_id,
       toStartOfHour(ts) AS hour,
       uniqState(user_id) AS users,
       sumState(revenue)  AS revenue
FROM events
GROUP BY tenant_id, hour;              -- no POPULATE on a big base table
-- Read it back: -Merge collapses the partial states.
SELECT tenant_id, hour, uniqMerge(users) AS uniq_users, sumMerge(revenue) AS rev
FROM events_hourly
GROUP BY tenant_id, hour;
  • The MV's GROUP BY must match the target table's ORDER BY so merges stay efficient.
  • Never POPULATE a billion-row base table — it blocks the MV and can OOM. Create the MV empty (it captures new rows immediately), then backfill history in time-bounded INSERT ... SELECT windows. Full backfill walkthrough: references/ingestion-and-mvs.md.

Query optimization

The sparse index only prunes on ORDER BY prefix columns. When a hot query filters on a column the primary key doesn't cover, in order of reach for:

  1. PREWHERE — ClickHouse auto-applies it, but an explicit PREWHERE on a cheap, highly selective column reads that column first and skips other columns for non-matching rows. Cuts I/O.
  2. Projection — an alternate ORDER BY/pre-aggregation stored with the table; ClickHouse picks it transparently. Best when one secondary access pattern is common and worth the storage.
  3. Data-skipping index — minmax (correlated-with-PK ranges), set (low distinct count), bloom_filter (high-cardinality equality/IN). Cheaper than a projection, coarser pruning.

Decision: PK can't prune and you query one alternate sort order a lot → projection. You just need to skip granules on a side column → skip index (bloom_filter for high-cardinality =/IN, minmax for ranges). Inspect with EXPLAIN indexes = 1 and SET send_logs_level = 'trace' to see granules read. Walkthrough + slow-query recipes: references/query-optimization.md.

SELECT event_type, count() FROM events
PREWHERE country = 'ES'                 -- cheap, selective: filter before reading the rest
WHERE ts >= now() - INTERVAL 7 DAY
GROUP BY event_type;

Operations

  • Watch part count. SELECT table, count() FROM system.parts WHERE active GROUP BY table — a growing number means inserts are too small/frequent or partitioning is too fine. Fix the insert pattern, not the merge settings.
  • Retention via TTL, not DELETE. TTL on the table drops expired data during merges automatically.
  • ALTER TABLE ... DROP PARTITION is instant and free; row-level DELETE/ALTER DELETE is a mutation that rewrites parts — avoid it for bulk cleanup. This is the payoff of coarse partitioning.
  • ReplacingMergeTree reads can see un-merged duplicates. Use FINAL only on cold/admin queries, never in dashboards — it merges at query time.

Anti-patterns

Anti-pattern Why it hurts Do instead
MergeTree with no ORDER BY (or ORDER BY tuple()) on a queried table No sparse index → every query full-scans Pick a 3–5 col key, low→high cardinality, WHERE-driven
PARTITION BY a high-cardinality col / per-hour / per-day at low volume Thousands of partitions → too many parts → merge storms Partition by toYYYYMM; the sparse index does the speed
Single-row INSERT ... VALUES in a loop Each becomes a tiny part; merges can't keep up Batch 10k–100k+ rows, or rely on 26.3 async inserts
POPULATE on a billion-row base table's MV Blocks the MV, can OOM Create MV empty, backfill in time windows
SELECT * on a wide table Reads every column, defeats columnar storage Select only the columns you need
FINAL in a dashboard query Forces merge at query time → slow Keep FINAL off hot paths; accept eventual dedup
ClickHouse for OLTP point-updates / single-row reads by id Wrong engine; no real updates, weak point lookups Use ../postgresdb/SKILL.md
MV GROUP BY not matching target ORDER BY Inefficient merges, wrong rollups Align them exactly

Verification

scripts/verify.sh <file.sql> is a static linter over candidate ClickHouse DDL/queries: flags MergeTree without ORDER BY, over-fine PARTITION BY, single-row INSERT ... VALUES, POPULATE on materialized views, SELECT *, and FINAL. Read-only, no live cluster needed, exits 0 on clean input.

Files (rsc-harness)
  • evals
    • cases.yaml 4 KB
      skill: clickhouse-analytics
      
      should_trigger:
        - prompt: "We're ingesting 2B clickstream events a day into ClickHouse — what ENGINE, ORDER BY and PARTITION BY should the table use?"
          why: Core schema-design request at scale; engine + sort-key + partition choice is the heart of this skill.
        - prompt: "Our ClickHouse dashboard queries scan the whole table, the primary key isn't pruning anything."
          why: Non-obvious — points at sparse-index/ORDER-BY-prefix pruning, not generic SQL tuning.
        - prompt: "After our hourly ingest job, parts keep piling up and merges can't keep up."
          why: Non-obvious — too-many-parts from tiny/over-partitioned inserts; the merge-pressure symptom maps to ingestion + partition rules here.
        - prompt: "Build a materialized view that keeps a rolling uniq-users-per-hour aggregate as events stream in."
          why: AggregatingMergeTree + -State/-Merge incremental pre-aggregation, the MV section of this skill.
        - prompt: "Montar ClickHouse para analítica de eventos a gran escala con ingesta continua y consultas sub-segundo."
          why: Spanish trigger for standing up a high-volume ClickHouse analytics server.
        - prompt: "Should the upserted order rows table be ReplacingMergeTree or AggregatingMergeTree?"
          why: MergeTree-family engine choice with the FINAL/dedup tradeoff — exactly the engine decision table.
        - prompt: "Consultes lentes a ClickHouse, l'index no poda particions — com l'optimitzo?"
          why: Catalan trigger for query optimization / sparse-index pruning on a ClickHouse server.
      
      should_not_trigger:
        - prompt: "Query a folder of Parquet files on my laptop with SQL, no server to run."
          route_to: duckdb
          why: In-process, single-machine, file-based analytics with no server — DuckDB's space, not a ClickHouse cluster.
        - prompt: "Design the orders table with foreign keys and add an index to fix this slow OLTP point-lookup query."
          route_to: postgresdb
          why: Transactional CRUD schema, FKs, row-level point reads/updates — an OLTP relational job, not columnar OLAP.
        - prompt: "Install PostHog and instrument signup and checkout events in our web app."
          route_to: analytics
          why: Event-capture instrumentation in the app, not designing the columnar store the events land in.
        - prompt: "Lay out an executive KPI dashboard so the numbers fit on one screen."
          route_to: dashboard
          why: Visual layout/charting of metrics for humans, not the engine querying them.
        - prompt: "Decide which five metrics the business should track this quarter and set their targets."
          route_to: kpi-framework
          why: Defining which metrics matter and their targets is strategy, not ClickHouse schema/query work.
      
      capability:
        - scenario: "A team has a high-volume web-events table feeding a dashboard whose GROUP BY query over the last 7 days is now too slow. They ask how to model the table, ingest the firehose, and serve sub-second aggregates on ClickHouse 26.3 LTS."
          must_include:
            - Picks a MergeTree-family engine and justifies it (MergeTree for append-only events; AggregatingMergeTree only behind an MV).
            - Proposes an ORDER BY ordered low-cardinality to high-cardinality, driven by WHERE/GROUP BY (not join keys), with the raw timestamp last; 3-5 columns.
            - Chooses a coarse PARTITION BY (toYYYYMM monthly, or daily only at extreme volume) and explains partitioning is for data lifecycle, not query speed.
            - Gives batched/async ingestion guidance — batches of 10k-100k+ rows or async inserts default-on since 26.3, and notes insert dedup default-on since 26.2 makes retries safe.
            - Builds an AggregatingMergeTree materialized view with uniqState/sumState and -Merge readback, GROUP BY matching the target ORDER BY, and explicitly avoids POPULATE on the big base table (backfill in bounded windows).
            - Adds a query-side accelerator (PREWHERE, projection, or a bloom_filter/minmax skip index) for a filter the ORDER BY prefix doesn't cover.
            - Names at least one anti-pattern it is avoiding (e.g. single-row INSERTs, per-hour partitioning, SELECT *, or FINAL in the hot path).
      
    • README.md 1019 B
      # Evals — clickhouse-analytics
      
      These cases are prompts fed to the skill router. `should_trigger` entries expect this skill (`clickhouse-analytics`) to be selected; `should_not_trigger` entries each name a real sibling in `route_to` that should win instead (duckdb for file/in-process analytics, postgresdb for OLTP, analytics for event capture, dashboard for layout, kpi-framework for metric strategy). The single `capability` case is graded by a judge against its `must_include` rubric — a good answer designs a MergeTree-family table with a justified low→high-cardinality ORDER BY and coarse PARTITION BY, gives 26.x-correct batched/async ingestion with safe-retry dedup, builds an AggregatingMergeTree MV (no POPULATE) and a query-side accelerator, and names an anti-pattern avoided. Run them through the repo's eval harness the same way as other skills under `skills/*/evals/cases.yaml`; there is no live ClickHouse cluster involved — routing is judged on the prompt and capability on the produced answer.
      
  • references
    • ingestion-and-mvs.md 4.7 KB
      # Ingestion and materialized views
      
      Target version: 26.3 LTS. The 26.x line changed several insert defaults — note them per item below.
      
      ## Batch sizing
      
      ClickHouse turns every `INSERT` into at least one part on disk. A part is a sorted, compressed unit that the background merge scheduler later combines. Too many small parts = the scheduler falls behind = "too many parts" errors and slow queries.
      
      - Aim for **10k–100k+ rows per `INSERT`** when you batch client-side.
      - Or rely on **async inserts** (default-on since 26.3) to batch server-side.
      - Never one row per statement in a loop.
      
      ## Async inserts (default-on since 26.3 LTS)
      
      The server holds incoming small inserts in an in-memory buffer and flushes them as one part when the first threshold is reached:
      
      | Setting | Default | Meaning |
      |---|---|---|
      | `async_insert` | `1` (since 26.3) | Buffer inserts server-side |
      | `async_insert_max_query_number` | `450` | Flush after this many buffered queries |
      | `async_insert_busy_timeout_min_ms` | `50` | Lower bound of the adaptive flush timer |
      | `async_insert_busy_timeout_max_ms` | data-rate driven | Upper bound; adaptive since 24.2 |
      | `wait_for_async_insert` | `1` | Client waits for the flush to confirm durability |
      
      The busy timeout is **adaptive**: at high data rates it shortens toward the min, at low rates it lengthens toward the max, balancing latency vs part count automatically.
      
      Keep inserts **synchronous** (`async_insert=0`) when you need read-your-write consistency immediately, or when you already send large well-sized blocks and want zero buffering latency.
      
      ## Deduplication (default-on for all inserts since 26.2)
      
      - Before 26.2, insert dedup was sync-only. **As of 26.2 it applies uniformly to sync and async inserts.**
      - Since 26.1 dedup works **end-to-end across async inserts and dependent materialized views**.
      - Net effect: an identical re-sent block is dropped, so **retrying a failed insert is safe** — no double counting.
      - Control identity explicitly with `insert_deduplication_token`: same token = same logical insert, so you can dedup across differing row content or split a logical batch.
      
      ```sql
      INSERT INTO events SETTINGS insert_deduplication_token = 'batch-2026-06-02-0007'
      SELECT * FROM s3('https://bucket/events/2026-06-02/*.parquet', 'Parquet');
      ```
      
      ## Loading from external sources
      
      ```sql
      -- S3 (Parquet / JSONEachRow / CSV auto-detected by extension or explicit format)
      INSERT INTO events
      SELECT * FROM s3('https://bucket.s3.amazonaws.com/events/2026/*.parquet', 'Parquet');
      
      -- Local/served files via file() or the clickhouse-client --query with FORMAT
      INSERT INTO events FROM INFILE 'events.csv.gz' FORMAT CSV;
      ```
      
      ```sql
      -- Kafka: a Kafka engine table + an MV that drains it into the MergeTree table.
      CREATE TABLE events_queue (raw String)
      ENGINE = Kafka
      SETTINGS kafka_broker_list = 'kafka:9092',
               kafka_topic_list  = 'events',
               kafka_group_name  = 'ch-ingest',
               kafka_format      = 'JSONEachRow';
      
      CREATE MATERIALIZED VIEW events_queue_mv TO events AS
      SELECT * FROM events_queue;          -- the Kafka engine itself never stores rows
      ```
      
      ## AggregatingMergeTree materialized view — end to end
      
      Goal: a rolling **uniq users and revenue per tenant per hour** that stays current as events stream in.
      
      ```sql
      -- 1. Target table storing partial aggregate STATES.
      CREATE TABLE events_hourly
      (
          tenant_id  UInt32,
          hour       DateTime,
          users      AggregateFunction(uniq, UInt64),
          revenue    AggregateFunction(sum, Float64)
      )
      ENGINE = AggregatingMergeTree
      PARTITION BY toYYYYMM(hour)
      ORDER BY (tenant_id, hour);
      
      -- 2. MV that writes states on every new insert into events.
      --    GROUP BY MUST equal the target ORDER BY. No POPULATE.
      CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
      SELECT tenant_id,
             toStartOfHour(ts)  AS hour,
             uniqState(user_id) AS users,
             sumState(revenue)  AS revenue
      FROM events
      GROUP BY tenant_id, hour;
      
      -- 3. Backfill history WITHOUT POPULATE: bounded windows so it never OOMs.
      INSERT INTO events_hourly
      SELECT tenant_id, toStartOfHour(ts), uniqState(user_id), sumState(revenue)
      FROM events
      WHERE ts >= '2026-01-01' AND ts < '2026-02-01'   -- one month at a time
      GROUP BY tenant_id, hour;
      -- repeat per month; dedup keeps reruns safe.
      
      -- 4. Read with -Merge to collapse states into final values.
      SELECT tenant_id, hour,
             uniqMerge(users)  AS uniq_users,
             sumMerge(revenue) AS rev
      FROM events_hourly
      GROUP BY tenant_id, hour
      ORDER BY tenant_id, hour;
      ```
      
      Why no `POPULATE`: it scans the entire base table in one shot, blocks the MV from capturing concurrent inserts during that scan, and can exhaust memory on a billion-row table. Creating the MV empty captures new rows from the moment of creation; the bounded backfill fills the past safely.
      
    • query-optimization.md 3.9 KB
      # Query optimization
      
      ## How the sparse index prunes
      
      ClickHouse reads data in **granules** (8192 rows by default). It keeps one primary-index mark per granule — the `ORDER BY` column values at the granule boundary. A query whose `WHERE` constrains a **prefix of the `ORDER BY`** lets the engine binary-search the marks and read only matching granules. Everything else is skipped without touching disk.
      
      Consequence: a filter on a column **not** in the `ORDER BY` prefix prunes nothing — the engine scans every granule. That is the usual cause of "the primary key isn't pruning." The fixes below all add a *secondary* way to prune.
      
      Inspect what actually happened:
      
      ```sql
      EXPLAIN indexes = 1
      SELECT count() FROM events WHERE country = 'ES' AND ts >= now() - INTERVAL 7 DAY;
      -- shows which indexes were used and granules selected vs total
      
      -- or, run the query with trace logging to see granules/rows read:
      SET send_logs_level = 'trace';
      ```
      
      ## PREWHERE
      
      `PREWHERE` reads its columns *first*, evaluates the predicate, and only then reads the remaining columns for surviving rows. ClickHouse auto-moves predicates into `PREWHERE`, but an explicit `PREWHERE` on a **cheap, highly selective** column forces the order you want and cuts I/O on wide tables.
      
      ```sql
      SELECT event_type, count() FROM events
      PREWHERE country = 'ES'                 -- 1 narrow column read first, filters hard
      WHERE ts >= now() - INTERVAL 7 DAY
      GROUP BY event_type;
      ```
      
      Use it when one filter column is small and eliminates most rows before the expensive columns are touched.
      
      ## Projections
      
      A projection is a second physical copy of the table data with a different `ORDER BY` and/or pre-aggregation, stored alongside the table. The optimizer picks it transparently when it serves the query better.
      
      ```sql
      ALTER TABLE events ADD PROJECTION by_country
      ( SELECT * ORDER BY (country, ts) );
      ALTER TABLE events MATERIALIZE PROJECTION by_country;   -- builds it for existing data
      ```
      
      Cost: extra storage and insert work (every insert maintains the projection). Worth it when **one** secondary access pattern is frequent and latency-critical.
      
      ## Data-skipping indexes
      
      Cheaper and coarser than projections — they store summaries per block of granules and skip blocks that can't match.
      
      | Index | Built for | Example |
      |---|---|---|
      | `minmax` | Columns correlated with the sort order (ranges) | `INDEX idx_amt revenue TYPE minmax GRANULARITY 4` |
      | `set(N)` | Low distinct count per block | `INDEX idx_et event_type TYPE set(100) GRANULARITY 4` |
      | `bloom_filter` | High-cardinality equality / `IN` | `INDEX idx_uid user_id TYPE bloom_filter GRANULARITY 4` |
      
      ```sql
      ALTER TABLE events ADD INDEX idx_uid user_id TYPE bloom_filter GRANULARITY 4;
      ALTER TABLE events MATERIALIZE INDEX idx_uid;   -- backfill for existing parts
      ```
      
      ## Which accelerator, when
      
      - Query filters a column the PK prefix doesn't cover, **point/`IN` lookups**, high cardinality → `bloom_filter` skip index.
      - Query filters a **range** on a side column loosely correlated with the sort → `minmax` skip index.
      - You repeatedly query in a **whole different sort order** (and storage is acceptable) → projection.
      - You repeatedly compute the **same aggregate** at read time → pre-aggregate with an `AggregatingMergeTree` MV instead (see `ingestion-and-mvs.md`).
      - A cheap, selective filter column on a wide table → explicit `PREWHERE`.
      
      ## Common slow-query fixes
      
      | Symptom | Likely cause | Fix |
      |---|---|---|
      | Full scan despite a `WHERE` | Filter not on `ORDER BY` prefix | Skip index / projection, or revisit the key |
      | Slow `SELECT *` dashboards | Reading every column | Select only needed columns |
      | `ReplacingMergeTree` query slow | `FINAL` merging at query time | Drop `FINAL` off hot path; dedup in the MV layer |
      | Query fast cold, slow under load | Too many parts | Fix insert batching/partitioning (see schema ref) |
      | Memory blows up on GROUP BY | Huge cardinality grouping | Pre-aggregate via MV; or `max_bytes_before_external_group_by` |
      
    • schema-and-engines.md 4.6 KB
      # Schema and engines (deep dive)
      
      ## Engine matrix
      
      | Engine | Keeps | Merge action | Query pattern | When |
      |---|---|---|---|---|
      | `MergeTree` | Every inserted row | Just sorts/merges parts | Direct `SELECT ... GROUP BY` | Append-only events, logs, metrics — the default |
      | `ReplacingMergeTree([ver][, is_deleted])` | Latest row per `ORDER BY` key | Drops older versions on merge | `SELECT ... FINAL` or app-side latest | Upserts, CDC sink, "current state per id" |
      | `AggregatingMergeTree` | Partial aggregate states per key | Combines `-State` partials | `SELECT ...Merge(col)` behind an MV | Pre-aggregated uniq/quantile/argMax rollups |
      | `SummingMergeTree([cols])` | Summed numerics per key | Adds numeric columns on merge | Direct `SELECT sum-already-done` | Simple additive counters only |
      | `CollapsingMergeTree(sign)` | Rows with +1/-1 sign | Cancels +1/-1 pairs | Sum the sign column | Mutable rows with a known prior state |
      | `Replicated<X>` | Same as `<X>` | Same + cross-replica sync via Keeper | Same | Any of the above, in production HA |
      
      Rule of thumb: start at `MergeTree`. Add `AggregatingMergeTree` only behind a materialized view. Reach for `ReplacingMergeTree` only when you genuinely have upserts and can tolerate eventual dedup. Use the `Replicated` prefix in any clustered deployment.
      
      ## ORDER BY — the reasoning
      
      The `ORDER BY` defines the **sparse primary index**: ClickHouse stores one index mark per granule (8192 rows by default), so the index is tiny and lives in memory. A query whose `WHERE` touches a *prefix* of the `ORDER BY` can skip whole granules.
      
      Construction:
      
      1. List the columns you filter and group by most. Ignore join keys — they don't help pruning.
      2. Sort that list **low cardinality first, high cardinality last**. Low-cardinality leading columns produce long runs of equal values, which compress hard and let the index prune large contiguous ranges.
      3. Keep it to **3–5 columns**. Extra columns past the useful prefix only cost insert-time sorting.
      4. For timeseries, bucket the timestamp early and keep the raw timestamp last: `(tenant_id, toStartOfDay(ts), event_type, ts)`. The bucket prunes by day; the trailing raw `ts` orders within a granule for range scans.
      
      A good key vs a bad key on the same data is routinely a ~100x query-time difference. This is the highest-leverage decision in the whole schema, and it is effectively immutable — changing it means a new table and an `INSERT ... SELECT` migration.
      
      `PRIMARY KEY` may be a prefix of `ORDER BY` when you want a smaller index than the sort order (e.g. sort by `(a, b, c)` but index only `(a, b)`).
      
      ## PARTITION BY — sizing
      
      Partitioning is for **data management**, not speed:
      
      - TTL drops expired partitions during merges.
      - `ALTER TABLE ... DROP PARTITION` removes a chunk instantly.
      - Backfills and detaches operate per partition.
      
      Budget: aim for **tens to low hundreds of active partitions**, not thousands. Each partition holds its own parts; merges never cross partitions, so over-partitioning multiplies tiny parts and starves the merge scheduler.
      
      | Volume | Partition by |
      |---|---|
      | < ~100M rows/month | `toYYYYMM(ts)` (monthly) |
      | Billions/month, time-bounded retention | `toYYYYMMDD(ts)` (daily) — only at this scale |
      | Anything | never a raw high-cardinality column, never per-hour |
      
      ## Types and codecs
      
      | Column shape | Type | Codec | Why |
      |---|---|---|---|
      | Enum-like string, < ~10k distinct | `LowCardinality(String)` | (built-in dict) | Dictionary-encodes; faster GROUP BY, smaller |
      | Free-form string, high distinct | `String` | `ZSTD` | LowCardinality hurts above ~10k distinct |
      | Monotonic timestamp / counter | `DateTime64(3)` / `UInt*` | `CODEC(Delta, ZSTD)` | Delta makes deltas tiny, ZSTD packs them |
      | Float metric | `Float64` | `CODEC(ALP)` | ALP (26.3) beats Gorilla on many float series |
      | Semi-structured payload | `JSON` (native, GA 26.3) | — | Real typed paths, not a `String` blob |
      | Bounded fixed set | `Enum8` / `Enum16` | — | Stored as int, validated on insert |
      | Integer | smallest that fits (`UInt8`…`UInt64`) | — | Narrower = less I/O |
      
      `LowCardinality` threshold: under ~10k distinct values it wins; above that the dictionary overhead can cost more than it saves — fall back to plain `String` + `ZSTD`. Measure with `SELECT uniqExact(col) FROM table` on a sample.
      
      ## Migrating an ORDER BY / PARTITION BY
      
      Because both are immutable, the migration is always: create a new table with the corrected key, `INSERT INTO new SELECT * FROM old` (ideally partition-by-partition to bound memory), verify counts, then `EXCHANGE TABLES new AND old` (atomic) and drop the old one.
      
  • scripts
    • verify.sh 4 KB
      #!/usr/bin/env bash
      # verify.sh — static linter for ClickHouse DDL/queries emitted by the
      # clickhouse-analytics skill. Read-only: it greps candidate .sql text for
      # anti-patterns from SKILL.md. It does NOT connect to any cluster.
      #
      # Usage:
      #   scripts/verify.sh [PATH ...]      # lint given .sql files
      #   scripts/verify.sh                 # lint *.sql under the current dir
      #   cat x.sql | scripts/verify.sh -   # lint stdin
      #
      # Exit: 0 when nothing flagged (also on empty/no input — no false failure).
      #       1 when at least one anti-pattern line is found.
      
      set -u
      
      findings=0
      
      # Emit a finding line and bump the counter.
      flag() { # file line_no message line_text
        printf '%s:%s: %s\n    %s\n' "$1" "$2" "$3" "$(printf '%s' "$4" | sed 's/^[[:space:]]*//')"
        findings=$((findings + 1))
      }
      
      # Lint one already-collected buffer. Args: label, full-text-in-$2.
      lint_buffer() {
        local label="$1" text="$2"
        [ -z "${text//[[:space:]]/}" ] && return 0   # empty buffer: nothing to flag
      
        local lineno=0 line lower
        while IFS= read -r line || [ -n "$line" ]; do
          lineno=$((lineno + 1))
          # Strip trailing line comments so they don't trip the matchers.
          local code="${line%%--*}"
          lower="$(printf '%s' "$code" | tr '[:upper:]' '[:lower:]')"
          [ -z "${lower//[[:space:]]/}" ] && continue
      
          # 1. Over-fine partitioning: per-day / per-hour buckets.
          case "$lower" in
            *partition\ by*toyyyymmdd*|*partition\ by*tostartofhour*|*partition\ by*tostartofminute*|*partition\ by*todate\(*)
              flag "$label" "$lineno" "fine PARTITION BY (per-day/hour) — partition coarsely (toYYYYMM); the sparse index does the speed" "$line" ;;
          esac
      
          # 2. Single-row INSERT ... VALUES (...) on one line — tiny parts.
          case "$lower" in
            *insert\ into*values*\(*\)*)
              flag "$label" "$lineno" "single-row INSERT ... VALUES — batch 10k-100k+ rows or rely on async inserts" "$line" ;;
          esac
      
          # 3. POPULATE on a materialized view — blocks/OOM on big tables.
          case "$lower" in
            *materialized\ view*populate*|*populate*as\ select*)
              flag "$label" "$lineno" "POPULATE on a materialized view — create it empty and backfill in bounded windows" "$line" ;;
          esac
      
          # 4. SELECT * — defeats columnar storage on wide tables.
          case "$lower" in
            *select\ \**) flag "$label" "$lineno" "SELECT * — name only the columns you need (columnar store reads per-column)" "$line" ;;
          esac
      
          # 5. FINAL — merges at query time, keep off hot paths.
          case "$lower" in
            *\ final\ *|*\ final\;*|*\ final|*\)final*)
              flag "$label" "$lineno" "FINAL — merges at query time; keep it off dashboard/hot-path queries" "$line" ;;
          esac
        done <<EOF
      $text
      EOF
      
        # 6. MergeTree-family ENGINE with no ORDER BY anywhere in the statement set.
        #    Whole-buffer check because ORDER BY may sit lines below ENGINE.
        if printf '%s' "$text" | grep -iqE 'engine[[:space:]]*=[[:space:]]*[a-z]*mergetree'; then
          if ! printf '%s' "$text" | grep -iqE 'order[[:space:]]+by[[:space:]]+[^t]'; then
            flag "$label" "0" "MergeTree-family ENGINE with no usable ORDER BY — define a 3-5 col sort key (ORDER BY tuple() leaves no sparse index)" "ENGINE = *MergeTree"
          fi
        fi
      }
      
      # Collect input sources.
      inputs=()
      if [ "$#" -eq 0 ]; then
        while IFS= read -r f; do inputs+=("$f"); done < <(find . -type f -name '*.sql' 2>/dev/null)
      else
        for arg in "$@"; do inputs+=("$arg"); done
      fi
      
      # Nothing to lint at all: clean exit, no false failure.
      if [ "${#inputs[@]}" -eq 0 ]; then
        echo "clickhouse-analytics verify: no .sql input found — nothing to check."
        exit 0
      fi
      
      for src in "${inputs[@]}"; do
        if [ "$src" = "-" ]; then
          lint_buffer "(stdin)" "$(cat)"
        elif [ -f "$src" ]; then
          lint_buffer "$src" "$(cat "$src")"
        else
          echo "clickhouse-analytics verify: skip (not a file): $src" >&2
        fi
      done
      
      if [ "$findings" -gt 0 ]; then
        echo "---"
        echo "clickhouse-analytics verify: $findings anti-pattern line(s) flagged. See SKILL.md."
        exit 1
      fi
      
      echo "clickhouse-analytics verify: clean — no anti-patterns found."
      exit 0
      
  • SKILL.md 11.3 KB
    ---
    name: clickhouse-analytics
    description: "Use when running a ClickHouse server for high-volume OLAP: choosing a MergeTree engine and ORDER BY/PARTITION BY keys, ingesting billions of event/log/metric rows, pre-aggregating with materialized views, or fixing a query that scans instead of pruning. NOT in-process file analytics (that is `duckdb`), NOT OLTP CRUD indexing (that is `postgresdb`)."
    tags: [clickhouse, olap, columnar, mergetree, analytics, materialized-views, data-ingestion, sql]
    recommends: [duckdb, postgresdb, dashboard, kpi-framework, reporting, business-intelligence]
    origin: risco
    ---
    
    # ClickHouse analytics
    
    ClickHouse is a multi-user, always-on, replicated columnar server built to ingest continuous high-volume writes and answer aggregation queries over billions of rows in milliseconds. You reach for it when the workload is "append a firehose of events/logs/metrics, then GROUP BY them for dashboards." Target **26.3 LTS** (v26.3.12.3, 2026-05-22) — several defaults below changed in the 26.x line, so version matters.
    
    The fork before you write any DDL: files on a laptop, in-process, no server, no concurrent writers → `../duckdb/SKILL.md`; app CRUD, point updates, foreign keys, row locks, RLS, migrations → `../postgresdb/SKILL.md`; `clickhouse-server`, replication, concurrent writers, 100M+ rows/s ingest → this skill.
    
    Instrumenting capture (GA4/PostHog) is `../analytics/SKILL.md`; charting the result for humans is `../dashboard/SKILL.md`; deciding which metrics matter is `../kpi-framework/SKILL.md`. ClickHouse is the engine underneath all three.
    
    ## Pick the engine first
    
    The engine decides dedup and merge behavior, and you cannot change `ORDER BY`/`PARTITION BY` later without a rebuild — so choose before typing `CREATE TABLE`.
    
    | Engine | Use it for | Dedup / merge behavior | Gotcha |
    |---|---|---|---|
    | `MergeTree` | Append-only events, logs, metrics | No dedup of logical rows; inserts dedup'd by block since 26.2 | The default and 90% of tables |
    | `ReplacingMergeTree(ver)` | Upserts / keep latest version per key | Collapses duplicate `ORDER BY` keys *eventually* during merges | Reads see dupes until merged; need `FINAL` to force — slow, keep off hot path |
    | `AggregatingMergeTree` | Pre-aggregated rollups fed by a materialized view | Merges `-State` partials per `ORDER BY` key | Only useful behind an MV; query with `-Merge` |
    | `SummingMergeTree` | Simple additive rollups (sum only) | Sums numeric columns per `ORDER BY` key on merge | Can't do uniq/quantile — use AggregatingMergeTree for those |
    | `Replicated*` prefix | High availability / multi-replica | Same as base engine + ZooKeeper/Keeper replication | Production HA wrapper; combine with any of the above |
    
    Default to `MergeTree`. Move to `AggregatingMergeTree` only when you are pre-aggregating through a materialized view. Full matrix and reasoning: `references/schema-and-engines.md`.
    
    ## Schema rules
    
    1. **`ORDER BY` is your single biggest perf lever — a good one cuts query time ~100x.** It defines the sparse primary index that prunes which granules get read. Get this right above everything else.
    2. **Order the key low-cardinality → high-cardinality, left to right, driven by `WHERE`/`GROUP BY` — never by join keys.** 3–5 columns. The leftmost column should be the one you filter on most; cardinality rises as you go right. Timeseries: put the raw timestamp last, often `(tenant_id, toStartOfDay(ts), event_type, ts)`.
    3. **Treat `ORDER BY` and `PARTITION BY` as immutable.** Changing either almost always means a new table + `INSERT ... SELECT` migration. Decide deliberately now.
    4. **Partition coarsely — by month, or by day only at very high volume.** Partitioning is for *data lifecycle* (TTL, `DROP PARTITION`), not query speed; the sparse index does speed. Per-hour or per-`toYYYYMMDD` on a high-cardinality stream creates thousands of partitions → too many parts → merge storms.
    5. **Right-size types and use codecs.** `LowCardinality(String)` for columns under ~10k distinct values (enum-like: country, event_type, status). Smallest int that fits. `CODEC(Delta, ZSTD)` for monotonic timestamps/counters; `CODEC(ALP)` for float columns (26.3, beats Gorilla on many workloads); native `JSON` type (GA in 26.3) for semi-structured payloads instead of stringly-typed blobs.
    
    ```sql
    CREATE TABLE events
    (
        tenant_id    UInt32,
        ts           DateTime64(3) CODEC(Delta, ZSTD),
        event_type   LowCardinality(String),
        user_id      UInt64,
        country      LowCardinality(String),
        revenue      Float64 CODEC(ALP),
        props        JSON
    )
    ENGINE = MergeTree
    PARTITION BY toYYYYMM(ts)             -- monthly: coarse, for TTL/drops
    ORDER BY (tenant_id, toStartOfDay(ts), event_type, ts)
    TTL toDateTime(ts) + INTERVAL 18 MONTH;
    ```
    
    Depth (cardinality math, codec table, type mapping, partition-count budget): `references/schema-and-engines.md`.
    
    ## Ingestion rules
    
    ```sql
    -- Bad: row-at-a-time. Each statement becomes its own tiny part.
    INSERT INTO events VALUES (1, now(), 'click', 42, 'ES', 0, '{}');
    INSERT INTO events VALUES (1, now(), 'view',  42, 'ES', 0, '{}');
    -- ... 10k more single inserts -> 10k parts -> merges can't keep up
    ```
    
    ```sql
    -- Good: one batch of many rows (aim 10k–100k+ per INSERT).
    INSERT INTO events VALUES
      (1, now(), 'click', 42, 'ES', 0, '{}'),
      (1, now(), 'view',  42, 'ES', 0, '{}'),
      /* ...thousands more... */ ;
    
    -- Or load straight from object storage, no client batching at all:
    INSERT INTO events
    SELECT * FROM s3('https://bucket.s3.amazonaws.com/events/2026/*.parquet', 'Parquet');
    ```
    
    - **Async inserts are enabled by default starting 26.3 LTS.** The server buffers small inserts in memory and flushes on a size/time threshold, so many client-side batchers become unnecessary. Flush fires on the *first* threshold hit: `async_insert_max_query_number` (default 450) or the adaptive busy timeout, between `async_insert_busy_timeout_min_ms` (default 50ms) and a data-rate-driven max (adaptive since 24.2).
    - **Insert deduplication is on by default for all inserts as of 26.2** (previously sync-only), and works end-to-end across async inserts and dependent materialized views since 26.1. **Net effect: retrying a failed insert is safe** — an identical block won't double-count. Pass `insert_deduplication_token` when you want explicit control over what counts as identical.
    - **Keep inserts synchronous** when you must read-your-write immediately, or when you already batch large blocks yourself and want no buffering latency.
    
    S3/Kafka/file recipes, async-insert tuning knobs, dedup tokens: `references/ingestion-and-mvs.md`.
    
    ## Materialized views and pre-aggregation
    
    For anything beyond raw sum/count (uniq, quantiles, argMax), pre-aggregate incrementally with `AggregatingMergeTree` + a materialized view storing `-State` partials, queried back with `-Merge`.
    
    ```sql
    CREATE TABLE events_hourly
    (
        tenant_id  UInt32,
        hour       DateTime,
        users      AggregateFunction(uniq, UInt64),
        revenue    AggregateFunction(sum, Float64)
    )
    ENGINE = AggregatingMergeTree
    PARTITION BY toYYYYMM(hour)
    ORDER BY (tenant_id, hour);            -- MV GROUP BY MUST match this
    
    CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
    SELECT tenant_id,
           toStartOfHour(ts) AS hour,
           uniqState(user_id) AS users,
           sumState(revenue)  AS revenue
    FROM events
    GROUP BY tenant_id, hour;              -- no POPULATE on a big base table
    ```
    
    ```sql
    -- Read it back: -Merge collapses the partial states.
    SELECT tenant_id, hour, uniqMerge(users) AS uniq_users, sumMerge(revenue) AS rev
    FROM events_hourly
    GROUP BY tenant_id, hour;
    ```
    
    - **The MV's `GROUP BY` must match the target table's `ORDER BY`** so merges stay efficient.
    - **Never `POPULATE` a billion-row base table** — it blocks the MV and can OOM. Create the MV empty (it captures new rows immediately), then backfill history in time-bounded `INSERT ... SELECT` windows. Full backfill walkthrough: `references/ingestion-and-mvs.md`.
    
    ## Query optimization
    
    The sparse index only prunes on `ORDER BY` prefix columns. When a hot query filters on a column the primary key doesn't cover, in order of reach for:
    
    1. **`PREWHERE`** — ClickHouse auto-applies it, but an explicit `PREWHERE` on a cheap, highly selective column reads that column first and skips other columns for non-matching rows. Cuts I/O.
    2. **Projection** — an alternate `ORDER BY`/pre-aggregation stored with the table; ClickHouse picks it transparently. Best when one secondary access pattern is common and worth the storage.
    3. **Data-skipping index** — `minmax` (correlated-with-PK ranges), `set` (low distinct count), `bloom_filter` (high-cardinality equality/`IN`). Cheaper than a projection, coarser pruning.
    
    Decision: PK can't prune and you query *one* alternate sort order a lot → projection. You just need to skip granules on a side column → skip index (`bloom_filter` for high-cardinality `=`/`IN`, `minmax` for ranges). Inspect with `EXPLAIN indexes = 1` and `SET send_logs_level = 'trace'` to see granules read. Walkthrough + slow-query recipes: `references/query-optimization.md`.
    
    ```sql
    SELECT event_type, count() FROM events
    PREWHERE country = 'ES'                 -- cheap, selective: filter before reading the rest
    WHERE ts >= now() - INTERVAL 7 DAY
    GROUP BY event_type;
    ```
    
    ## Operations
    
    - **Watch part count.** `SELECT table, count() FROM system.parts WHERE active GROUP BY table` — a growing number means inserts are too small/frequent or partitioning is too fine. Fix the insert pattern, not the merge settings.
    - **Retention via TTL**, not `DELETE`. `TTL` on the table drops expired data during merges automatically.
    - **`ALTER TABLE ... DROP PARTITION` is instant and free**; row-level `DELETE`/`ALTER DELETE` is a mutation that rewrites parts — avoid it for bulk cleanup. This is the payoff of coarse partitioning.
    - **`ReplacingMergeTree` reads can see un-merged duplicates.** Use `FINAL` only on cold/admin queries, never in dashboards — it merges at query time.
    
    ## Anti-patterns
    
    | Anti-pattern | Why it hurts | Do instead |
    |---|---|---|
    | `MergeTree` with no `ORDER BY` (or `ORDER BY tuple()`) on a queried table | No sparse index → every query full-scans | Pick a 3–5 col key, low→high cardinality, `WHERE`-driven |
    | `PARTITION BY` a high-cardinality col / per-hour / per-day at low volume | Thousands of partitions → too many parts → merge storms | Partition by `toYYYYMM`; the sparse index does the speed |
    | Single-row `INSERT ... VALUES` in a loop | Each becomes a tiny part; merges can't keep up | Batch 10k–100k+ rows, or rely on 26.3 async inserts |
    | `POPULATE` on a billion-row base table's MV | Blocks the MV, can OOM | Create MV empty, backfill in time windows |
    | `SELECT *` on a wide table | Reads every column, defeats columnar storage | Select only the columns you need |
    | `FINAL` in a dashboard query | Forces merge at query time → slow | Keep `FINAL` off hot paths; accept eventual dedup |
    | ClickHouse for OLTP point-updates / single-row reads by id | Wrong engine; no real updates, weak point lookups | Use `../postgresdb/SKILL.md` |
    | MV `GROUP BY` not matching target `ORDER BY` | Inefficient merges, wrong rollups | Align them exactly |
    
    ## Verification
    
    `scripts/verify.sh <file.sql>` is a static linter over candidate ClickHouse DDL/queries: flags `MergeTree` without `ORDER BY`, over-fine `PARTITION BY`, single-row `INSERT ... VALUES`, `POPULATE` on materialized views, `SELECT *`, and `FINAL`. Read-only, no live cluster needed, exits 0 on clean input.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related