Schema DSL Reference
Schema files export definitions using functions from @chkit/core (TypeScript) or chkit (Python, via chkit-py). All exported definitions are collected when chkit loads schema files matched by the schema glob in your configuration. The two implementations share every field’s semantics — pick your language once and the whole page follows.
import { schema, table, view, materializedView, dictionary } from '@chkit/core'from chkit import schema, table, view, materialized_view, dictionaryschema()
Section titled “schema()”Groups definitions into a single array for export.
export default schema(users, events)Any exported value with a valid kind is also discovered automatically.
definitions = schema(users, events)Any module-level definition is also discovered automatically, including definitions nested in lists or tuples.
table()
Section titled “table()”Creates a table definition.
Minimal example:
import { schema, table } from '@chkit/core'
const users = table({ database: 'app', name: 'users', columns: [ { name: 'id', type: 'UInt64' }, { name: 'email', type: 'String' }, ], engine: 'MergeTree', primaryKey: ['id'], orderBy: ['id'],})
export default schema(users)from chkit import schema, table
users = table( database="app", name="users", columns=[ {"name": "id", "type": "UInt64"}, {"name": "email", "type": "String"}, ], engine="MergeTree", primary_key=["id"], order_by=["id"],)
definitions = schema(users)Comprehensive example (all features):
const events = table({ database: 'analytics', name: 'events', columns: [ { name: 'id', type: 'UInt64' }, { name: 'org_id', type: 'String' }, { name: 'source', type: 'LowCardinality(String)' }, { name: 'payload', type: 'String', nullable: true }, { name: 'received_at', type: 'DateTime64(3)', default: { expression: 'now64(3)' } }, { name: 'status', type: 'String', default: 'pending', comment: 'Event processing status' }, ], engine: 'MergeTree', primaryKey: ['id'], orderBy: ['org_id', 'received_at', 'id'], partitionBy: 'toYYYYMM(received_at)', ttl: 'received_at + INTERVAL 90 DAY', settings: { index_granularity: 8192 }, indexes: [ { name: 'idx_source', expression: 'source', type: 'set', maxRows: 0, granularity: 1 }, ], projections: [ { name: 'p_recent', query: 'SELECT id ORDER BY received_at DESC LIMIT 10' }, ], comment: 'Raw ingested events',})events = table( database="analytics", name="events", columns=[ {"name": "id", "type": "UInt64"}, {"name": "org_id", "type": "String"}, {"name": "source", "type": "LowCardinality(String)"}, {"name": "payload", "type": "String", "nullable": True}, {"name": "received_at", "type": "DateTime64(3)", "default": "fn:now64(3)"}, {"name": "status", "type": "String", "default": "pending", "comment": "Event processing status"}, ], engine="MergeTree", primary_key=["id"], order_by=["org_id", "received_at", "id"], partition_by="toYYYYMM(received_at)", ttl="received_at + INTERVAL 90 DAY", settings={"index_granularity": 8192}, indexes=[ {"name": "idx_source", "expression": "source", "type": "set", "maxRows": 0, "granularity": 1}, ], projections=[ {"name": "p_recent", "query": "SELECT id ORDER BY received_at DESC LIMIT 10"}, ], comment="Raw ingested events",)Required fields
Section titled “Required fields”| Field | Type | Description |
|---|---|---|
database | string | ClickHouse database name |
name | string | Table name |
columns | ColumnDefinition[] | Column definitions (see Columns) |
engine | string | Engine clause, e.g. 'MergeTree', 'ReplacingMergeTree(ver)' |
primaryKey | string[] | Primary key columns or expressions, e.g. ['toDate(ts)', 'id'] |
orderBy | string[] | ORDER BY columns or expressions, e.g. ['toStartOfHour(ts)', 'id'] |
Optional fields
Section titled “Optional fields”For Kafka tables, omit primaryKey and orderBy. Kafka also
rejects storage clauses (partitionBy, uniqueKey, ttl, indexes, projections)
and column defaults. Its setting strings are escaped SQL literals.
| Field | Type | Description |
|---|---|---|
partitionBy | string | Partition expression, e.g. 'toYYYYMM(created_at)' |
uniqueKey | string[] | Unique key columns |
ttl | string | TTL expression, e.g. 'created_at + INTERVAL 90 DAY' |
settings | Record<string, string | number | boolean> | Table-level settings |
indexes | SkipIndexDefinition[] | Skip indexes (see Skip indexes) |
projections | ProjectionDefinition[] | Projections (see Projections) |
comment | string | Table comment |
renamedFrom | { database?: string; name: string } | Previous identity for rename tracking (see Rename support) |
plugins | TablePlugins | Per-table plugin configuration (see Plugin configuration) |
Columns
Section titled “Columns”Each entry in the columns array is a ColumnDefinition.
name (string, required)
Section titled “name (string, required)”Column name.
type (string, required)
Section titled “type (string, required)”Any ClickHouse type string. Parameterized types like DateTime64(3), Decimal(18, 4), Enum8('a' = 1, 'b' = 2), and FixedString(32) are supported.
Primitive types recognized by the DSL type system: String, UInt8, UInt16, UInt32, UInt64, UInt128, UInt256, Int8, Int16, Int32, Int64, Int128, Int256, Float32, Float64, Bool, Boolean, Date, DateTime, DateTime64.
SQL-standard aliases
Section titled “SQL-standard aliases”chkit passes the type string through to ClickHouse verbatim — it does not rewrite it. ClickHouse itself accepts standard SQL type aliases and stores them as its native types, so a table declared with aliases like BIGINT or TEXT is created successfully:
| SQL alias | ClickHouse native type |
|---|---|
TINYINT | Int8 |
SMALLINT | Int16 |
INTEGER / INT | Int32 |
BIGINT | Int64 |
FLOAT / REAL | Float32 |
DOUBLE | Float64 |
TEXT / VARCHAR / CHAR | String |
TIMESTAMP | DateTime |
See the ClickHouse data types reference for the complete alias list.
nullable (boolean, optional)
Section titled “nullable (boolean, optional)”When true, the column type is wrapped in Nullable(...) in the generated SQL.
{ name: 'payload', type: 'String', nullable: true }// SQL: `payload` Nullable(String){"name": "payload", "type": "String", "nullable": True}# SQL: `payload` Nullable(String)default (string | number | boolean | SQLExpression, optional)
Section titled “default (string | number | boolean | SQLExpression, optional)”The value of the column’s DEFAULT clause, or of the clause that defaultKind names: a literal value, or a SQL expression that ClickHouse evaluates for each row.
| Value | Meaning | Example | Renders |
|---|---|---|---|
string | Literal, single-quoted, with quotes and backslashes escaped | default: 'pending' | DEFAULT 'pending' |
number / boolean | Literal, as written | default: 0 | DEFAULT 0 |
{ expression: string } | SQL expression | default: { expression: 'now64(3)' } | DEFAULT now64(3) |
Use { expression } for anything ClickHouse evaluates: function calls, arithmetic, casts, and references to other columns.
columns: [ { name: 'status', type: 'String', default: 'pending' }, // SQL: `status` String DEFAULT 'pending' { name: 'received_at', type: "DateTime64(3, 'UTC')", default: { expression: 'now64(3)' } }, // SQL: `received_at` DateTime64(3, 'UTC') DEFAULT now64(3)]columns=[ {"name": "status", "type": "String", "default": "pending"}, # SQL: `status` String DEFAULT 'pending' {"name": "received_at", "type": "DateTime64(3, 'UTC')", "default": "fn:now64(3)"}, # SQL: `received_at` DateTime64(3, 'UTC') DEFAULT now64(3)]chkit-py does not accept the {"expression": ...} form or run the column_default_looks_like_expression and column_default_invalid checks yet. Write expression defaults with the fn: prefix.
An expression may span lines and contain SQL comments. chkit removes the comments when it writes the clause, so a -- comment cannot hide the rest of the column definition, and keeps the rest of the text as written. An unterminated string, quoted identifier, or block comment would swallow the rest of the migration, so chkit generate rejects it with column_default_invalid, as it does a # that is not followed by a space or !, which ClickHouse cannot parse. chkit/meta/snapshot.json stores the expression with its comments, so editing only a comment plans a MODIFY COLUMN that sets the same default.
chkit drift and chkit check compare defaults with the live default_expression token by token, ignoring whitespace, comments, outer parentheses, and quotes around plain identifiers. A string stays a literal, so default: 'now()' drifts against a live DEFAULT now().
The fn: prefix. A string that starts with fn: is the original spelling of an expression default and keeps working: default: 'fn:now64(3)' is equivalent to default: { expression: 'now64(3)' }. chkit/meta/snapshot.json stores both as "fn:now64(3)", so switching between them generates no migration. Because of the prefix, a string literal cannot start with fn:; to store such a value, write the quoted literal as an expression: default: { expression: "'fn:abc'" }.
defaultKind (optional)
Section titled “defaultKind (optional)”Choose DEFAULT (the implicit default), MATERIALIZED, ALIAS, or EPHEMERAL.
Python also accepts default_kind. The default
field holds the value or expression for every kind: strings remain SQL literals, and
{ expression } marks a SQL expression (fn: in chkit-py).
| Kind | Behavior |
|---|---|
DEFAULT | Stored; the expression applies when the insert omits the value. |
MATERIALIZED | Computed on insert and stored; cannot be supplied in a normal insert. |
ALIAS | Computed when explicitly selected; neither stored nor insertable. |
EPHEMERAL | Input for other column expressions; neither stored nor selectable. |
columns: [ { name: 'ts', type: 'DateTime' }, { name: 'day', type: 'Date', defaultKind: 'MATERIALIZED', default: { expression: 'toDate(ts)' } }, { name: 'label', type: 'String', defaultKind: 'ALIAS', default: { expression: 'toString(day)' } }, { name: 'raw', type: 'String', defaultKind: 'EPHEMERAL' }, { name: 'size', type: 'UInt64', default: { expression: 'length(raw)' } },]columns=[ {"name": "ts", "type": "DateTime"}, {"name": "day", "type": "Date", "default_kind": "MATERIALIZED", "default": "fn:toDate(ts)"}, {"name": "label", "type": "String", "default_kind": "ALIAS", "default": "fn:toString(day)"}, {"name": "raw", "type": "String", "default_kind": "EPHEMERAL"}, {"name": "size", "type": "UInt64", "default": "fn:length(raw)"},]MATERIALIZED and ALIAS compute their value, so they require a default, and a
plain string fails with column_expression_requires_fn: default: 'toDate(ts)' would
render the quoted literal MATERIALIZED 'toDate(ts)' instead of SQL. Write
{ expression: 'toDate(ts)' }, or { expression: "'text'" } for a constant string
(fn:toDate(ts) and fn:'text' in the legacy spelling, which chkit-py uses). Numbers
and booleans render as written. EPHEMERAL may omit default and keeps a plain string
as a literal.
ClickHouse normally excludes all three from SELECT * and accepts EPHEMERAL values
only through an explicit insert column list. Keep the base type in type; do not
embed MATERIALIZED ... in the type string.
Changing a DEFAULT or MATERIALIZED expression, or switching between those two
kinds, emits MODIFY COLUMN with a warning that stored values are not rewritten.
Removing an expression emits REMOVE DEFAULT or REMOVE MATERIALIZED as its own
statement, before any type change in the same migration. Converting
a column to or from ALIAS or EPHEMERAL fails chkit generate with
column_kind_change_unsupported instead of dropping and recreating the column.
ClickHouse stores no data for ALIAS and EPHEMERAL columns, so validation rejects
them where a stored column is required. Use a MATERIALIZED column instead.
| Code | Rejected use |
|---|---|
column_kind_not_stored | Named directly in orderBy, primaryKey, partitionBy, or an engine argument such as ReplacingMergeTree(ver); for EPHEMERAL, also a skip index on the bare column. |
column_ephemeral_in_projection | An EPHEMERAL column read by a projection. ClickHouse can accept the table and then fail every insert. |
column_kind_codec_unsupported | A codec on an ALIAS column, or on an EPHEMERAL column without a value or comment. |
References inside expressions, such as toStartOfDay(day) or a TTL, are left to
ClickHouse to report at migrate time.
Codegen emits separate read and insert types for these tables (see Tables with generated columns), and the backfill plugin checks the target’s column kinds before an automatic backfill (see Target safety checks).
comment (string, optional)
Section titled “comment (string, optional)”Column-level comment rendered in SQL.
renamedFrom (string, optional)
Section titled “renamedFrom (string, optional)”Previous column name for rename tracking. See Rename support.
codec (ColumnCodecSpec, optional)
Section titled “codec (ColumnCodecSpec, optional)”Sets the column compression codec, rendered as a CODEC(...) clause. A codec is an object with a kind, or an array forming a chain (zero or more preprocessors followed by exactly one general codec).
columns: [ { name: 'ts', type: 'DateTime64(3)', codec: { kind: 'Delta', size: 4 } }, { name: 'amount', type: 'Float64', codec: { kind: 'ZSTD', level: 3 } }, // chain: preprocessor then general codec { name: 'seq', type: 'UInt64', codec: [{ kind: 'DoubleDelta' }, { kind: 'LZ4HC', level: 9 }] },]columns=[ {"name": "ts", "type": "DateTime64(3)", "codec": {"kind": "Delta", "size": 4}}, {"name": "amount", "type": "Float64", "codec": {"kind": "ZSTD", "level": 3}}, # chain: preprocessor then general codec {"name": "seq", "type": "UInt64", "codec": [{"kind": "DoubleDelta"}, {"kind": "LZ4HC", "level": 9}]},]General codecs (the compressor; at most one, and it must come last in a chain):
kind | Args | Renders |
|---|---|---|
NONE, LZ4, T64, GCD, ALP | — | CODEC(LZ4) |
LZ4HC | level?: number | CODEC(LZ4HC(9)) |
ZSTD | level?: number | CODEC(ZSTD(3)) |
Preprocessing codecs (placed before the general codec):
kind | Args | Renders |
|---|---|---|
Delta, DoubleDelta, Gorilla | size?: 1 | 2 | 4 | 8 (bytes, defaults to 1) | CODEC(Delta(4)) |
FPC | level: number, floatSize: 4 | 8 | CODEC(FPC(...)) |
Raw escape hatch — for codecs not yet typed (new ClickHouse versions, unusual arg shapes), pass the inner expression through verbatim:
{ name: 'blob', type: 'String', codec: { kind: 'raw', expression: 'T64, LZ4' } }// → CODEC(T64, LZ4){"name": "blob", "type": "String", "codec": {"kind": "raw", "expression": "T64, LZ4"}}# → CODEC(T64, LZ4)Codec chains are validated (see Validation rules): a chain must be non-empty, contain at most one general codec, and end with the general codec.
Skip indexes
Section titled “Skip indexes”Each entry in the indexes array is a SkipIndexDefinition. The shared base fields are:
| Field | Type | Description |
|---|---|---|
name | string | Index name |
expression | string | Indexed expression |
type | 'minmax' | 'set' | 'bloom_filter' | 'tokenbf_v1' | 'ngrambf_v1' | 'text' | Index type |
granularity | number | Required for other indexes; optional and ignored for text, which always uses 100000000 |
Type-specific fields:
| Type | Required fields | Optional fields | Notes |
|---|---|---|---|
minmax | — | — | No arguments |
set | maxRows: number | — | maxRows: 0 stores all unique values (ClickHouse 26+ requires set(0) rather than bare set) |
bloom_filter | — | falsePositiveRate: number | Defaults to 0.025 when omitted |
tokenbf_v1 | sizeBytes, hashFunctions, randomSeed (all number) | — | Maps to tokenbf_v1(size_bytes, n_hash, seed) |
ngrambf_v1 | ngramSize, sizeBytes, hashFunctions, randomSeed (all number) | — | Maps to ngrambf_v1(n, size_bytes, n_hash, seed) |
Full-text indexes
Section titled “Full-text indexes”Use type: 'text' on ClickHouse 26.2 or newer. tokenizer is a required SQL
expression, such as splitByNonAlpha, ngrams(3), or splitByString([' ', ';']).
Quoted whitespace, Unicode, and escaped characters retain their meaning through
generation, pull, and drift checks. Granularity is automatic: ClickHouse indexes
an entire part and ignores any supplied granularity.
| Field | Type | Meaning |
|---|---|---|
tokenizer | string | Required SQL tokenizer |
preprocessor | string | Optional SQL expression applied before tokenization |
postprocessor | string | Optional SQL expression applied to each token; requires server support |
supportPhraseSearch | boolean | Store token positions; requires server support and the table setting allow_experimental_text_index_phrase_search: 1 |
dictionaryBlockSize | number | Positive integer dictionary block size |
dictionaryBlockFrontcodingCompression | boolean | Enable or disable dictionary front coding |
postingListBlockSize | number | Positive integer posting-list block size |
postingListCodec | 'none' | 'bitpacking' | Posting-list compression |
indexes: [{ name: 'idx_body', expression: 'body', type: 'text', tokenizer: "splitByString([' ', ';'])", preprocessor: 'lower(body)',}]Python accepts the same dictionary fields, or
SkipIndexText(name="idx_body", expression="body", tokenizer="splitByNonAlpha").
Snake-case names such as posting_list_codec are also accepted.
Basic text indexes and tuning options are tested against ClickHouse 26.3 and 26.8.
The newer postprocessor and supportPhraseSearch options are exercised on 26.8;
26.2 availability of the index does not imply availability of every later option.
ClickHouse remains responsible for validating tokenizer/function availability and
server-specific parameter limits. Pull fails with an explicit error for unknown
text-index parameters instead of silently discarding them.
Avoid column names that are also SQL literals (true, false, inf, infinity,
or nan) in text-index expressions on older servers. ClickHouse 26.3 can remove
their required identifier quotes from index metadata, preventing a lossless pull.
chkit treats meaningful quote differences as drift; it does not assume a column
reference and a literal are equivalent. Ordinary identifier quoting and switching
between backticks and double quotes do not require an index rebuild.
Adding or changing an index does not automatically index historical parts. Run
ALTER TABLE database.table MATERIALIZE INDEX idx_body when historical data must
be indexed; materialization consumes database resources. Existing rows remain
queryable before materialization.
indexes: [ { name: 'idx_source', expression: 'source', type: 'set', maxRows: 0, granularity: 1 }, { name: 'idx_ts', expression: 'received_at', type: 'minmax', granularity: 3 }, { name: 'idx_body', expression: 'body', type: 'tokenbf_v1', sizeBytes: 256, hashFunctions: 2, randomSeed: 0, granularity: 1, },]indexes=[ {"name": "idx_source", "expression": "source", "type": "set", "maxRows": 0, "granularity": 1}, {"name": "idx_ts", "expression": "received_at", "type": "minmax", "granularity": 3}, { "name": "idx_body", "expression": "body", "type": "tokenbf_v1", "sizeBytes": 256, "hashFunctions": 2, "randomSeed": 0, "granularity": 1, },]Model classes are importable when dicts feel too loose: SkipIndexMinmax, SkipIndexSet, SkipIndexBloomFilter, SkipIndexTokenBF, SkipIndexNgramBF, SkipIndexText.
Projections
Section titled “Projections”Each entry in the projections array is a ProjectionDefinition, which takes one of two forms.
A SELECT projection stores a rewritten copy of the data.
| Field | Type | Description |
|---|---|---|
name | string | Projection name |
query | string | Projection SELECT query |
An index-only projection stores no SELECT body. It reorders parts by a secondary key so lookups on that key prune instead of scanning.
| Field | Type | Description |
|---|---|---|
name | string | Projection name |
index | string | Expression list to order by, e.g. receiver, sender |
type | string | Projection index type. ClickHouse currently accepts basic |
projections: [ { name: 'p_recent', query: 'SELECT id ORDER BY received_at DESC LIMIT 10' }, { name: 'by_receiver', index: 'receiver, sender', type: 'basic' },]projections=[ {"name": "p_recent", "query": "SELECT id ORDER BY received_at DESC LIMIT 10"}, {"name": "by_receiver", "index": "receiver, sender", "type": "basic"},]The index expression is rendered the way ClickHouse normalizes it: a single expression is emitted bare (INDEX receiver), several are emitted as a tuple (INDEX (receiver, sender)), redundant parentheses are dropped, and a space follows every argument separator. Writing '(receiver)' and 'receiver' therefore produce the same table, and neither reads as drift.
A projection must be exactly one of the two kinds. Setting both query and index on the same entry is a projection_ambiguous_kind validation error, and an empty index is a projection_empty_index error.
view()
Section titled “view()”Creates a view definition.
| Field | Type | Required | Description |
|---|---|---|---|
database | string | yes | Database name |
name | string | yes | View name |
as | string | yes | SELECT query; may span lines and contain comments (see SQL fragments) |
comment | string | no | View comment |
import { view } from '@chkit/core'
const activeUsers = view({ database: 'app', name: 'active_users', as: 'SELECT id, email FROM app.users WHERE active = 1',})from chkit import view
active_users = view( database="app", name="active_users", as_="SELECT id, email FROM app.users WHERE active = 1",)A view can read tables, other views, materialized views, and dictionaries. chkit generate creates a view after the objects it reads and drops it before them, whatever their names; chkit-py still orders by kind and name. Write qualified names (app.users) in as so every reference is detected. See Operation order.
materializedView()
Section titled “materializedView()”Creates a materialized view definition. In Python the factory is materialized_view().
| Field | Type | Required | Description |
|---|---|---|---|
database | string | yes | Database name |
name | string | yes | Materialized view name |
to | { database: string; name: string } | yes | Target table for the view |
refresh | MaterializedViewRefresh | no | Refresh schedule — see Refreshable materialized views |
as | string | yes | SELECT query; may span lines and contain comments (see SQL fragments) |
comment | string | no | View comment |
import { materializedView } from '@chkit/core'
const eventCounts = materializedView({ database: 'analytics', name: 'event_counts_mv', to: { database: 'analytics', name: 'event_counts' }, as: 'SELECT org_id, count() AS total FROM analytics.events GROUP BY org_id',})from chkit import materialized_view
event_counts = materialized_view( database="analytics", name="event_counts_mv", to={"database": "analytics", "name": "event_counts"}, as_="SELECT org_id, count() AS total FROM analytics.events GROUP BY org_id",)For a refreshable (scheduled) materialized view, add the refresh field:
const dailyReport = materializedView({ database: 'analytics', name: 'daily_report_mv', to: { database: 'analytics', name: 'daily_report' }, refresh: { every: '1 DAY', offset: '2 HOUR' }, as: 'SELECT toDate(ts) AS day, count() AS total FROM analytics.events GROUP BY day',})daily_report = materialized_view( database="analytics", name="daily_report_mv", to={"database": "analytics", "name": "daily_report"}, refresh={"every": "1 DAY", "offset": "2 HOUR"}, as_="SELECT toDate(ts) AS day, count() AS total FROM analytics.events GROUP BY day",)See Refreshable materialized views for the full refresh field reference, including APPEND mode, DEPENDS ON, and the ClickHouse rules that chkit validates.
dictionary()
Section titled “dictionary()”Creates a ClickHouse dictionary definition — a key-value lookup structure backed by an external or in-database source, queried with dictGet().
import { dictionary } from '@chkit/core'
const usersDict = dictionary({ database: 'default', name: 'users_dict', attributes: [ { name: 'id', type: 'UInt64' }, { name: 'name', type: 'String' }, { name: 'email', type: 'String', default: '' }, ], primaryKey: ['id'], source: `MYSQL(host 'db' port 3306 user 'reader' password '${process.env.MYSQL_PASSWORD}' db 'app' table 'users')`, layout: `HASHED()`, lifetime: `300`, comment: 'User lookup dictionary',})import os
from chkit import dictionary
users_dict = dictionary( database="default", name="users_dict", attributes=[ {"name": "id", "type": "UInt64"}, {"name": "name", "type": "String"}, {"name": "email", "type": "String", "default": ""}, ], primary_key=["id"], source=( f"MYSQL(host 'db' port 3306 user 'reader' " f"password '{os.environ['MYSQL_PASSWORD']}' db 'app' table 'users')" ), layout="HASHED()", lifetime="300", comment="User lookup dictionary",)Required fields
Section titled “Required fields”| Field | Type | Description |
|---|---|---|
database | string | ClickHouse database name |
name | string | Dictionary name |
attributes | DictionaryAttribute[] | Attribute definitions (see Dictionary attributes) |
primaryKey | string[] | Key attribute name(s) — every entry must name a declared attribute |
source | string | Raw SOURCE(...) body, e.g. `MYSQL(host '...' password '...' ...)` |
layout | string | Raw LAYOUT(...) body, e.g. `HASHED()` or `COMPLEX_KEY_HASHED()` |
lifetime | string | Raw LIFETIME(...) body, e.g. `300` or `MIN 300 MAX 360` |
Optional fields
Section titled “Optional fields”| Field | Type | Description |
|---|---|---|
range | { min: string; max: string } | RANGE(MIN ... MAX ...) — required by RANGE_HASHED / COMPLEX_KEY_RANGE_HASHED layouts. Both min and max must name declared attributes |
settings | Record<string, string | number> | Raw SETTINGS(...) key/value pairs, e.g. { dictionary_use_async_executor: 1 } |
comment | string | Dictionary comment |
renamedFrom | { database?: string; name: string } | Previous identity for rename tracking |
Dictionary attributes
Section titled “Dictionary attributes”Each entry in the attributes array is a DictionaryAttribute.
| Field | Type | Description |
|---|---|---|
name | string | Attribute name |
type | string | ClickHouse type |
default | string | number | boolean | DEFAULT value for missing keys. A literal: ClickHouse accepts no expression here. Mutually exclusive with expression |
expression | string | EXPRESSION computed from source columns. Mutually exclusive with default |
hierarchical | boolean | Marks the attribute HIERARCHICAL |
bidirectional | boolean | Marks the attribute BIDIRECTIONAL — enables parent/child lookups in both directions. Only valid alongside hierarchical |
injective | boolean | Marks the attribute INJECTIVE |
isObjectId | boolean | Marks the attribute IS_OBJECT_ID (MongoDB sources) |
Credentials in source
Section titled “Credentials in source”Inline credentials in source (e.g. a MySQL/PostgreSQL password '...') should be interpolated from environment variables at schema-authoring time, the same way you’d handle any other secret in a config file:
source: `MYSQL(host 'db' password '${process.env.MYSQL_PASSWORD}' ...)`,source=f"MYSQL(host 'db' password '{os.environ['MYSQL_PASSWORD']}' ...)",ClickHouse redacts inline passwords back to [HIDDEN] on introspection (SHOW CREATE DICTIONARY, system.dictionaries). A real password change diffs and migrates like any other field change. The one exception is a source that still carries the literal [HIDDEN] placeholder written by chkit pull — chkit never knows the real value in that case, so it excludes source from the diff entirely rather than risk rendering [HIDDEN] into DDL — see Pull: credential handling.
No ALTER DICTIONARY
Section titled “No ALTER DICTIONARY”ClickHouse has no ALTER DICTIONARY — every structural change to a dictionary is rendered as a single CREATE OR REPLACE DICTIONARY statement (atomic, dependency-safe). See Structural vs. alterable properties. A pure rename (renamedFrom with no other change) is the one exception — it renders as RENAME DICTIONARY, not a replace; see Dictionary rename.
SQL fragments
Section titled “SQL fragments”Several fields hold ClickHouse SQL that chkit copies into the DDL it generates: as on views and materialized views, partitionBy and ttl on tables, skip index expression, projection query and index, and dictionary source, layout, and lifetime. These fields accept SQL that spans several lines and contains comments:
import { view } from '@chkit/core'
const meetingCompany = view({ database: 'crm', name: 'meeting_company', as: ` WITH people_by_email AS ( SELECT person_id, company_id, arrayJoin(emails) AS email FROM crm.person_identity ) -- The attendee's company, through their person record. SELECT email, company_id /* one row per address */ FROM people_by_email `,})Before chkit compares a fragment, stores it in snapshot.json, or writes it into a migration, it removes the comments and collapses each run of whitespace to one space, so the generated CREATE VIEW holds the query on one line. Comment syntax follows ClickHouse: --, //, #!, and # followed by a space start a comment that runs to the end of the line, and /* */ comments can nest. Comment markers inside string literals ('--') and quoted identifiers are text and stay in place. An unterminated block comment or string literal is left as written, so ClickHouse reports it when the migration runs.
Editing the text of a comment does not produce a migration, and neither does adding or removing a comment that whitespace separates from the rest of the SQL. Whitespace inside string literals collapses too: each run of spaces, tabs, or newlines inside a literal becomes one space.
Full-text indexes (type: 'text') are the exception: chkit reads their SQL with a separate parser that keeps whitespace inside string literals and recognizes only -- and non-nested /* */ comments.
Type system reference
Section titled “Type system reference”The codegen plugin maps ClickHouse types to TypeScript types using these rules (the Python codegen plugin emits Pydantic models with the analogous Python types — string → str, number → int/float, T[] → list[T], and so on):
| Category | ClickHouse Types | TypeScript Type |
|---|---|---|
| String-like | String, FixedString, Date, Date32, DateTime, DateTime64, UUID, IPv4, IPv6, Enum8, Enum16, Decimal* | string |
| Number | Int8, Int16, Int32, UInt8, UInt16, UInt32, Float32, Float64, BFloat16 | number |
| Large integers | Int64, Int128, Int256, UInt64, UInt128, UInt256 | string (default) or bigint |
| Boolean | Bool, Boolean | boolean |
| Wrappers | Nullable(T) | T | null |
| Wrappers | LowCardinality(T) | same as T |
| Composite | Array(T) | T[] |
| Composite | Map(K, V) | Record<K, V> |
| Composite | Tuple(T1, T2, ...) | [T1, T2, ...] |
| Aggregate | SimpleAggregateFunction(fn, T) | same as T |
| JSON | JSON | Record<string, unknown> |
Parameterized types like DateTime('UTC'), Decimal(18, 4), and Enum8('a' = 1) are supported. The bigintMode option in the codegen plugin controls whether large integers map to string or bigint.
Rename support
Section titled “Rename support”chkit tracks renames to avoid destructive drop-and-recreate operations.
Table rename
Section titled “Table rename”Set renamedFrom on a table definition to rename a table:
const users = table({ database: 'app', name: 'accounts', // new name renamedFrom: { name: 'users' }, // old name // ...})users = table( database="app", name="accounts", # new name renamed_from={"name": "users"}, # old name # ...)The database field in renamedFrom is optional and defaults to the table’s current database.
Column rename
Section titled “Column rename”Set renamedFrom on a column definition to rename a column:
columns: [ { name: 'user_email', type: 'String', renamedFrom: 'email' },]columns=[ {"name": "user_email", "type": "String", "renamedFrom": "email"},]Dictionary rename
Section titled “Dictionary rename”Set renamedFrom on a dictionary definition to rename a dictionary. This emits a single RENAME DICTIONARY IF EXISTS ... TO ... statement instead of a drop_dictionary + create_dictionary pair:
const lookupDict = dictionary({ database: 'app', name: 'lookup_dict', // new name renamedFrom: { name: 'users_dict' }, // old name // ...})lookup_dict = dictionary( database="app", name="lookup_dict", # new name renamed_from={"name": "users_dict"}, # old name # ...)The database field in renamedFrom is optional and defaults to the dictionary’s current database.
Table, column, and dictionary renames can all be overridden by CLI flags: --rename-table, --rename-column, and --rename-dictionary.
Plugin configuration
Section titled “Plugin configuration”The plugins field on a table definition provides per-table configuration for plugins. In TypeScript, each plugin that supports table-level config augments the TablePlugins interface via declaration merging; in Python it is a plain dict.
import { table } from '@chkit/core'
const events = table({ database: 'app', name: 'events', columns: [ { name: 'event_time', type: 'DateTime' }, { name: 'id', type: 'UInt64' }, ], engine: 'MergeTree', orderBy: ['event_time', 'id'], primaryKey: ['event_time', 'id'], plugins: { backfill: { timeColumn: 'event_time' }, },})from chkit import table
events = table( database="app", name="events", columns=[ {"name": "event_time", "type": "DateTime"}, {"name": "id", "type": "UInt64"}, ], engine="MergeTree", order_by=["event_time", "id"], primary_key=["event_time", "id"], plugins={ "backfill": {"timeColumn": "event_time"}, },)Currently supported plugin keys:
| Key | Plugin | Fields | Description |
|---|---|---|---|
backfill | @chkit/plugin-backfill | timeColumn?: string | Time column for backfill WHERE clauses |
The plugins field is ignored by the diff engine — it does not affect migration planning or SQL generation.
Validation rules
Section titled “Validation rules”chkit validates schema definitions and throws a ChxValidationError if any issues are found:
- Duplicate object names — two definitions with the same
kind,database, andname - Duplicate column names — repeated column name within a table
- Duplicate index names — repeated index name within a table
- Duplicate projection names — repeated projection name within a table
- Ambiguous projection kind (
projection_ambiguous_kind) — a projection sets bothqueryandindex; use one or the other (see Projections) - Empty projection index (
projection_empty_index) — an index-only projection whoseindexexpression is empty - Primary key references missing column —
primaryKeyincludes a bare column name not incolumns(function expressions liketoDate(ts)are passed through to ClickHouse unchecked) - Order by references missing column —
orderByincludes a bare column name not incolumns(function expressions liketoStartOfHour(ts)are passed through to ClickHouse unchecked) - Empty codec chain (
codec_chain_empty) — acodecarray with no steps; provide at least one codec or omit the field - Multiple general codecs (
codec_chain_multiple_general) — more than one general codec in a chain; only one is allowed - Codec chain must end with a general codec (
codec_chain_must_end_with_general) — preprocessors must precede the single general codec (NONE,LZ4,LZ4HC,ZSTD,T64,GCD,ALP) - Column expression required (
column_expression_required) — aMATERIALIZEDorALIAScolumn has nodefault, or adefaultexpression is empty:{ expression: '' }, an expression made only of comments, or a bare'fn:' - Invalid column kind (
column_default_kind_invalid) —defaultKindis notDEFAULT,MATERIALIZED,ALIAS, orEPHEMERAL(Python rejects it when the column is constructed) - Default looks like an expression (
column_default_looks_like_expression) — the plain string default of aDEFAULTorEPHEMERALcolumn starts with a function call, such as'now64(3)', and the column type cannot hold a string; use{ expression: 'now64(3)' }(seedefault) - Plain string expression (
column_expression_requires_fn) — aMATERIALIZEDorALIAScolumn has a plain stringdefault, which renders as a quoted literal instead of SQL; use{ expression: 'toDate(ts)' }, or{ expression: "'text'" }for a constant string (seedefaultKind) - Invalid default (
column_default_invalid) —defaultis an object other than{ expression: string }, an expression that keeps the legacy prefix, such as{ expression: 'fn:now()' }, or an expression with an unterminated string, quoted identifier, or block comment, such as{ expression: 'now() /* set on insert' }, or with a#that starts no comment, such as{ expression: 'now() #' } - Unstored column in a key (
column_kind_not_stored) — anALIASorEPHEMERALcolumn is named directly inorderBy,primaryKey,partitionByor an engine argument, or anEPHEMERALcolumn is a skip index expression - EPHEMERAL column in a projection (
column_ephemeral_in_projection) — a projection reads anEPHEMERALcolumn - Codec on an unstored column (
column_kind_codec_unsupported) — anALIAScolumn, or anEPHEMERALcolumn without a default or comment, has acodec - Dictionary missing primary key (
dictionary_missing_primary_key) — a dictionary’sprimaryKeyis empty - Dictionary primary key references missing attribute (
dictionary_primary_key_missing_attribute) — aprimaryKeyentry doesn’t name a declared attribute - Dictionary missing source/layout/lifetime (
dictionary_missing_source,dictionary_missing_layout,dictionary_missing_lifetime) — one of these raw-string fields is empty - Dictionary attribute default/expression exclusive (
dictionary_attribute_default_expression_exclusive) — an attribute sets bothdefaultandexpression - Dictionary range references missing attribute (
dictionary_range_missing_attribute) —range.min/range.maxdoesn’t name a declared attribute - Dictionary bidirectional requires hierarchical (
dictionary_bidirectional_requires_hierarchical) — an attribute setsbidirectionalwithouthierarchical
Structural vs. alterable properties
Section titled “Structural vs. alterable properties”The table rules below apply to MergeTree-family tables. Kafka changes require an explicit replacement; chkit refuses generic ALTERs.
When a property changes, chkit determines whether the table can be altered in place or must be dropped and recreated.
Structural (drop + recreate): engine, primaryKey, orderBy, partitionBy, uniqueKey
Alterable (ALTER in place): columns, indexes, projections, settings, TTL, comment
Views and materialized views always use drop + recreate.
Dictionaries have no ALTER at all: any change to attributes, primaryKey, layout, lifetime, source (including a password change), or comment renders as a single CREATE OR REPLACE DICTIONARY (risk=caution) — except a source still carrying the [HIDDEN] introspection placeholder, which is excluded from the diff entirely (see Credentials in source). Removing a dictionary from schema emits DROP DICTIONARY (risk=danger, requires --allow-destructive).