A declarative tool for managing ClickHouse schemas. Schemas are written in HCL, layered for multi-environment setups, resolved into a flat desired state, and round-tripped against a live cluster.
hclexp is a multi-command CLI. The core modes:
- Introspect — connect to a live ClickHouse instance and dump its databases as HCL (to stdout, a file, or a directory). Round-trips tables, materialized views, plain views, dictionaries, and named collections.
- Load & resolve — read an HCL schema (a single file or a stack of layer directories), apply the deterministic parent-first composition model, and emit the resolved, flat schema as canonical HCL.
- Validate — check that every cross-object reference (MV sources +
destination, view sources, Distributed
remote_*) in a resolved schema is satisfied, without connecting to a cluster. - Diff — compare two schemas (HCL sources or live clusters, in any
combination) and report the changes — or the migration DDL — between
them;
-format jsonemits a dependency-ordered, machine-readable plan.
Additional commands:
- plan — diff every node role in a desired
-manifestagainst either a live-dumptopology or a previous-from-manifestcomposition, emitting a single globally-ordered, cross-role operation list (storage before its Distributed/Buffer proxies before the MV). See Cross-role planning and the runnableexamples/manifest/. - drift — detect cross-node schema drift across per-node HCL dumps.
- locate — find every declaration site of an object across manifest
layers and per-node dumps;
-duplicatesaudits the once-only rule. - dump-cluster — enumerate a cluster's nodes and dump one
<host>.hclper node. dump-sql — dump a database's CREATE statements as replayable DDL. - sql2hcl — apply ClickHouse DDL edits to an HCL schema and emit updated HCL.
- web — serve a read-only web UI to browse a resolved schema.
- github-token — mint a short-lived GitHub App installation token.
Run hclexp <command> -h for command-specific flags.
Connections can be plaintext (default) or TLS (see
TLS / secure connections). hclexp also
ships as a minimal container image (see Container image)
for use as a deployment-time schema-dump hook.
go build -o hclexp ./cmd/hclexpClickHouse connection defaults come from environment variables and can be overridden by flags:
| Variable | Default |
|---|---|
CLICKHOUSE_HOST |
localhost |
CLICKHOUSE_PORT |
9000 |
CLICKHOUSE_DB |
migration_test |
CLICKHOUSE_USER |
user1 |
CLICKHOUSE_PASSWORD |
pass1 |
CLICKHOUSE_SECURE |
false |
CLICKHOUSE_TLS_SKIP_VERIFY |
false |
For TLS-only clusters (typically port 9440), set CLICKHOUSE_SECURE=true
— or pass -secure on the CLI, or ?secure=true on the diff URL form.
See TLS / secure connections below.
# Dump a database to stdout as HCL
hclexp introspect -database posthog
# Dump several databases, one <db>.hcl file per database, into a directory
hclexp introspect -database posthog,system -out ./schema/
# Dump to a single file
hclexp introspect -database posthog -out posthog.hcl
# Override connection details
hclexp introspect -host ch.example.com -port 9000 -user ro -password secret \
-database posthog -out ./schema/
# TLS-only cluster on port 9440, internal CA → skip cert verification
hclexp introspect -host ch.prod.internal -port 9440 -user readonly \
-secure -tls-skip-verify -database posthog -out ./dump/Flags:
-database— comma-separated list of databases to introspect (required)-node— name for the emittednode {}block; defaults to the server'shostName()-host,-port,-user,-password— connection overrides-secure— connect over TLS (matchesCLICKHOUSE_SECURE)-tls-skip-verify— skip server-cert verification (requires-secure; matchesCLICKHOUSE_TLS_SKIP_VERIFY)-out— output target:- omitted → write HCL to stdout
- a directory → write one
<database>.hclper database - any other path → write all databases to that single file
-allow-raw— capture objects whoseCREATEDDL can't be parsed or expressed as araw {}block instead of failing (see below)-show-secrets— capture real secret values (dictionary source passwords, named-collection params) instead of the redacted[HIDDEN]. Off by default; requires the server'sdisplay_secrets_in_show_and_select = 1and thedisplaySecretsInShowAndSelectgrant. Writes real secrets to the output — handle with care. See docs/secrets.md.
Introspection reads each object's create_table_query and parses it with
the ClickHouse SQL parser, so columns (types, defaults, codecs, comments,
MATERIALIZED/ALIAS/EPHEMERAL), indexes, constraints, engine +
parameters, ORDER BY, PARTITION BY, SAMPLE BY, PRIMARY KEY, TTL,
and SETTINGS all come back populated. Materialized views (TO-form),
plain views (with column_aliases, sql_security, definer, comment),
dictionaries (every supported source + layout kind), and named
collections are dumped in the same pass.
Each dump also gets a top-level node {} block recording the source
node's name and ClickHouse macros (shard, replica, hostClusterRole,
hostClusterType, …) read from system.macros. It's metadata only —
hclexp diff ignores it — and exists so hclexp drift (below) can group
nodes by their authoritative identity.
Introspection is strict by default: an object whose DDL the parser can't
handle, or that uses an engine/form the HCL model can't express, aborts the
dump with an error. Pass -allow-raw to capture such objects verbatim as
raw "<kind>" "<name>" { sql = ... } escape-hatch blocks (with a warning)
and continue, so one unusual object never breaks the whole dump.
hclexp dump-cluster takes the same flag. Raw blocks are opaque — diffed as
text and recreated (DROP + CREATE) on change, with a table-kind change
flagged -- UNSAFE. See docs/README.hcl.md for
the full reference.
hclexp dump-cluster enumerates every node of a named cluster from an
entry host's system.clusters and introspects each node natively,
writing one <short-host>.hcl per node — the per-node dumps that drift,
plan, and locate -dump consume.
hclexp dump-cluster -host entry.prod.internal -cluster posthog \
-database posthog,system -out-dir ./prod/eu -allow-raw -exclude exclude.hcl-cluster— thesystem.clustersname to enumerate (required)-out-dir— output directory (required). Existing*.hclfiles in it are removed first, so decommissioned nodes disappear from the dump.-database,-allow-raw,-exclude, and the connection/TLS flags work exactly as inintrospect, applied on every node.- Per-node failures are non-fatal: the run logs the node, continues, and reports the failure count at the end — one unreachable replica doesn't lose the fleet dump.
Enumeration and introspection use the native protocol from your machine to each node; the entry host only supplies the node list.
# Load a single HCL file, resolve it, print a summary
hclexp -config ./schema/posthog.hcl
# Load a layer stack (applied left to right); an entry may be a dir or an .hcl file
hclexp -layer ./schema/base,./schema/env_us,./schema/nodes/ingest.hcl
# Write the resolved schema out as canonical HCL
hclexp -config ./schema/posthog.hcl -out ./resolved.hclFlags:
-config— path to a single HCL file (default./cmd/hclexp/node.conf)-layer— comma-separated layer stack, loaded in order; each entry is a directory (every*.hclin it) or a single.hclfile (mutually exclusive with-config)-out— if set, write the resolved schema as canonical HCL to this path-exclude— HCL exclude config (patterns+object_types, the same filediff/drift/planconsume); matching objects are dropped from the emitted schema-exclude-objects— comma-separated name globs (bare ordb.name) dropped from the emitted schema-only— comma-separated name globs; keep only the matching objects
-only/-exclude-objects make layer factoring two load calls instead of a
hand-written HCL parser (which silently loses shapes like the two-label
raw "dictionary" "x" {}):
# the shared layer: only the objects identical everywhere
hclexp load -layer overrides/data/dev -only "$LIST" -out overrides/data/cloud/tables.hcl
# each env layer: everything except those
hclexp load -layer overrides/data/dev -exclude-objects "$LIST" -out overrides/data/dev/tables.hclAn object survives iff it matches -only (when given) and neither exclusion.
Filtering removes objects only: the database {} wrapper survives even when
emptied and node {} blocks are untouched, so the two halves of a split are
exact complements.
With -manifest/-env the layer stack comes from the same role manifest
validate and plan consume, so callers never rebuild it by hand
(mutually exclusive with -layer/-config):
# One role to stdout (or -out FILE)
hclexp load -manifest manifest.hcl -env prod-us -layer-root ./schema -role ops
# Every role deployed in the env, one file per role into -out (a directory)
hclexp load -manifest manifest.hcl -env prod-us -layer-root ./schema -out ./golden
# Per-env tree instead of the flat default: golden/prod-us/ops.hcl
hclexp load -manifest manifest.hcl -env prod-us -layer-root ./schema \
-out ./golden -out-name '{env}/{role}'
# The resolved layer stacks themselves (no composition; works before the
# layer dirs exist)
hclexp load -manifest manifest.hcl -env prod-us -format json-role— compose only this role (default: every role deployed in-env)-layer-root— root directory the manifest's layer paths resolve under-out-name— file name template for roles written into the-outdirectory (default{env}-{role}, i.e. the flat<env>-<role>.hcllayout).{env}and{role}expand,.hclis appended, and template subdirectories are created, so'{env}/{role}'writesgolden/<env>/<role>.hcldirectly. Unknown placeholders, paths escaping-out, and two roles rendering to the same path are errors.-format json— emit each role's declared and resolved layer stack instead of composing
hclexp diff reports the changes needed to turn a left schema into a
right schema. Either side can be an HCL source or a live ClickHouse
instance, so you can diff config-vs-config, config-vs-cluster, or
cluster-vs-cluster.
# Local HCL file vs. a live cluster
hclexp diff -left ./schema/posthog.hcl \
-right clickhouse://user:pass@ch.example.com:9000/posthog
# Layered config vs. a single resolved file
hclexp diff -left ./schema/base,./schema/env_us -right ./resolved.hcl
# Two clusters
hclexp diff -left clickhouse://localhost:9000/posthog \
-right clickhouse://staging:9000/posthog
# Emit migration DDL (left -> right) instead of a summary
hclexp diff -left ./schema/posthog.hcl \
-right clickhouse://localhost:9000/posthog -sql
# Diff against a TLS-only cluster with an internal CA
hclexp diff -left ./schema/posthog.hcl \
-right 'clickhouse://ro:secret@ch.prod.internal:9440/posthog?secure=true&skip-verify=true'Side specs (-left / -right): each is one of
- a comma-separated layer stack, loaded + resolved in order; each entry is a
directory or a single
.hclfile (so one.hclpath is the common case) clickhouse://[user[:password]@]host:port/db1[,db2][?secure=true[&skip-verify=true]]— introspected live; missing connection pieces fall back to theCLICKHOUSE_*defaults. The optionalsecure/skip-verifyquery params switch on TLS (see below).
Flags:
-left,-right— the two schemas to compare (both required)-scope all|left|right— object ownership for the comparison.allis the default exact diff.leftignores right-only objects;rightignores left-only objects. Scoping removes whole unmanaged objects only: once an object is present on the scope side, all of its columns, engine, TTL, settings, and other fields still compare exactly.-ignore-column-order— compare table and materialized-view columns by name and definition while ignoring declaration order. By default physical column order is significant because it affectsSELECT *, positional inserts, and dump convergence.-sql— emit the migration DDL (CREATE/ALTER/DROP) that turns the left side into the right side, instead of the change summary. Changes ClickHouse can't apply in place (engine swap,ORDER BY,PARTITION BY,SAMPLE BY) are flagged with-- UNSAFEcomments. Heavy operator-run statements (currentlyMATERIALIZE INDEX, generated alongside everyADD INDEXon an existing table) are printed commented out as-- MANUAL:lines — run them deliberately, never as part of an automated apply. In-format jsonoutput the same statements carry"manual": true.
Comparison is semantic rather than rendered-text based: supported SQL
expressions are canonicalized and equivalent table TTL forms such as
toIntervalMonth(3) and INTERVAL 3 MONTH compare equal without losing a TTL
WHERE condition. When adding a column before an existing target column,
generated SQL includes FIRST or AFTER <column> so applying the migration
converges to the right-hand declaration order. Reordering columns that already
exist is reported for explicit review; hclexp does not auto-generate those
MODIFY COLUMN ... FIRST/AFTER statements.
This supports a reference schema that owns only part of a live node:
# Managed drift: live-only objects are outside the reference's ownership set.
hclexp diff -left ./reference -right ./clickhouse-schema/node-01.hcl \
-scope left -format json
# Correct production back to the reference without dropping live-only objects.
hclexp diff -left ./clickhouse-schema/node-01.hcl -right ./reference \
-scope right -sqlUse an unscoped exact diff for migrations between repository revisions:
hclexp diff -left ./reference -right ./proposed -format json. Every managed
drift must first be corrected in production or accepted into the reference;
the reviewed migration should never be adapted to an unresolved live state.
The default output is an indented +/-/~ summary:
database "posthog"
+ table new_table
- table old_table
~ table events
+ column event String
~ column team_id: UInt32 -> UInt64
+ setting index_granularity = 8192
hclexp validate checks that every cross-object reference in a resolved
schema can actually be satisfied — without connecting to ClickHouse.
It's the static guard the diff/apply path relies on:
- A
materialized_viewreads from source tables (named in itsquery) and writes intoto_table. Both must be declared somewhere in the loaded schema — the source may also live on a mapped sibling cluster (see below), the destination must be local. - A
distributed-engine table forwards to the table named by itscluster_name/remote_database/remote_table. The remote must be declared on the node itself, on a mapped cluster (see below), or in the built-insystemdatabase. Once the remote resolves, the proxy's columns are checked against it (see Distributed proxy columns).
Missing references — or references into a database that wasn't loaded —
fail with a non-zero exit code. The MV query is parsed to discover its
source tables; WITH ... AS CTE names are not treated as table references.
References into the built-in system database are always satisfied.
# Validate a single-file schema
hclexp validate -config ./schema/posthog.hcl
# Validate a layer stack
hclexp validate -layer ./schema/base,./schema/env_us
# Skip dependency checks for specific objects, or all of them
hclexp validate -config ./schema/posthog.hcl -skip-validation=events_mv,events_dist
hclexp validate -config ./schema/posthog.hcl -skip-validation='*'A Distributed proxy routinely forwards to a storage table that lives on
another cluster's composition — the remote database is posthog on
every cluster, so cluster_name is the only discriminator. Map each such
cluster to the layer stack that composes it with the repeatable
-cluster NAME=STACK flag, and the proxy's remote is resolved against that
cluster's schema. STACK is a layer stack (directories or .hcl files) joined
by the OS list separator (:), so it never clashes with the comma that
separates -layer entries. Two sentinel forms stand in for a stack:
NAME=@absent— a cluster with no composition in this env; references into it are structurally unresolvable and count as satisfied.NAME=@alias=BASE— a ClickHouseremote_serversalias (e.g.posthog_writable,batch_exports_primary_replica) that sharesBASE's composition; the remote resolves againstBASE(which may itself be mapped or@absent). This avoids re-listing the same stack under every alias.
hclexp validate -layer ./nodes/data \
-cluster aux=./nodes/aux \
-cluster ai_events=./base:./nodes/ai_events \
-cluster aux_writable=@alias=aux \
-cluster events_recent=@absentThe same mappings also resolve materialized-view and plain-view sources:
a SELECT ... FROM posthog.foo carries no cluster name, so a source missing
from the node is satisfied when foo is declared in any mapped cluster —
the co-located composition the server sees at runtime. @absent clusters
contribute no schema and never satisfy a view source (the table must really
exist somewhere); only Distributed remotes, which name their cluster
explicitly, are satisfied by @absent.
With no -cluster mapping an off-node remote is an error — a new
cross-cluster proxy can't be silently accepted: map its cluster, mark it
@absent, or -skip-validation the proxy. This lets a caller replace a
hand-maintained skip list with a generated cluster mapping that actually
checks the remotes exist.
Use -dump when the input is the directory produced by dump-cluster, rather
than a declared layer composition or role manifest:
# Validate every node and print the derived cluster map plus per-node findings.
hclexp validate -dump ./clickhouse-schema/prod-eu
# Select part of the topology, apply the normal validation controls, and emit
# stable structured output for CI.
hclexp validate -dump ./clickhouse-schema/prod-eu \
-glob '*[fg].hcl,*-offline.hcl' \
-exclude ./exclude.hcl \
-skip-validation legacy_proxy \
-strict-proxy-columns \
-format jsonA dump directory is not a layer stack. Each .hcl file is loaded and
resolved independently, so the same table declared by 58 peer nodes is not a
redefinition conflict. Validation then builds a topology index:
- Nodes are grouped by their
node.macros.clustervalue. - Each cluster exposes the union of object names declared by all of its members. This is a reference-resolution union, not a merged HCL schema; heterogeneous members can therefore contribute different objects.
- Referenced remote-server variants ending in
_writable,_single_shard, or_primary_replicaare inferred as aliases when their base cluster exists in the dump. - Explicit
-clusterflags are applied last and override the derived mapping, including@alias=BASEand@absent.
The text output prints this derived map before the per-node results. JSON emits
the same assumptions under clusters, including every mapping's source
(dump, inferred, or explicit), member nodes, and alias base. It also emits
per-node findings and an aggregate summary suitable for CI.
A cluster_name that has neither a member node, an inferred base, nor an
explicit mapping is an error. It is reported once under unmapped_clusters
with all referencing nodes, rather than repeated once for every Distributed
table. Nodes without macros.cluster are still validated, but are listed under
unclustered_nodes and contribute to no derived cluster. -glob,
-skip-validation, -strict-proxy-columns, -strict-clusters, and -exclude
all apply to dump mode.
Instead of listing every cluster as a flag, -manifest/-env derive the
mappings from the same role manifest plan/web consume. A ClickHouse cluster
is composed of nodes from one or more roles, so a cluster block lists its
member roles and its schema is their union for the selected env:
role "data" { env "prod-us" { layers = ["layers/base", "layers/env/prod-us"] } }
role "ingestion-events" { env "prod-us" { layers = ["layers/base", "layers/ingestion", "layers/env/prod-us"] } }
cluster "posthog" {
roles = ["data", "ingestion-events"]
aliases = ["posthog_writable", "posthog_single_shard"]
}
# A cluster with no composition in the manifest (modeled elsewhere) is declared
# absent instead of with roles; proxies into it resolve as satisfied.
cluster "events_recent" {
absent = true
}hclexp validate -layer ./nodes/data -manifest roles.hcl -env prod-us -layer-root .Each cluster resolves against the union of its member roles' compositions;
aliases resolve to their base. A cluster whose member roles aren't deployed in
the env (or that sets absent = true) is treated absent. Explicit -cluster
flags are applied last, so they override or extend the manifest (e.g.
-cluster events_recent=@absent). A cluster that references an undeclared role,
or sets both roles and absent, is rejected.
Pass -strict-clusters to forbid absence entirely: every Distributed remote
must resolve against a real composition, so a remote on an @absent cluster
becomes an error. Use it as the CI gate once the whole fleet is composed, so a
stale @absent (a cluster that has since been mapped) cannot silently pass.
With -manifest/-env and no -layer/-config, validate runs in
manifest-driven mode: it validates every role in the manifest for that env,
each against the cluster set derived from the whole manifest. One command checks
the entire environment instead of a shell loop over nodes; failures are prefixed
with the role they came from.
hclexp validate -manifest roles.hcl -env prod-us -layer-root .
# validation error: [role data] posthog.web_stats: Distributed table column ...Once a Distributed remote resolves to an inspectable table (locally or via a
-cluster mapping), the proxy's columns are checked against the remote storage
table. By default the proxy's columns must be a subset of the remote's:
every forwarded proxy column must exist on the remote with the same type and
nullability. The comparison is type-only — a proxy legitimately drops the
remote's CODEC / DEFAULT / TTL / COMMENT. ALIAS and EPHEMERAL
columns are computed or insert-only, not forwarded, and are ignored on both
sides.
A proxy column absent from the remote, or with a differing type, is an error —
this catches drift where the storage table renamed, retyped, or dropped a
column the proxy still exposes. Pass -strict-proxy-columns to also require the
reverse (the remote's columns must all exist on the proxy), i.e. an exact
mirror.
# default: proxy columns must be a subset of the remote's, types must match
hclexp validate -layer ./nodes/data -cluster aux=./nodes/aux
# strict: proxy and remote must have exactly the same forwarded columns
hclexp validate -layer ./nodes/data -cluster aux=./nodes/aux -strict-proxy-columnsThe comparison engine (diff, plan, drift) applies the same subset idea
to one narrow case: a Distributed proxy whose remote_database is system.
System tables are server-defined and gain columns with version bumps, so a
live proxy created from a fuller set routinely carries columns the layer
intentionally omits — column presence differences on such proxies are not
reported (in either direction), while columns declared on both sides still
compare fully. Non-system proxies keep exact column semantics.
hclexp diff -sql applies the same dependency knowledge to DDL ordering:
within the generated migration, a table is created before any
Distributed/MV/Dictionary that depends on it, and dropped after.
hclexp drift compares the per-node HCL dumps in a directory and reports
where nodes that should share a schema don't. It's built for fleet dumps
like prod/eu/*.hcl — one file per node, each carrying a node {} block
with that node's macros (see Introspect).
# Compare every node, grouped by the hostClusterRole macro
hclexp drift -dir prod/eu
# Compare just one pool via a filename glob
hclexp drift -dir prod/eu -glob '*ingestion-small*'
# Group by the deployment role parsed from the node name; show full diffs
hclexp drift -dir prod/eu -group-by role -detailsWithin each group every node is diffed — via the same engine as hclexp diff — against the lexically-first reference node, and a one-line
change summary is printed per drifting node. The command exits non-zero
when any drift is found, so it doubles as a CI guard.
Flags:
-dir— directory of per-node.hcldumps to compare (required)-glob— comma-separated filename globs selecting dumps within-dir(default*); a file is included if it matches any pattern. Use it to hand-pick exactly the nodes you want compared, e.g.'*ingestion-small*'for one pool, or'*-ch-*[fg].hcl,*-offline.hcl'to pull all DATA nodes (onlinef/greplicas plus offlineh) together into one comparison.-group-by— comma-separated keys to group nodes by (defaulthostClusterRole). Each key is looked up first in the node's macros, then as one of the pseudo-keysrole/shard/replicaparsed from the node name (prod-<region>-<az>-ch-<shard><replica>[-<role>]). Examples:-group-by role,-group-by hostClusterRole,hostClusterType.-zk-paths— how to treat ReplicatedMergeTreezoo_pathbefore diffing (defaultmask-uuid):mask-uuid— replace the literal table UUID with the{uuid}macro. ClickHouse expands{uuid}to the table's own UUID atCREATEtime (while keeping{shard}/{replica}as macros), so the same table on different shards otherwise looks like drift. Masking compares the intended path; genuine differences (e.g. a different database in the path) still drift.keep— compare paths verbatim (no normalization).ignore— blankzoo_path/replica_nameentirely.
-details— print the full change set of each drifting node against its group reference, instead of just the one-line summary-ignore-column-order— ignore table and materialized-view column order for the whole drift run; column names, types, and modifiers still compare
Example output:
group "ingestion" — 22 nodes, reference prod-eu-fra-ch-10a-ingestion-events — 6 drifting
✗ prod-eu-fra-ch-1a-ingestion-medium: +16 table, -8 table, +8 mv, -4 mv
✗ prod-eu-fra-ch-1a-ingestion-small: +25 table, -8 table, +10 mv, -4 mv
group "ops" — 2 nodes, reference prod-eu-fra-ch-1a-ops — OK (all identical)
summary: 58 nodes, 8 groups, 2 groups with drift, 28 drifting nodes
Tip:
hostClusterRoleis coarse — it can lump distinct pools together (e.g.ingestioncovers ingestion-events / -medium / -small, anddatacovers online and offline nodes).-group-by roleuses the finer deployment role from the node name and usually isolates genuine drift.
hclexp plan diffs every role in a manifest in one run and emits a single
globally-ordered, cross-role operation list —
storage tables before the Distributed/Buffer proxies that front them,
proxies before the MVs that read them — with roles provenance on every
operation. It has two deliberately separate current-state modes.
For managed convergence against a live topology, use -dump -scope desired:
hclexp plan -manifest manifest.hcl -env prod-us -layer-root ./schema \
-dump ./prod/us -scope desired -format json \
| jq '.operations[] | {order, kind, object_type, object, roles}'Desired state is each role's composed layer stack from the manifest;
current state is the matching node in the dump (nodes matched by their
hostClusterRole macro, replicas collapsed to one representative per
role; a role absent from the dump plans as all-CREATE). desired ignores
live-only objects but still creates missing managed objects and reconciles all
fields inside managed objects. The default -scope all is exact and may emit
DROPs for live-only objects. Desired scope cannot represent an intentional
deletion from the reference.
For a deterministic migration, compare the previous and proposed manifest
compositions exactly. Resolve the deployed revision into files (for example,
with a temporary Git worktree) and pass it as -from-manifest:
git worktree add /tmp/chschema-reference "$DEPLOYED_SHA"
hclexp plan \
-from-manifest /tmp/chschema-reference/schema/manifest.hcl \
-from-layer-root /tmp/chschema-reference/schema \
-manifest schema/manifest.hcl -layer-root schema -env prod-us \
-format json-from-manifest and -dump are mutually exclusive. A proposed-only role
plans as all-CREATE; a previous-only role is rejected because decommissioning
a role must be explicit. -format text prints the same ordered list
human-readably. Require the reference-scoped live drift check to be empty
immediately before applying this reference-to-proposed plan.
See Cross-role planning
in the reference for the manifest format, and
examples/manifest/ for a runnable
two-role × three-environment example.
hclexp locate answers "where does a name live?" across a manifest's
whole layer tree: every declaration site of each matching object
(tables, materialized views, views, dictionaries, named collections,
raw blocks), with its inheritance markers and the (role, env) stacks
whose layer lists include each declaring layer. It is query-only:
nothing is resolved or diffed. See
Locating declarations
for the full reference.
# Every declaration site of an object, plus where those layers deploy
hclexp locate -manifest manifest.hcl -layer-root ./schema posthog.events
# Globs match the bare name and the db.name qualified form; several
# patterns are independent existence checks (exit 1 if any finds nothing)
hclexp locate -manifest manifest.hcl -layer-root ./schema 'events*' person
# Also report which per-node dump files declare it
hclexp locate -manifest manifest.hcl -layer-root ./schema -dump prod/eu events
# Ad-hoc layer dirs or files, before a manifest exists (no placement info)
hclexp locate -layer roles/shared,roles/ops/prod events
# CI guard: every object defined at more than one site, even
# across layers that never co-compose into one stack
hclexp locate -manifest manifest.hcl -layer-root ./schema -duplicatesExample output:
table posthog.events
roles/shared/events.hcl:7 extends events_base
(ops, prod-us), (ops, prod-eu), (ingest, prod-us)
roles/ops/prod/events.hcl:2 [override]
(ops, prod-us), (ops, prod-eu)
roles/ops/prod/events.hcl:9 [patch_table]
(ops, prod-us), (ops, prod-eu)
dump: dumps/node1.hcl:2 (node prod-ch-1a)
Each site is marked when it is [abstract], an [override]
redeclaration, an object-specific [patch_*], an extends <parent> child,
or a [raw <kind>] block. An object extended by others lists its children
(extended by: ...), and each dump site names its node (from the dump's
node {} block, else the filename).
Flags:
-manifest— the same role manifestplan/validate/loadconsume. Unlike those commands there is no-env: locate scans the union of every layer named by any(role, env)stack and derives placement for all of them.-layer-root— root directory the manifest's layer paths resolve under (default.)-layer— comma-separated ad-hoc layer dirs or.hclfiles to search too (or instead of a manifest); resolved as given, no placement info, deduped against the manifest's layers-dump— directory of per-node.hcldumps (as written byintrospect/dump-cluster); reports which node files also declare each matching object-format—text(default) orjson(a{"patterns": [...], "objects": [...]}/{"duplicates": [...]}document with per-site file/line/layer/markers/placements)-duplicates— takes no name argument and requires-manifestor-layer(mutually exclusive with-dump); lists every object defined at more than one site and exits non-zero when any is found. Patch sites,override = trueredeclarations, and declarations carryingextendare refinements rather than definitions and do not count. Abstract declarations do count: copying the same abstract schema is still duplication. Reported groups retain every site, including refinements.
Exit codes: 0 on success, 1 when any pattern matches nothing (a
scriptable existence check) or -duplicates finds any, 2 on usage
errors.
hclexp web serves a read-only UI for resolved schemas — databases,
objects, their columns/engine/settings, dependency cross-links, and data
flows — with no cluster connection. The header lookup finds objects by a
case-insensitive partial name, qualified name, database, or kind.
# One schema, from a config file or a layer stack
hclexp web -layer ./schema/base,./schema/envs/us -addr :8080
# Every (env, role) a manifest composes: a schema list at /, each
# composition under /s/<env>/<role>/
hclexp web -manifest manifest.hcl -layer-root ./schema
# Every per-node schema in a dump directory: a node list at /, each dump
# kept independent under /n/<node>/
hclexp web -dump ../clickhouse-schema -glob '*.hcl'On the manifest or dump list page, lookup searches every schema and includes
the owning composition/node in each result. After opening one schema, the same
lookup box searches only that schema. Dump nodes are grouped by their cluster
macro, falling back to hostClusterRole; two files declaring the same node name
are rejected rather than silently combined. -dump is mutually exclusive with
the single-schema and manifest inputs, and -glob accepts the same
comma-separated filename patterns as drift.
The dump-node list links to Review object differences, a fleet-wide inventory of every qualified object found in the loaded dumps. It uses the same semantic diff as the CLI, summarizes uniform and differing schemas, groups nodes by semantic schema variant, and links each non-baseline variant to the full comparison view. Filters narrow the inventory by status or by database, kind, object, cluster, and node. Object presence follows an all-or-none rule per cluster. It is valid for an object to be absent from every loaded node of a cluster, because different clusters can legitimately contain different object sets. If the object exists on any node of a cluster, however, the review expects it on every loaded node of that cluster and reports the missing nodes as inconsistent presence. Presence inconsistencies are kept separate from semantic schema differences.
In dump mode, every object page (table, materialized view, view, dictionary, or
raw object) also shows all cluster/node dumps containing the same qualified
object, ordered by cluster and then node. The current node is the comparison
baseline; peers are marked same or different by semantic comparison of
their resolved HCL. Click different to open a unified HCL diff with the
baseline and peer node identified explicitly. The comparison can swap its two
sides and lists every dumped node sharing either semantic schema variant. For
tables, comparison uses the same default as drift: literal UUIDs expanded into
ReplicatedMergeTree ZooKeeper paths are masked, while real column, engine, key,
TTL, setting, index, projection, constraint, and comment differences remain
visible as schema drift.
The Ignore column order control applies to the fleet review, individual
object markers, comparison groups, and patch generation for the entire browser
session. hclexp web -dump ... -ignore-column-order makes that the initial
session default; the control can still change it without restarting the server.
Patch to uniform previews the pretty-printed SQL needed to change the
right-hand node's object to match the left-hand baseline, using the same
migration planner as hclexp diff -sql. It never executes SQL. The preview
must be reviewed against the live cluster before use; unsafe or
unexpressible changes are called out explicitly and require manual
reconciliation.
Materialized-view to_table values and Distributed remote_table values are
links when their targets resolve. In dump mode the browser derives the same
cluster unions and well-known aliases (_writable, _single_shard, and
_primary_replica) as validate -dump, so cross-cluster destinations and MV
read sources link to the matching node and resolve consistently in validation,
dependency lists, and data flows. MV write destinations remain local, matching
ClickHouse execution semantics.
Navigation links preserve object context throughout the UI: node names open the node schema, database names jump to stable database anchors, and object names open the exact object view. The cross-node comparison matrix exposes all three targets separately instead of making the node label stand in for the object.
The server auto-reloads on source edits: each request re-stats the source
files at most once per -reload-interval (default 2s; 0 disables) and
reloads when a mod time changes — a broken edit keeps serving the last good
schema. Existing dump files are reloaded the same way; restart the server to
discover added or removed dump files. In manifest mode -env filters to one
environment. Try it against examples/manifest/.
hclexp dump-sql captures a database's CREATE statements (the SHOW CREATE
equivalent) as a replayable SQL file, and a gated test recreates that schema
through the full hclexp round-trip and asserts every CREATE is byte-identical —
so you can dump production and verify it locally.
# Capture a production schema
hclexp dump-sql -host prod-ch -database posthog -user … -password … -out prod.sql
# Replay it on a local ClickHouse and verify hclexp round-trips it exactly
docker compose up -d
ROUNDTRIP_FIXTURE=$PWD/prod.sql go test ./test -run TestLive_RoundTripFidelity -clickhouseSee docs/roundtrip-fidelity.md for the full workflow and limitations.
You already know ClickHouse DDL; hclexp sql2hcl applies it to an HCL
schema and emits the updated HCL, so a change you'd naturally write as SQL
never has to be hand-translated into blocks:
printf 'ALTER TABLE posthog.events ADD COLUMN plan LowCardinality(String);\n' | \
hclexp sql2hcl -left ./schema -database posthog -in - -out ./resolved.hclSupported: CREATE TABLE/MATERIALIZED VIEW/VIEW/DICTIONARY (add or replace
by name), ALTER TABLE add/drop/modify/rename column, add/drop index,
TTL and settings changes, MODIFY QUERY, DROP …, and RENAME TABLE.
Data/partition operations (TRUNCATE, ALTER … DELETE, MATERIALIZE …)
are rejected — this is schema editing, not execution. Output is the
resolved (flat) schema; pair it with hclexp diff -sql to preview the
migration the edit implies. See
SQL → HCL edits
for the full statement list and flags.
hclexp github-token exchanges a GitHub App's credentials for a short-lived
(~1 h) installation access token and prints only the token to stdout, so
a workload can authenticate to GitHub as an App instead of a long-lived PAT. The
ops image already ships hclexp, so the JWT signing + token exchange happen in
Go — no openssl/shell needed in the (distroless) image.
# Private key from env (never touches disk); token captured by the caller
export GITHUB_APP_PRIVATE_KEY="$(cat app.private-key.pem)"
TOKEN=$(hclexp github-token -app-id 123456 -installation-id 7654321)
# …then push over HTTPS as x-access-token:$TOKENFlags:
-app-id— GitHub App ID (required)-installation-id— the App's installation ID on the target org/repo (required)-private-key-file— path to the App private key PEM; defaults to theGITHUB_APP_PRIVATE_KEYenv var (PKCS#1 or PKCS#8)-repo owner/name— optional: scope the token to a single repository and print the resolvedrepository_selection/permissionsto stderr
It builds an RS256 JWT (iss = App ID, exp ≤ 10 min) signed with the private
key, POSTs it to /app/installations/{id}/access_tokens, and prints the
resulting token. Diagnostics go to stderr and a failure exits non-zero, so
TOKEN=$(hclexp github-token …) is safe. HTTPS_PROXY/HTTP_PROXY are honoured
(api.github.com egresses through Smokescreen in our clusters).
hclexp connects in plaintext by default. To reach a TLS-only cluster
(typically port 9440), enable TLS via any of three equivalent forms:
| Form | Enable TLS | Skip cert verification |
|---|---|---|
hclexp introspect flag |
-secure |
-tls-skip-verify |
| Environment variable | CLICKHOUSE_SECURE=true |
CLICKHOUSE_TLS_SKIP_VERIFY=true |
clickhouse:// URL query |
?secure=true |
?skip-verify=true |
- Defaults are
false— existing invocations behave identically. -tls-skip-verify/?skip-verify=trueis only valid together with-secure/?secure=true; passing it alone is rejected to prevent silent misconfiguration.- For public-CA certs the default verification path uses the system
trust store;
-tls-skip-verifyis for internal/self-signed CAs.
# Introspect a TLS cluster with a private CA
hclexp introspect \
-host ch.prod.internal -port 9440 -user readonly \
-secure -tls-skip-verify \
-database posthog,system \
-out ./dump
# Diff local HCL against a TLS cluster
hclexp diff \
-left ./schema \
-right 'clickhouse://ro:secret@ch.prod.internal:9440/posthog?secure=true&skip-verify=true'Two multi-arch (linux/amd64, linux/arm64) images are built and pushed
on every main push and Git tag. Both bundle the same static hclexp
binary and share one tag stream each:
mainpush →main+sha-<short>+latestvX.Y.Ztag →X.Y.Z,X.Y,X
| Image | Base | Contents | Use |
|---|---|---|---|
ghcr.io/posthog/chschema |
distroless | hclexp only — no shell |
default; minimal runtime for diff/introspect |
ghcr.io/posthog/chschema-ops |
alpine:3.20 |
hclexp + sh, git, curl, ca-certificates |
deploy-time schema-dump hook (needs a shell to git-commit dumps) |
Both packages are published public (the repo is public), so an EKS
cluster can pull them without credentials. If a package is ever flipped to
private, consumers must attach an imagePullSecret holding a GHCR token
with read:packages:
kubectl create secret docker-registry ghcr-pull \
--docker-server=ghcr.io --docker-username=<gh-user> \
--docker-password=<PAT-with-read:packages>
# then reference it via the workload's imagePullSecrets / serviceAccountMinimal — distroless, no shell, no AWS CLI, no extras.
# Print usage
docker run --rm ghcr.io/posthog/chschema:latest -help
# Introspect into a host directory
docker run --rm \
-e CLICKHOUSE_HOST=ch.prod.internal -e CLICKHOUSE_PORT=9440 \
-e CLICKHOUSE_USER=readonly -e CLICKHOUSE_PASSWORD=secret \
-e CLICKHOUSE_SECURE=true -e CLICKHOUSE_TLS_SKIP_VERIFY=true \
-v "$PWD/dump:/dump" \
ghcr.io/posthog/chschema:latest \
introspect -database posthog,system -out /dumpIt can be paired with amazon/aws-cli (or any other uploader) via a shared
emptyDir volume — hclexp writes HCL to the volume, the sidecar ships it.
Same binary plus sh, git, and curl. It exists for the deploy-time
schema-dump hook: a Kubernetes Job in
posthog/charts (the
clickhouse-schema-dump PostSync hook) runs hclexp introspect against
each ClickHouse node and then git-commits the dumped HCL — which needs a
shell, git, and curl that the distroless image deliberately omits. The
ENTRYPOINT is still hclexp; override it (e.g. --entrypoint sh) to run
the surrounding dump-and-commit script.
docker run --rm --entrypoint sh ghcr.io/posthog/chschema-ops:latest -c \
'hclexp introspect -database posthog -out /work && cd /work && git ...'# Default (distroless)
docker build -t hclexp:dev .
docker run --rm hclexp:dev -help
# Ops image
docker build --target ops -t chschema-ops:dev .
docker run --rm --entrypoint sh chschema-ops:dev -c 'hclexp -h; git --version'A schema is one or more database blocks, each containing table blocks.
database "posthog" {
table "events" {
order_by = ["timestamp", "team_id"]
partition_by = "toYYYYMM(timestamp)"
sample_by = "team_id"
ttl = "timestamp + INTERVAL 2 YEARS"
settings = {
index_granularity = "8192"
}
column "timestamp" { type = "DateTime" }
column "team_id" { type = "UInt64" }
column "event" { type = "String" }
column "payload" {
type = "String"
codec = "ZSTD(3)"
comment = "raw event body"
}
index "idx_team" {
expr = "team_id"
type = "minmax"
granularity = 4
}
constraint "team_positive" {
check = "team_id > 0"
}
engine "replicated_merge_tree" {
zoo_path = "/clickhouse/tables/{shard}/events"
replica_name = "{replica}"
}
}
}type is required; everything else is optional:
nullable— wraptypeinNullable(...)default,materialized,ephemeral,alias— mutually exclusive default-value expressions (DEFAULT/MATERIALIZED/EPHEMERAL/ALIAS)codec— compression codec, e.g."ZSTD(3)"ttl— per-column TTL expressioncomment— column commentrenamed_from— previous column name; the diff engine emitsRENAME COLUMNinstead of drop + add
The engine block label is the engine kind. Supported kinds and their attributes:
| Kind | Attributes |
|---|---|
merge_tree |
— |
replicated_merge_tree |
zoo_path, replica_name |
replacing_merge_tree |
version_column, is_deleted_column |
replicated_replacing_merge_tree |
zoo_path, replica_name, version_column, is_deleted_column |
summing_merge_tree |
sum_columns |
replicated_summing_merge_tree |
zoo_path, replica_name, sum_columns |
collapsing_merge_tree |
sign_column |
replicated_collapsing_merge_tree |
zoo_path, replica_name, sign_column |
aggregating_merge_tree |
— |
replicated_aggregating_merge_tree |
zoo_path, replica_name |
distributed |
cluster_name, remote_database, remote_table, sharding_key |
log |
— |
kafka |
broker_list, topic, consumer_group, format |
A materialized_view block declares a TO-form materialized view — it
continuously transforms inserts and writes the result into a separate
destination table.
database "posthog" {
materialized_view "app_metrics_mv" {
to_table = "posthog.sharded_app_metrics"
query = "SELECT team_id, category FROM posthog.kafka_app_metrics"
column "team_id" { type = "Int64" }
column "category" { type = "LowCardinality(String)" }
}
}| Attribute | Required | Meaning |
|---|---|---|
to_table |
yes | destination table the MV writes into (TO <db.>table) |
query |
yes | the AS SELECT ... body |
column |
yes | the destination column list (name + type) |
cluster |
no | ON CLUSTER target |
comment |
no | view comment |
hclexp diff reports a changed query as an in-place ALTER TABLE ... MODIFY QUERY; a changed to_table or column list is flagged unsafe
because it needs the view dropped and recreated.
Not supported. These fail introspection with a clear error rather than being silently mishandled:
- inner-engine materialized views (
CREATE MATERIALIZED VIEW ... ENGINE = ...) - refreshable materialized views (
REFRESH EVERY|AFTER ...) - window views
A view block declares a ClickHouse plain (non-materialized) view — a
saved SELECT evaluated on every read of the view.
database "posthog" {
view "team_event_counts" {
query = "SELECT team_id, count() AS n FROM posthog.events GROUP BY team_id"
column_aliases = ["team_id", "n"]
sql_security = "definer"
definer = "alice"
cluster = "posthog"
comment = "team-level event counter"
}
}| Attribute | Required | Meaning |
|---|---|---|
query |
yes | the AS SELECT ... body (verbatim text) |
column_aliases |
no | CREATE VIEW v (a, b, ...) AS ... |
sql_security |
no | SQL SECURITY clause: definer, invoker, or none (canonical lowercase; case-insensitive on parse) |
definer |
no | DEFINER = <user> or DEFINER = current_user; only valid alongside sql_security = "definer" |
cluster |
no | ON CLUSTER target |
comment |
no | view comment |
hclexp diff reports a body change as in-place ALTER TABLE ... MODIFY QUERY; a comment-only change becomes ALTER TABLE ... MODIFY COMMENT;
any change to column_aliases / sql_security / definer / cluster
requires drop-and-recreate and is flagged unsafe.
Not supported. Live views, refreshable materialized views, and window views fail introspection with a clear error.
A dictionary block declares a ClickHouse dictionary — a key/value
lookup loaded from an external source (another ClickHouse table, a
relational database, an HTTP endpoint, a file, etc.) and queried at
runtime via dictGet* functions. Credentials do not belong in HCL; the
recommended form refers to a named collection provisioned on the target
cluster:
named_collection "exchange_rate_source" {
external = true
comment = "credentials are provisioned by the cluster deployment"
}
database "posthog" {
dictionary "exchange_rate_dict" {
primary_key = ["currency"]
lifetime { min = 3000 max = 3600 }
range { min = "start_date" max = "end_date" }
attribute "currency" { type = "String" }
attribute "start_date" { type = "Date" }
attribute "end_date" { type = "Nullable(Date)" }
attribute "rate" { type = "Decimal64(10)" }
source "clickhouse" {
collection = "exchange_rate_source"
query = "SELECT currency, start_date, end_date, rate FROM default.exchange_rate"
}
layout "complex_key_range_hashed" {
range_lookup_strategy = "max"
}
}
}| Block / attribute | Required | Meaning |
|---|---|---|
primary_key |
yes | single or composite key column names |
attribute |
yes | one per column (type + optional default / expression / hierarchical / injective / is_object_id) |
source |
yes | exactly one — see supported kinds below |
layout |
yes | exactly one — see supported kinds below |
lifetime |
no | { min = <s> max = <s> } (range form) or just { min = <s> } (simple LIFETIME(n)) |
range |
no | { min = "<col>" max = "<col>" } — only with range_hashed / complex_key_range_hashed layouts |
settings |
no | dictionary-level SETTINGS map |
cluster |
no | ON CLUSTER target |
comment |
no | dictionary comment |
Supported source kinds: clickhouse, mysql, postgresql, http, file, executable, null.
Supported layout kinds: flat, hashed, sparse_hashed, complex_key_hashed, complex_key_sparse_hashed, range_hashed, complex_key_range_hashed, cache, ip_trie, direct.
clickhouse, mysql, postgresql, and http sources accept an optional
collection attribute. It renders as ClickHouse's native NAME argument and
may be combined with non-secret overrides such as table or query:
SOURCE(CLICKHOUSE(NAME exchange_rate_source QUERY 'SELECT ...'))Kinds outside these lists error during introspection with unsupported dictionary source/layout kind: <name>. Adding a new kind is a small change — one typed struct + one switch case in dictionary_sources.go / dictionary_layouts.go.
Diff & apply. ClickHouse has no useful in-place ALTER DICTIONARY. hclexp diff reports any non-empty change with ~ dictionary <name> (changed: ...); -sql emits a CREATE OR REPLACE DICTIONARY statement, which is the idiomatic ClickHouse update path and is treated as safe when every required runtime value is resolvable.
The credential boundary is deliberately cluster-side. hclexp plan is an
ordered statement producer, not a secret provider, and its JSON is commonly
stored as a CI artifact. Neither HCL nor operation.sql should contain a
dictionary password.
The execution contract is:
- Before schema operations run, the deployment system creates the named collection on every target node, or makes it available through ClickHouse's Keeper-backed named-collection storage. It obtains the password from the environment's secret manager.
- HCL declares that collection with
external = true. This is a reference and ownership marker: hclexp validates the name but emits no CREATE/ALTER/DROP for the collection and never compares its values. - The dictionary source sets
collection = "...". hclexp emitsSOURCE(<KIND>(NAME <collection> ...))with only non-secret overrides. - The executor sends that DDL unchanged. ClickHouse resolves
NAMEagainst the collection already present on that node, so the password enters the dictionary source only inside ClickHouse.
For example, the cluster bootstrap—not a committed migration—may execute a secret-injected statement like:
CREATE NAMED COLLECTION exchange_rate_source AS
host = 'localhost', user = 'dict_reader', password = '<from secret manager>';The reviewed plan still contains only:
CREATE OR REPLACE DICTIONARY posthog.exchange_rate_dict (...)
SOURCE(CLICKHOUSE(NAME exchange_rate_source QUERY 'SELECT ...'))
LAYOUT(...);Important operational details:
- The collection is a precondition. Because
external = trueemits no DDL, a missing collection makes ClickHouse reject the dictionary statement rather than silently creating a passwordless source. - Per-node execution means the collection must be available on every node that receives the dictionary DDL. Keeper-backed storage is the convenient shared option; server XML/config or per-node bootstrap also works.
- The execution user needs
NAMED COLLECTION ON <name>access in addition to its dictionary DDL privileges. - Source arguments after
NAMEare named-collection overrides. ClickHouse allows them by default; ifallow_named_collection_override_by_defaultis disabled, declare those keys overridable in the cluster-side collection or put them in the collection itself. clickhouse,mysql,postgresql, andhttpdictionary sources support this form.file,executable, andnullhave no connection password.
An inline live dictionary may introspect as password = "[HIDDEN]". If HCL
merely omits that password, hclexp reports an unsafe-only difference and emits
no replacement DDL—the safe fallback from #178. To migrate it, provision the
external collection and add collection = "..."; the resulting source change
is executable because ClickHouse, rather than hclexp, can resolve the secret.
When an object's CREATE DDL can't be parsed, or uses an engine/form the HCL
model doesn't express, capture it verbatim in a raw block. The two labels
mirror Terraform's resource "<type>" "<name>" — first the kind
(table, materialized_view, view, or dictionary), then the name:
database "posthog" {
raw "dictionary" "city_postal_ip_trie" {
sql = <<SQL
CREATE DICTIONARY posthog.city_postal_ip_trie (`prefix` String)
PRIMARY KEY prefix
SOURCE(CLICKHOUSE(USER 'reader' QUERY 'SELECT prefix FROM s3(...)'))
LIFETIME(MIN 0 MAX 3600)
LAYOUT(IP_TRIE)
SQL
}
}Raw objects are opaque: hclexp diff compares the stored sql as text and
renders + / - / ~ raw <kind> <name>; -sql emits the sql verbatim to
create and a DROP + CREATE to change. Recreating a view/dictionary/MV is
lossless; a table-kind change is flagged -- UNSAFE and its
destructive DDL is not auto-generated. A declared raw block also satisfies
dependency references to it (an MV to_table, a Distributed remote_table).
hclexp introspect/dump-cluster only emit raw blocks under -allow-raw;
otherwise an object they can't express is a hard error. Full reference:
docs/README.hcl.md#raw.
A named_collection block declares a ClickHouse named collection —
cluster-scoped key/value bags that other objects (most notably Kafka
tables) can reference by name. Named collections sit at the top level
of the HCL document, next to database blocks rather than inside one —
they're cluster-scoped, not database-scoped.
named_collection "my_kafka" {
cluster = "posthog"
param "kafka_broker_list" { value = "kafka:9092" }
param "kafka_topic_list" { value = "events" }
param "kafka_group_name" { value = "ch_events" }
param "kafka_format" { value = "JSONEachRow" }
}
database "posthog" {
table "events_kafka" {
column "team_id" { type = "Int64" }
column "payload" { type = "String" }
engine "kafka" { collection = "my_kafka" }
}
}| Block / attribute | Required | Meaning |
|---|---|---|
external |
no | true marks an NC managed outside hclexp (e.g. server XML config); hclexp emits no DDL for it but lets Kafka and dictionary-source references resolve. |
cluster |
no | ON CLUSTER target. Changing it forces a DROP+CREATE recreate. |
comment |
no | NC comment. |
param |
yes (unless external = true) |
one per key, with required value and optional overridable boolean. |
Diff & apply. hclexp diff uses ALTER NAMED COLLECTION ... SET / DELETE for surgical param changes and a DROP+CREATE pair (emitted adjacently) when cluster changes. A collection marked external = true on either side is ignored: external is an ownership boundary, not a property introspection can recover from the live object.
Production secret pattern. Secret-bearing collections should be external: the cluster deployment obtains their values from its secret manager, while HCL commits only their names and references. Managed named collections remain useful for non-secret settings.
Externally-managed NCs (PostHog-style XML config). When a collection is defined in the ClickHouse server's XML config rather than via DDL, declare it in HCL with external = true:
named_collection "kafka_main" {
external = true
comment = "managed in /etc/clickhouse-server/config.d/kafka.xml"
}hclexp emits no DDL for external collections, but their declaration makes
Kafka engine and dictionary source collection = "..." references resolvable
and validatable at parse time.
Privilege & redaction caveat. ClickHouse redacts named-collection values to [HIDDEN] for users without SHOW_NAMED_COLLECTIONS_SECRETS. In production, declare secret-bearing collections external so comparison never needs their values. Use -show-secrets only for controlled debugging or export workflows; it writes plaintext secrets to the output.
engine "kafka" { ... } accepts either a collection reference or a complete inline set of kafka_* settings — never both. The inline form is the canonical preferred shape, modeling every documented kafka_* setting as a typed HCL attribute (numbers, booleans, strings) with an extra escape map for settings ClickHouse adds in versions hclexp doesn't yet model:
engine "kafka" {
// option A: named collection reference (no other field allowed)
collection = "my_kafka"
}
engine "kafka" {
// option B: full inline (canonical Kafka() + SETTINGS form)
broker_list = "kafka:9092"
topic_list = "events"
group_name = "ch_events"
format = "JSONEachRow"
num_consumers = 4
max_block_size = 1048576
commit_on_select = false
skip_broken_messages = 100
handle_error_mode = "stream"
sasl_mechanism = "PLAIN"
sasl_username = "ch"
sasl_password = "[set via override layer]"
extra = {
kafka_some_future_setting = "passthrough"
}
}Field names drop the kafka_ prefix (implicit inside engine "kafka"). The extra map carries any setting that doesn't have a typed field; its keys must include the kafka_ prefix.
Layers let a base schema be specialized per environment. -layer a,b,c
loads every .hcl file under each directory in order; later layers merge
on top of earlier ones.
This is the central mechanic of hclexp. Authored HCL may be spread across layers, inheritance parents, child-local column specializations, and environment patches, but the desired schema is always reduced to one flat table by the same rule:
Before resolving inheritance, hclexp merges the selected layers in their
declared order. A later override = true replaces the earlier declaration;
patch blocks are collected rather than applied during that merge. The resolver
then evaluates the table graph below.
resolved(table) =
apply that table's patch_table blocks, in layer order, to
merge the table's declaration with its fully resolved parent
More explicitly, resolving a table performs these steps:
- Resolve its parent recursively. If the table has
extend, the parent is completed first—including everypatch_tableaddressed to that parent. Resolution is dependency-driven, so file/declaration order does not change the result. - Build the child from the resolved parent and its own declaration. Parent
columns come first. Child-local
patch_columnblocks partially specialize inherited columns in place; ordinary childcolumnblocks then append. Parent and child indexes are combined similarly. Inheritable scalar fields such asengine,order_by, andsettingsflow from the parent only when the child does not declare its own value. - Apply
patch_tableblocks addressed to this table. Patches run in layer order, and each patch sees the result of the previous one. At this point the complete inherited shape exists, somodify_column/drop_columnscan target inherited columns and positioned column/index additions can use inherited names inafter. - Publish the completed table to its descendants. A child extending this table inherits the patched result. A sibling does not: it resolves through its own parent path.
- Discard resolution-only syntax. Once the graph is resolved,
extend,abstract,patch_column, and patch blocks are consumed; abstract tables are removed, and the emitted desired schema is flat.
There is no abstract-versus-concrete exception. Abstract tables participate in the same algorithm and are removed only after their descendants have inherited their resolved shapes. Likewise, a concrete table may be extended, and its descendants inherit its completed patched shape.
The important propagation rule is therefore:
patch parent → parent and every descendant
patch child → child and that child's descendants
sibling → unaffected
For example, consider this inheritance graph:
_event_base (abstract)
├── events
└── sharded_events
└── regional_events
It can be authored and specialized across layers like this:
# base layer
database "posthog" {
table "_event_base" {
abstract = true
column "uuid" { type = "UUID" }
column "event" { type = "String" }
column "properties" { type = "String" }
}
table "events" {
extend = "_event_base"
order_by = ["uuid"]
engine "merge_tree" {}
}
table "sharded_events" {
extend = "_event_base"
order_by = ["uuid"]
patch_column "uuid" { codec = "ZSTD(1)" }
engine "merge_tree" {}
}
table "regional_events" {
extend = "sharded_events"
}
}
# environment layer
database "posthog" {
patch_table "sharded_events" {
modify_column "properties" {
type = "String"
materialized = "lower(event)"
}
column "region" {
type = "LowCardinality(String)"
after = "event"
}
}
}The resolved output has these effective shapes:
| Table | Effective columns |
|---|---|
events |
uuid, event, properties |
sharded_events |
uuid CODEC(ZSTD(1)), event, region, properties MATERIALIZED lower(event) |
regional_events |
the completed sharded_events shape, including region and the materialized properties |
_event_base |
not emitted (abstract = true) |
This ordering is also why patch_column and modify_column are different:
patch_column is a partial, child-local overlay applied while inheritance is
being merged; modify_column is a full column replacement from a cross-layer
patch_table, applied after the target's inherited shape exists.
The resolver enforces several safety invariants around this model:
- Inheritance copies the resolved parent shape into the child; specializing or patching a child never mutates its parent or siblings.
- A normal child
column/indexblock is an addition. Reusing an inherited name is an error; usepatch_columnfor a partial inherited-column overlay orpatch_table.modify_columnfor a full cross-layer replacement. - Unknown parents, inheritance cycles, unknown patch targets, missing
modify/drop targets, and invalid
afterreferences fail resolution rather than silently producing a different shape. - Patches keep layer order. A later patch observes and may deliberately build on earlier patches for the same table.
- Downstream diffing and SQL generation see only the final flat tables—not inheritance or patch control syntax.
Table inheritance within a database:
abstract = true— a template table that is not emitted itselfextend = "other_table"— declare a new table inheriting another's shape: inherited columns may be specialized with partialpatch_columnblocks, then ordinary columns and indexes append (collisions error);engine,order_by,partition_by,sample_by,ttl, andsettingsare inherited only if the child doesn't set its own — a child that does set one replaces it wholesale (a one-keysettingsmap loses every inherited key).primary_key,comment,cluster, constraints, and projections never flow throughextend.override = true— required for a later layer to replace a table or materialized view that an earlier layer already defined
Rule of thumb: extend is for different tables sharing a shape
(events_local / events_distributed); patch_table (below) is for the
same table adjusted by one layer — it adds no declaration and its
settings merge instead of replacing. The full decision guide is in
docs/README.hcl.md.
database "posthog" {
table "_event_base" {
abstract = true
column "timestamp" { type = "DateTime" }
column "team_id" { type = "UInt64" }
}
table "events_local" {
extend = "_event_base"
order_by = ["timestamp", "team_id"]
patch_column "timestamp" { codec = "Delta(4), ZSTD(1)" }
column "event" { type = "String" }
engine "merge_tree" {}
}
}patch_column is child-local inheritance specialization: it targets a column
from extend, preserves every omitted field and the inherited position, and
is consumed during resolution. This is useful when the storage table needs a
CODEC, TTL, comment, default, type, or nullability variation that a sibling
Distributed table should not inherit. It is distinct from patch_table, which
modifies the same named table across layers. An ordinary column block still
means “add” and still errors if its name collides with an inherited column.
Patching — a patch_table block modifies a table declared elsewhere,
so the table itself stays declared once and an environment layer carries
just its delta: columns (add — appended or positioned with
after/first — plus modify_column / drop_columns), indexes
(add — likewise positioned — / drop_indexes),
projections (add),
order_by/partition_by/sample_by/ttl
(replace when set), the engine block (wholesale replace — e.g. a
Distributed target that moves with the env's topology), and settings
(merged, patch wins per key):
# env_us/events_patch.hcl
database "posthog" {
patch_table "events_local" {
column "us_session_id" { type = "String" }
settings = { default_compression_codec = "lz4" }
}
}Tables resolve parent-first, then apply their own patches. A patch can therefore modify/drop inherited columns or position additions after inherited columns/indexes. Any descendant inherits that completed result, while siblings remain on their own inheritance path. Abstract and concrete tables follow the same rule.
patch_materialized_view does the same for MV queries and output columns;
its column operations run after extend, so inherited columns are patchable.
patch_view and patch_dictionary cover views (query, comment) and
dictionaries (source/layout/lifetime replace, settings merge). See the
patch_table reference.
After resolution, extend / abstract / patch blocks are all consumed
and every table is flat with its effective columns, engine, and settings.
# Build
go build -o hclexp ./cmd/hclexp
# Unit + snapshot tests
just test
# Live ClickHouse integration tests (needs: docker compose up -d)
just test-live
# Install the repo's pre-commit hook (gofmt + go vet) — one-time per checkout
just setup-hooksThe pre-commit hook lives at .githooks/pre-commit and is opt-in:
just setup-hooks sets core.hooksPath to .githooks. It rejects a
commit whose staged Go files aren't gofmt'd or whose module fails
go vet, and prints the exact command to fix.
See CLAUDE.md for repository conventions and justfile for the full
list of recipes.
MIT — see LICENSE.
chschema links against HCL (MPL-2.0)
and clickhouse-go (Apache-2.0);
their licenses apply to those components.