All skills
posthog avatar

/clickhouse-migrations

@8681dce official
by posthogposthog/posthog40k stars
3,379

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

Use this Skill: https://skilld.dev/gh/posthog/posthog/clickhouse-migrations

This session only. Nothing lands on disk.

SKILL.md

≈29 tokens always: the name and description. ≈2.5k when used: this file.

ClickHouse Migrations

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

Quick reference

Migration structure

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:

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:

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:

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.

Source: SKILL.md on GitHub

No alerts10d4 checks · Risk SAFE
  • Gen Agent Trust Hub10d

    This skill provides safe technical guidelines and best practices for creating database migrations. It encourages secure credential handling and includes rules to prevent accidental data loss during schema updates.

  • Socket10d

    No alerts

  • Snyk10d

    Risk: LOW · No issues

  • Runlayer7mo

    1/1 file flagged

Signed by skilld at 8681dce. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated last month

README badge

README badge for posthog/posthog/clickhouse-migrations

Defines ClickHouse migration patterns for PostHog's analytics infrastructure, covering table engines, node roles, replication schemes, and critical constraints like never using ON CLUSTER or initiating DROP COLUMN operations. Use when writing or modifying ClickHouse migrations in the PostHog codebase, particularly for sharded vs. replicated table decisions and cloud-deployment guards.

Generated from the current SKILL.md.

Can I use ON CLUSTER in my migration SQL?
No. Never use the ON CLUSTER clause in SQL statements within migrations. The run_sql_with_exceptions wrapper handles cluster execution automatically.
What should I do before writing a DROP COLUMN migration?
Never initiate a DROP COLUMN migration yourself. The ClickHouse team must drop the column directly on the cluster first, then you write a matching migration to keep the codebase schema in sync. Initiating the drop from a migration can block releases.
Can I mix a ClickHouse migration with feature code in the same PR?
No. A PR containing a ClickHouse migration must be migration-only. Ship the migration first in its own PR and merge it before opening the application-code PR.
Do I need to create new tables in both cloud and local environments?
Yes. Every table created via migration must exist in local dev. If a migration is cloud-guarded, add its SQL function to the appropriate tuple in posthog/clickhouse/schema.py so the table gets created locally.
Can I drop or modify the kafka_events_json_ws or events_json_ws_mv tables?
No. These tables are a no-go zone. Their definitions differ between US prod, EU prod, and dev but those differences are not tracked in the repo. Any change must go through the ClickHouse team.

Generated from the current SKILL.md. These answers refresh after source changes.