This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A declarative Infrastructure-as-Code (IaC) tool for managing ClickHouse
schemas in Go. The active code path is HCL-first: schemas are written
in HCL, layered for multi-environment setups, resolved into a flat
desired state, and round-tripped against a live cluster via the hclexp
binary. Terraform/Kubernetes-style state reconciliation; no sequential
migrations.
Execution model: generated DDL is run on each node of a cluster
individually — never with ON CLUSTER (too fragile in operation). Heavy
mutations (MATERIALIZE INDEX) are generated but marked manual and are
only ever run deliberately by an operator.
The single binary is hclexp. (A legacy YAML/protobuf chschema tool was
removed; the Go module is still named github.com/posthog/chschema for
import stability — do not rename it.)
The hclexp binary is composed of these packages:
- HCL Loader & Resolver (
internal/loader/hcl/) — parses HCL files and layer stacks, appliespatch_tableandextendinheritance, drops abstracts, cascades the database-levelclusterdefault, and decodes typedengineblocks. - Introspection Engine (
internal/loader/hcl/introspect.go) — connects to a live ClickHouse instance, reads each table'screate_table_query, and parses it with the ClickHouse SQL parser to reconstruct the declarative schema (columns, indexes, constraints, engine, ORDER BY, PARTITION BY, SAMPLE BY, PRIMARY KEY, TTL, SETTINGS). - Diff Engine (
internal/loader/hcl/diff.go) — compares two resolved schemas (HCL ↔ HCL, HCL ↔ live, live ↔ live). - SQL Generator (
internal/loader/hcl/sqlgen.go) — turns the diff into CREATE/ALTER/DROP DDL; ordering respects MV and Distributed dependencies; in-place-impossible changes are flagged-- UNSAFE. - Validators (
internal/loader/hcl/validate.go) — dependency checks for MVs (querysource tables,to_tabletarget) and Distributed tables (remote_database/remote_table).
# Build the active binary
go build -o hclexp ./cmd/hclexp
# Run every test CI runs (./internal/... ./cmd/... and ./test)
just test
# Live ClickHouse integration tests (needs: docker compose up -d)
just test-liveThe justfile has the full recipe list.
/
├── cmd/
│ └── hclexp/ # CLI entry point (subcommand dispatch)
├── internal/
│ ├── loader/hcl/ # HCL parser, resolver, diff, sqlgen,
│ │ # introspection, validation, plan
│ └── logger/ # Shared logging
├── docs/ # Reference + FAQ + plans
│ ├── README.hcl.md # HCL language reference (authoritative)
│ ├── FAQ.md # Worked examples
│ └── plans/ # Design docs for in-flight features
├── config/ # ClickHouse connection config
└── test/ # Snapshot + live integration tests
- Edit an HCL file under your
schema/tree (e.g. add a column to atable "events"block). - Run
hclexp diff -left ./schema -right clickhouse://...to preview the change summary, or-sqlto see the migration DDL. - Commit and open a pull request; CI runs the same diff and asserts no unintended drift.
- Run with:
go test ./internal/... ./cmd/... -v - Located in each internal package, plus
cmd/hclexp(CLI wiring and the text/JSON renderers — CI runs these, so./internal/...alone is not enough)
- Run with:
go test ./test -v
- Run with:
go test ./test -v -clickhouse - Requires running ClickHouse instance (use
docker compose up -d) - Connection config from environment variables (see docker-compose.yml for defaults)
- Tests automatically create/cleanup isolated test databases
- Current test coverage:
- ✅ Basic connectivity (ping, SELECT 1)
- ✅ End-to-end: HCL → Diff → apply DDL → Introspect → Compare
(round-trip fidelity,
test/roundtrip_fidelity_live_test.go) - ✅ Table name, database, columns, ORDER BY validation
- ✅ Engine validation (unconditional - fully implemented)
- ⏳ Settings validation (conditional - needs introspection enhancement)
- ✅
databaseblocks withclusterdefault (cascades to tables) - ✅
tableblocks:primary_key,order_by,partition_by,sample_by,ttl,settings,comment,cluster - ✅
columnblocks:nullable,default/materialized/ephemeral/alias(mutually exclusive),codec,ttl,comment,renamed_from(drivesRENAME COLUMNin the diff) - ✅
indexblocks; adding an index to an existing table also generates aMATERIALIZE INDEXmarked manual (-- MANUAL:indiff -sql,"manual": truein JSON/plan) — heavy mutations are operator-run, never executed automatically - ✅
constraintblocks withcheckorassume(exactly one) - ✅
projectionblocks (query, optionalsettings→WITH SETTINGS); modify diffs as DROP+ADD, and adding to an existing table generates a manualMATERIALIZE PROJECTION(operator-run, likeMATERIALIZE INDEX); index-form projections (PROJECTION p INDEX …) are unsupported and fail loudly at parse - ✅
materialized_viewblocks (TO-form):to_table,query, explicitcolumnlist,cluster,comment - ✅
viewanddictionarytop-level blocks (parsed, resolved, diffed, and emitted as DDL) - ✅ A changed
dictionaryreconciles viaCREATE OR REPLACE DICTIONARY— one atomic statement rewriting the whole object, safe (a dictionary holds no persistent data; it reloads from its source) and never flagged unsafe - ✅ Secrets: ClickHouse redacts a credential to
[HIDDEN]unless the introspecting user hasdisplaySecretsInShowAndSelect. The marker is kept in-band (dictionary sources and named-collection params alike) so it round-trips through a dump and a comparison can tell "secret I cannot see" from "no secret". It compares as unknown (both sides hidden → equal; hidden vs a real value → reported unverifiable, excluded from the diff; hidden vs absent → a real difference), andsqlgenrefuses to emit any statement containing it. What that costs differs by kind: a dictionary is rewritten whole, so an unknown secret blocks any change to it; a named collection has surgicalALTER … SET/DELETE, so only the statements that write every param (CREATE, and the DROP+CREATE anON CLUSTERchange forces — blocked as a pair) are refused. Authoredpassword = "[HIDDEN]"declares a secret managed outside hclexp. Seedocs/README.hcl.md - ✅ Long view/MV
queryas a one-liner, HCL heredoc, orfile("x.sql"); all normalize to a canonical beautified form so formatting never diffs as drift (seedocs/README.hcl.md) - ✅ Canonicalization (
canonicalize, run at the tail of both the load and the introspect path) is what keeps authored text comparable with a live cluster's: view/MV/projection queries, columndefault/materialized/alias, indexexpr/type, tablettl, and column types (ClickHouse storesEnum8('a' = 1), the canonical form isEnum8('a'=1)). It must cover every field introspection renders throughformatNode, on every object kind that has one — table columns, MV column lists, dictionary attributes, TimeSeries inner columns, and the patch collections; a missed field is permanent phantom drift. Each normalizer parses its fragment inside a throwaway statement and rejects input that reaches past the fragment (type = "String DEFAULT 'x'"), keeping the raw text and warning rather than silently dropping the clause - ✅ A layer stack entry is a directory (every
*.hclin it) or a single.hclfile — same merge semantics either way (hclload.LayerFilesowns the dir/file decision, so every-layer/-left/manifestlayerspath gets it); a non-.hclor missing entry errors - ✅
patch_table(cross-layer table modification; the target stays declared once): columns add/modify_column/drop_columns(modify → drop → add; adds position withafter = "col"/first = true, resolving against the post-previous-op state, so mid-table env columns interleave without full redeclaration — placement is patch-only and cleared on application), indexes add/drop_indexes(drop first, so drop+add redefines; adds take the sameafter/firstplacement as columns),order_by/partition_by/sample_by/ttlreplace when set,enginereplaces wholesale,settingsmerge patch-wins;primary_key/comment/ constraints/projections stay non-patchable (useoverride = true) - ✅
patch_view(query/commentreplace; query normalized like a declared view's) andpatch_dictionary(source/layout/lifetimereplace wholesale,settingsmerge) — unknown targets error; MVs have no patch form - ✅
extendinheritance withabstractbases, child-local partialpatch_columnspecialization of inherited columns, and cycle detection - ✅
override = truefor cross-layer full replacement - ✅
nodetop-level blocks (introspection metadata: hostname +macrosfromsystem.macros; ignored by diff) - ✅
raw "<kind>" "<name>"escape-hatch blocks: opaque CREATE DDL stored verbatim for objects the parser/HCL model can't express. Diffed as text, recreated (DROP+CREATE) on change; atable-kind change is flagged-- UNSAFE.introspect/dump-clusterare strict by default and capture raw blocks only with-allow-raw.
- ✅ Tables —
hclexp introspectround-trips tables (columns, indexes, constraints, engine, ORDER/PARTITION/SAMPLE/TTL/SETTINGS) - ✅ Exclude patterns —
introspect/dump-cluster/diff/plan/drift/loadtake-exclude <file>, an HCL config with anexclude { patterns = [...] }glob list plus an optionalobject_types = [...](drop a whole class, e.g.named_collection). Objects whose name (ordb.name) matches are skipped before their DDL is parsed, so transient tables (_tmp_replace_*, migrationtmp_*,*_backup,*_staging, …) neither land in the dump nor abort introspection. On the comparison commandsFilterSchemadrops them from both sides before the diff, so they appear in no output and no count. Seeexamples/exclude.hcl. - ✅ Materialized Views — TO-form only; inner-engine, refreshable, and window views are rejected with a clear error
- ✅ Views & Dictionaries — round-tripped as HCL
- ✅ Materialized views: source tables (parsed from
query) andto_tabledestination must be declared - ✅ Distributed tables:
remote_database/remote_tablemust be declared - ✅ Fails on references into databases that weren't loaded
- ✅
-skip-validation=<name,...>/-skip-validation='*'skips checks for named dependent objects - ✅
-role <name>(manifest-driven mode) validates only that role; the cluster set is still derived from the whole manifest, so a single role's cross-role Distributed proxies still resolve - ✅
hclexp diff -sqlorders CREATE/DROP DDL by these dependencies
- ✅ Compares per-node HCL dumps in a directory; groups nodes and diffs each group against its lexically-first reference node
- ✅
-dir,-glob(filename filter),-group-by(macro names or the pseudo-keysrole/shard/replica),-details,-exclude; exits non-zero on drift (CI guard) - ✅
-zk-paths(defaultmask-uuid) normalizes ReplicatedMergeTreezoo_path(table UUID →{uuid}) so per-shard path noise isn't drift - ✅
-format text|json: JSON emits groups → drifters, each carrying the same per-object comparisonsdiffemits plus derived counts. Direction is descriptive (reference → drifter), NOT a fix script. The text one-liner is rendered from the same counts, so raw-block and named-collection drift are counted instead of printing the barechangedfallback
- ✅ Applies ClickHouse DDL to a left-side HCL schema and emits updated HCL,
reusing the introspection AST builders (
upsertObjectFromStmt) and theApplySQLengine ininternal/loader/hcl/sql_edit.go - ✅
CREATE TABLE/MV/VIEW/DICTIONARY(add or replace by name);ALTER TABLEadd/drop/modify/rename column, add/drop index, modify/remove TTL, modify/reset setting;ALTER TABLE <mv> MODIFY QUERY;DROP …;RENAME TABLE - ✅
-left(layer stack: dirs or.hclfiles),-in(file/stdin),-out(stdout/file/dir),-database(default DB for unqualified names),-allow-raw(capture unexpressible CREATE as araw{}block, likeintrospect) - ✅ Schema DDL only — data/partition ops (
TRUNCATE,ALTER … DELETE, partition ops,MATERIALIZE …) are rejected; output is the resolved (flat) schema, pair withhclexp diff -sqlto preview the migration - ❌ Does not rewrite layered source files in place
- ✅ One shared model (
internal/loader/hcl/compare.go):ObjectComparison= one differing object, with attribute-levelFieldChanges (old/new) and the DDL ops that reconcile it. It is a serialization of the existing ChangeSet —BuildObjectComparisons(cs, gen, left, right)— never a second diff engine - ✅
diff -format jsonemitsobjects+summary(counts derived fromobjects) alongside the unchanged flatoperations/unsafelists; an object's nested ops carry their index into the global list, so the object view and the execution view can't disagree.-excludefilters both sides - ✅ Text mode renders from the same
[]ObjectComparison(RenderObjectComparisons), so text/JSON/counts cannot contradict each other - ✅
statusis right-relative:added= present only on the right side of theDiff(left, right)call.diff/planput desired on the right;driftputs the drifter on the right - ✅ The
fieldvocabulary (column:/index:/projection:/constraint:/setting:/param:/engine/order_by/…) is a public contract, documented indocs/README.hcl.md - ✅ System-proxy column subsets — a Distributed proxy with
remote_database = "system"compares columns subset-tolerantly in the diff engine (diff/plan/drift): presence differences are suppressed in either direction (system tables gain columns with server versions, so a declared subset is the only pinnable state), while columns on both sides still compare fully and an engine change yields the full column diff. Non-system proxies stay exact (#136 item 4)
- ✅
-manifest/-envcompose a node straight from the same role manifestvalidateandplanconsume, so callers never rebuild the layer stack by hand;-layer-rootroots the manifest's layer paths - ✅
-role <name>composes one role; without it every role deployed in-envis composed and-outmust name a directory (one<env>-<role>.hcleach). Several roles cannot go to stdout — theirdatabaseblocks would collide - ✅
-out-nametemplates the file names written into the-outdirectory (default{env}-{role});{env}/{role}expand,.hclis appended, and template subdirectories are created —-out-name '{env}/{role}'writes the per-env treegolden/<env>/<role>.hcldirectly. Unknown placeholders, paths escaping-out, and two roles rendering to one path are errors - ✅ Object filters on the emitted schema (plain and manifest modes, every
composed role):
-exclude <file>(the shared exclude config),-exclude-objects <glob,...>(ad-hoc drops),-only <glob,...>(keep only matches — the layer-surgery selector). Kept iff matches-only(when given) and neither exclusion; emptieddatabase{}blocks andnode{}blocks survive; rejected with-format json - ✅
-format jsonemits each role's declared and resolved layer stack, for callers that need the stack itself rather than the composed schema; the stacks come from the manifest alone (no composition), so it answers "what should I fetch?" before the layer dirs exist - ✅
-manifestis mutually exclusive with-layer/-config; an unknown-roleexits 2, as invalidate
- ✅ Diffs every role in an HCL
-manifestagainst a-dumptopology in one run and emits a single globally-ordered, cross-role operation list (storage before its Distributed/Buffer proxies before the MV), withrolesprovenance - ✅ Manifest is role-first HCL with nested
envblocks selected by-env; dump nodes match roles byhostClusterRolemacro, replicas collapse to one representative;-format json|text;-excludefilters both sides - ✅ Alongside the merged
operations, emitsroles: each role's own (non-deduped) object comparisons with derived counts — triage is per (env, role), execution stays on the deduped global list
- ✅
locate <name-or-glob>...lists every declaration site (file:line+ abstract/override/patch/extend flags) of matching objects across all manifest layers, with derived (role, env) placements read from the manifest across every env;-dump DIRalso lists the per-node dump files declaring the object, attributed to the node (node{}block, else filename stem);-format text|json - ✅ Several patterns are independent existence checks: exits 1 when any
pattern matches nothing; an extended object cross-links its children
(
extended_by), computed over all authored declarations, not just matches - ✅
-layersearches ad-hoc layer dirs or.hclfiles (alone or alongside-manifest, deduped against its layers); sites carry no placements - ✅
-duplicates(no name argument; requires-manifestor-layer): exits 1 when any(database, name)has two or more plain declarations (patch_table/override/abstract sites are legitimate), so CI enforces once-only even for layers that never co-compose
- ✅ Serves a read-only web UI to browse a resolved HCL schema (databases,
objects, columns/engine/settings, dependency cross-links);
-config/-layersource,-addrto bind - ✅ Auto-reloads on source change: each request re-stats the source files at
most once per
-reload-interval(default 2s; 0 disables) and reloads when a file's mod time changes; a broken edit keeps the last good schema - ✅
-manifest(role/env/layers, likeplan) browses every composed schema in one server: a schema list at/, each(env, role)under/s/<env>/<role>/;-envfilters to one env,-layer-rootprefixes the manifest's layer paths
MergeTree, ReplicatedMergeTree, ReplacingMergeTree (with version_column
and is_deleted_column; the latter requires the former, matching
ClickHouse), ReplicatedReplacingMergeTree, SummingMergeTree (with
sum_columns),
CollapsingMergeTree, ReplicatedCollapsingMergeTree, AggregatingMergeTree,
ReplicatedAggregatingMergeTree, the corresponding SharedMergeTree variants,
Distributed (with optional
sharding_key and policy_name; the latter requires the former),
Log, Kafka. See docs/README.hcl.md for the
attribute table.
ClickHouse Cloud may rewrite MergeTree-family DDL to Shared*MergeTree.
These are first-class engine kinds: introspection and sql2hcl preserve the
reported engine name and all constructor arguments, and SQL generation emits
the same constructor. See docs/plans/2026-08-09-cloud-sharedmergetree.md.
- ❌ Inner-engine MVs,
REFRESHMVs, window views - ❌ Distributed
policy_nameparameter (silently dropped on introspect)
Connection configuration is managed in config/clickhouse.go:
- Uses environment variables for defaults:
CLICKHOUSE_HOST(default: localhost)CLICKHOUSE_PORT(default: 9000)CLICKHOUSE_DB(default: migration_test)CLICKHOUSE_USER(default: user1)CLICKHOUSE_PASSWORD(default: pass1)
- Command-line flags override environment variables
- Connection includes automatic ping validation
- 1 line summary
- empty line
- detailed description
No need to list files or exact changes, it's already in a git commit, unless some previous assumptions or architecture is modified.
- always write the plan to markdown plan, when a plan changes, update the md file
- in go.mod never change module name or go version
- run tests with:
go test ./...— CI runs./internal/... ./cmd/...(with-race) and./test/..., so never trust./internal/...alone: the CLI wiring and the text/JSON renderers live incmd/hclexpand do assert on rendered output - run live tests with:
go test ./test -v -clickhouse(requires docker compose up -d) - you can run clickhouse client directly: clickhouse client
- use gopls-io mcp when possible
- every time a new feature is added, there should be a test covering a feature
- before preparing PR description, run git status