Skip to content
Back to skills

Clickhouse Migrations

ASecurity

ClickHouse migration patterns and rules. Use when creating or modifying ClickHouse migrations.

  • 40,048 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 1, 2026
developmentpythongobashsqlnodetestingapifrontend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 20, 2026

npx -y skills add PostHog/posthog --skill clickhouse-migrations --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Clickhouse Migrations?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Clickhouse Migrations
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/posthog-clickhouse-migrations-posthog/badge)](https://www.skillsdirectory.com/skills/posthog-clickhouse-migrations-posthog)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: clickhouse-migrations
description: ClickHouse migration patterns and rules. Use when creating or modifying ClickHouse migrations.
---

# ClickHouse Migrations

Read `posthog/clickhouse/migrations/AGENTS.md` for comprehensive patterns, cluster setup, examples, and ingestion layer details.

## Quick reference

### Migration structure

```python
operations = [
    run_sql_with_exceptions(
        SQL_FUNCTION(),
        node_roles=[...],
        sharded=False,  # True for sharded tables
        is_alter_on_replicated_table=False  # True for ALTER on replicated tables
    ),
]
```

### Node roles (choose by the cluster that owns the object)

Only the members of `NodeRole` in `posthog/clickhouse/client/connection.py` are valid: `ALL`, `DATA`,
`INGESTION_EVENTS`, `INGESTION_SMALL`, `INGESTION_MEDIUM`, `ENDPOINTS`, `LOGS`, `AI_EVENTS`, `AUX`,
`BATCH_EXPORTS`, `OPS`, `SESSIONS`. A name that is not in that enum fails at import, which aborts
migration discovery and takes every job that runs migrations down with it.

Pick the role by **which cluster owns the object**, not by its type alone:

- `[NodeRole.DATA]`: anything on the main cluster — sharded tables, non-sharded replicated tables,
  distributed read tables, views, dictionaries. The default, and right for most migrations.
- `[NodeRole.INGESTION_SMALL]`: writable tables, Kafka tables, materialized views on the ingestion layer
- `[NodeRole.OPS]`, `[NodeRole.LOGS]`, `[NodeRole.AUX]`, `[NodeRole.AI_EVENTS]`, `[NodeRole.SESSIONS]`,
  `[NodeRole.BATCH_EXPORTS]`: objects that live on a satellite cluster. A table for one of those on
  `DATA` lands on the wrong nodes and leaves the intended cluster without it, so check where the
  object is read and written before defaulting to `DATA`. Dev runs the same satellite clusters as
  US/EU prod — never branch on `CLOUD_DEPLOYMENT` to give dev a different layout.
- `[NodeRole.ALL]`: rarely used

### Table engines quick reference

MergeTree engines:

- `AggregatingMergeTree(table, replication_scheme=ReplicationScheme.SHARDED)` for sharded tables
- `ReplacingMergeTree(table, replication_scheme=ReplicationScheme.REPLICATED)` for non-sharded
- Other variants: `CollapsingMergeTree`, `ReplacingMergeTreeDeleted`

Distributed engine:

- Sharded: `Distributed(data_table="sharded_events", sharding_key="sipHash64(person_id)")`
- Non-sharded: `Distributed(data_table="my_table", cluster=settings.CLICKHOUSE_SINGLE_SHARD_CLUSTER)`

Kafka engine — `kafka_engine()` returns the ENGINE clause only, so every table appends its own
`SETTINGS`. Start from this baseline:

```sql
SETTINGS kafka_skip_broken_messages = 100,
         kafka_num_consumers = {kafka_num_consumers(1)},
         kafka_thread_per_consumer = 1,
         kafka_poll_timeout_ms = 10000,
         kafka_max_block_size = 100000
```

- `kafka_skip_broken_messages` defaults to 0, where one malformed message stops the consumer for good.
- `kafka_poll_timeout_ms` is 10000 for WarpStream, which does not support `fetch.min.bytes`.
- `kafka_max_block_size` defaults to about a million rows, so 100000 is a reduction; lower for wide
  rows, not below ~1000.
- `kafka_num_consumers` is per node, so the role's node count multiplies it; the total must not
  exceed the topic's partitions.
- Columns and settings are fixed at creation: drop and recreate the table with its MV, no `SYNC`.
  `ALTER TABLE <mv> MODIFY QUERY` covers an MV-SELECT-only change. Never for
  `kafka_events_json_ws` / `events_json_ws_mv` — see Critical rules.

`posthog/clickhouse/migrations/AGENTS.md` has the sizing rules and the rest.

### Critical rules

- NEVER use `ON CLUSTER` clause in SQL statements
- Always use `IF EXISTS` / `IF NOT EXISTS` clauses
- When dropping and recreating replicated table in same migration, use `DROP TABLE IF EXISTS ... SYNC`
- If a function generating SQL has on_cluster param, always set `on_cluster=False`
- Use `sharded=True` when altering sharded tables
- Use `is_alter_on_replicated_table=True` when altering non-sharded replicated tables
- **Never write `CODEC(ZSTD(1))` on a column** — the server already compresses every column with ZSTD, so it buys nothing. Declare a CODEC only where it beats that default, and check the `ORDER BY` first: `Delta`/`DoubleDelta` need the column near-sorted in storage order (a leading sort-key prefix), and lose on a column the key only buckets or omits. `T64`/`Gorilla` are ordering-independent. Put it on the storage table only — a CODEC on a Distributed or Kafka table is inert metadata that drifts from the sharded table it fronts.
- **Never write a `DROP COLUMN` migration yourself** — `DROP COLUMN` can get stuck in ClickHouse and block releases. Column removal is a two-step process: (1) the ClickHouse team drops the column directly on the cluster, then (2) you write a migration with the matching `DROP COLUMN` so the codebase schema stays in sync. Never initiate the drop from a migration without the ClickHouse team having done step 1 first.
- **Never drop or recreate `kafka_events_json_ws` or `events_json_ws_mv`** — these tables are a no-go zone. The MV definition differs significantly between US prod, EU prod, and dev (dozens of environment-specific `mat_*` columns) and those differences are **not reflected in the repo**. Dropping and recreating from repo SQL would destroy the environment-specific schema and break event ingestion. Any change must go through the ClickHouse team.

### PR scope

A PR that contains a ClickHouse migration **must be migration-only**. Do not mix migration files with feature code, API changes, model changes, or frontend changes in the same PR. Migration-related files are:

- The migration file itself (`posthog/clickhouse/migrations/0NNN_*.py`)
- SQL definition files the migration depends on (e.g. `posthog/clickhouse/sql/*.py`, table engine helpers)
- Tests that directly exercise the migration or the SQL definitions it touches

If you need both a schema change and application code that uses the new schema, ship the migration first in its own PR and merge it before the application-code PR.

### Local setup parity

**No table should exist only in the cloud.** Every table created via migration must also exist in a local dev environment.

Some migrations are cloud-guarded and skipped in local/hobby dev. Gate on `posthog.run_mode`, never on `settings.CLOUD_DEPLOYMENT` directly, because the `clickhouse-migrations-use-run-mode` semgrep rule blocks raw comparisons:

```python
from posthog.run_mode import RunMode, run_mode

operations = (
    []
    if not run_mode().is_deployed_cloud  # US/EU/DEV
    else [...]
)
```

| Predicate                        | True for                                      |
| -------------------------------- | --------------------------------------------- |
| `run_mode().is_deployed_cloud`   | US, EU, DEV (the usual migration gate)        |
| `run_mode().is_prod_cloud`       | US, EU (excludes staging)                     |
| `run_mode() is RunMode.CLOUD_US` | one region (`CLOUD_EU`, `CLOUD_DEV` likewise) |

`run_mode().is_cloud` also counts E2E, so it is wrong for a migration. That one matches `posthog.cloud_utils.is_cloud`.

Call `run_mode()` where you need it rather than assigning a module-level constant. `posthog/clickhouse/test/test_migrations.py` re-imports every migration under a patched `posthog.settings.CLOUD_DEPLOYMENT` to check each deployment's branch for stray `ON CLUSTER`, and a cached value would silently skip that coverage.

If you create a new table inside such a guard, you must also add its SQL function to `posthog/clickhouse/schema.py` in the appropriate tuple so the table gets created locally:

| Table type             | Tuple in `schema.py`               |
| ---------------------- | ---------------------------------- |
| MergeTree / base table | `CREATE_MERGETREE_TABLE_QUERIES`   |
| Distributed / writable | `CREATE_DISTRIBUTED_TABLE_QUERIES` |
| Kafka consumer         | `CREATE_KAFKA_TABLE_QUERIES`       |
| Materialized view      | `CREATE_MV_TABLE_QUERIES`          |
| Non-materialized view  | `CREATE_VIEW_QUERIES`              |
| Dictionary             | `CREATE_DICTIONARY_QUERIES`        |

The only exception is tables whose definition intentionally differs per environment and is not tracked in the repo (e.g. the no-go zone `events_json_ws_mv` table).

**Dictionary credentials:** when a dictionary uses a `SOURCE(CLICKHOUSE(...))`, resolve the source user/password via `get_clickhouse_creds(ClickHouseUser.DICT_READER)` and interpolate them into the `USER`/`PASSWORD` clause — do not hardcode `default`/`CLICKHOUSE_USER` or omit credentials. This keeps dictionary auth on the dedicated low-privilege `dict_reader` user, decoupled from `default`; it falls back to `default` creds when the env vars are unset. See `posthog/models/exchange_rate/sql.py` for the pattern.

### Declarative schema (HCL) must agree

Some roles are also declared in `posthog/clickhouse/hcl/`, and the multinode migration smoke re-introspects
the migrated cluster and fails on any drift between the live schema and the committed golden. A table added
by a migration on a managed role therefore needs the HCL updated in the same PR, or CI fails at
`check_live_hcl` with a `DRIFT:` block naming your new objects.

The `data` role is managed for the `local-multi` composition, so a new table on `NodeRole.DATA` counts.
After writing the migration:

```sh
HCL=posthog/clickhouse/hcl
# add the table to the layer that composes it, e.g. $HCL/roles/data/local/tables.hcl
bash $HCL/gen-golden.sh && bash $HCL/gen-sql.sh   # refresh generated artifacts
bash $HCL/check.sh                                # must exit 0
```

Read `posthog/clickhouse/hcl/README.md` before editing a layer — it covers which layer to pick, abstract
column lists, and the codegen path that writes the migration for you.

### Testing

Delete entry from `infi_clickhouse_orm_migrations` table to re-run a migration.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.