Skip to content

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'

Groups definitions into a single array for export.

export default schema(users, events)

Any exported value with a valid kind is also discovered automatically.

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)

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',
})
FieldTypeDescription
databasestringClickHouse database name
namestringTable name
columnsColumnDefinition[]Column definitions (see Columns)
enginestringEngine clause, e.g. 'MergeTree', 'ReplacingMergeTree(ver)'
primaryKeystring[]Primary key columns or expressions, e.g. ['toDate(ts)', 'id']
orderBystring[]ORDER BY columns or expressions, e.g. ['toStartOfHour(ts)', 'id']

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.

FieldTypeDescription
partitionBystringPartition expression, e.g. 'toYYYYMM(created_at)'
uniqueKeystring[]Unique key columns
ttlstringTTL expression, e.g. 'created_at + INTERVAL 90 DAY'
settingsRecord<string, string | number | boolean>Table-level settings
indexesSkipIndexDefinition[]Skip indexes (see Skip indexes)
projectionsProjectionDefinition[]Projections (see Projections)
commentstringTable comment
renamedFrom{ database?: string; name: string }Previous identity for rename tracking (see Rename support)
pluginsTablePluginsPer-table plugin configuration (see Plugin configuration)

Each entry in the columns array is a ColumnDefinition.

Column name.

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.

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 aliasClickHouse native type
TINYINTInt8
SMALLINTInt16
INTEGER / INTInt32
BIGINTInt64
FLOAT / REALFloat32
DOUBLEFloat64
TEXT / VARCHAR / CHARString
TIMESTAMPDateTime

See the ClickHouse data types reference for the complete alias list.

When true, the column type is wrapped in Nullable(...) in the generated SQL.

{ 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.

ValueMeaningExampleRenders
stringLiteral, single-quoted, with quotes and backslashes escapeddefault: 'pending'DEFAULT 'pending'
number / booleanLiteral, as writtendefault: 0DEFAULT 0
{ expression: string }SQL expressiondefault: { 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)
]

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'" }.

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).

KindBehavior
DEFAULTStored; the expression applies when the insert omits the value.
MATERIALIZEDComputed on insert and stored; cannot be supplied in a normal insert.
ALIASComputed when explicitly selected; neither stored nor insertable.
EPHEMERALInput 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)' } },
]

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.

CodeRejected use
column_kind_not_storedNamed 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_projectionAn EPHEMERAL column read by a projection. ClickHouse can accept the table and then fail every insert.
column_kind_codec_unsupportedA 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).

Column-level comment rendered in SQL.

Previous column name for rename tracking. See Rename support.

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 }] },
]

General codecs (the compressor; at most one, and it must come last in a chain):

kindArgsRenders
NONE, LZ4, T64, GCD, ALP—CODEC(LZ4)
LZ4HClevel?: numberCODEC(LZ4HC(9))
ZSTDlevel?: numberCODEC(ZSTD(3))

Preprocessing codecs (placed before the general codec):

kindArgsRenders
Delta, DoubleDelta, Gorillasize?: 1 | 2 | 4 | 8 (bytes, defaults to 1)CODEC(Delta(4))
FPClevel: number, floatSize: 4 | 8CODEC(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)

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.

Each entry in the indexes array is a SkipIndexDefinition. The shared base fields are:

FieldTypeDescription
namestringIndex name
expressionstringIndexed expression
type'minmax' | 'set' | 'bloom_filter' | 'tokenbf_v1' | 'ngrambf_v1' | 'text'Index type
granularitynumberRequired for other indexes; optional and ignored for text, which always uses 100000000

Type-specific fields:

TypeRequired fieldsOptional fieldsNotes
minmax——No arguments
setmaxRows: number—maxRows: 0 stores all unique values (ClickHouse 26+ requires set(0) rather than bare set)
bloom_filter—falsePositiveRate: numberDefaults to 0.025 when omitted
tokenbf_v1sizeBytes, hashFunctions, randomSeed (all number)—Maps to tokenbf_v1(size_bytes, n_hash, seed)
ngrambf_v1ngramSize, sizeBytes, hashFunctions, randomSeed (all number)—Maps to ngrambf_v1(n, size_bytes, n_hash, seed)

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.

FieldTypeMeaning
tokenizerstringRequired SQL tokenizer
preprocessorstringOptional SQL expression applied before tokenization
postprocessorstringOptional SQL expression applied to each token; requires server support
supportPhraseSearchbooleanStore token positions; requires server support and the table setting allow_experimental_text_index_phrase_search: 1
dictionaryBlockSizenumberPositive integer dictionary block size
dictionaryBlockFrontcodingCompressionbooleanEnable or disable dictionary front coding
postingListBlockSizenumberPositive 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,
},
]

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.

FieldTypeDescription
namestringProjection name
querystringProjection 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.

FieldTypeDescription
namestringProjection name
indexstringExpression list to order by, e.g. receiver, sender
typestringProjection 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' },
]

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.

Creates a view definition.

FieldTypeRequiredDescription
databasestringyesDatabase name
namestringyesView name
asstringyesSELECT query; may span lines and contain comments (see SQL fragments)
commentstringnoView comment
import { view } from '@chkit/core'
const activeUsers = 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.

Creates a materialized view definition. In Python the factory is materialized_view().

FieldTypeRequiredDescription
databasestringyesDatabase name
namestringyesMaterialized view name
to{ database: string; name: string }yesTarget table for the view
refreshMaterializedViewRefreshnoRefresh schedule — see Refreshable materialized views
asstringyesSELECT query; may span lines and contain comments (see SQL fragments)
commentstringnoView 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',
})

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',
})

See Refreshable materialized views for the full refresh field reference, including APPEND mode, DEPENDS ON, and the ClickHouse rules that chkit validates.

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',
})
FieldTypeDescription
databasestringClickHouse database name
namestringDictionary name
attributesDictionaryAttribute[]Attribute definitions (see Dictionary attributes)
primaryKeystring[]Key attribute name(s) — every entry must name a declared attribute
sourcestringRaw SOURCE(...) body, e.g. `MYSQL(host '...' password '...' ...)`
layoutstringRaw LAYOUT(...) body, e.g. `HASHED()` or `COMPLEX_KEY_HASHED()`
lifetimestringRaw LIFETIME(...) body, e.g. `300` or `MIN 300 MAX 360`
FieldTypeDescription
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
settingsRecord<string, string | number>Raw SETTINGS(...) key/value pairs, e.g. { dictionary_use_async_executor: 1 }
commentstringDictionary comment
renamedFrom{ database?: string; name: string }Previous identity for rename tracking

Each entry in the attributes array is a DictionaryAttribute.

FieldTypeDescription
namestringAttribute name
typestringClickHouse type
defaultstring | number | booleanDEFAULT value for missing keys. A literal: ClickHouse accepts no expression here. Mutually exclusive with expression
expressionstringEXPRESSION computed from source columns. Mutually exclusive with default
hierarchicalbooleanMarks the attribute HIERARCHICAL
bidirectionalbooleanMarks the attribute BIDIRECTIONAL — enables parent/child lookups in both directions. Only valid alongside hierarchical
injectivebooleanMarks the attribute INJECTIVE
isObjectIdbooleanMarks the attribute IS_OBJECT_ID (MongoDB sources)

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}' ...)`,

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.

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.

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.

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):

CategoryClickHouse TypesTypeScript Type
String-likeString, FixedString, Date, Date32, DateTime, DateTime64, UUID, IPv4, IPv6, Enum8, Enum16, Decimal*string
NumberInt8, Int16, Int32, UInt8, UInt16, UInt32, Float32, Float64, BFloat16number
Large integersInt64, Int128, Int256, UInt64, UInt128, UInt256string (default) or bigint
BooleanBool, Booleanboolean
WrappersNullable(T)T | null
WrappersLowCardinality(T)same as T
CompositeArray(T)T[]
CompositeMap(K, V)Record<K, V>
CompositeTuple(T1, T2, ...)[T1, T2, ...]
AggregateSimpleAggregateFunction(fn, T)same as T
JSONJSONRecord<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.

chkit tracks renames to avoid destructive drop-and-recreate operations.

Set renamedFrom on a table definition to rename a table:

const users = table({
database: 'app',
name: 'accounts', // new name
renamedFrom: { name: 'users' }, // old name
// ...
})

The database field in renamedFrom is optional and defaults to the table’s current database.

Set renamedFrom on a column definition to rename a column:

columns: [
{ name: 'user_email', type: 'String', renamedFrom: 'email' },
]

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
// ...
})

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.

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' },
},
})

Currently supported plugin keys:

KeyPluginFieldsDescription
backfill@chkit/plugin-backfilltimeColumn?: stringTime column for backfill WHERE clauses

The plugins field is ignored by the diff engine — it does not affect migration planning or SQL generation.

chkit validates schema definitions and throws a ChxValidationError if any issues are found:

  • Duplicate object names — two definitions with the same kind, database, and name
  • 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 both query and index; use one or the other (see Projections)
  • Empty projection index (projection_empty_index) — an index-only projection whose index expression is empty
  • Primary key references missing column — primaryKey includes a bare column name not in columns (function expressions like toDate(ts) are passed through to ClickHouse unchecked)
  • Order by references missing column — orderBy includes a bare column name not in columns (function expressions like toStartOfHour(ts) are passed through to ClickHouse unchecked)
  • Empty codec chain (codec_chain_empty) — a codec array 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) — a MATERIALIZED or ALIAS column has no default, or a default expression is empty: { expression: '' }, an expression made only of comments, or a bare 'fn:'
  • Invalid column kind (column_default_kind_invalid) — defaultKind is not DEFAULT, MATERIALIZED, ALIAS, or EPHEMERAL (Python rejects it when the column is constructed)
  • Default looks like an expression (column_default_looks_like_expression) — the plain string default of a DEFAULT or EPHEMERAL column starts with a function call, such as 'now64(3)', and the column type cannot hold a string; use { expression: 'now64(3)' } (see default)
  • Plain string expression (column_expression_requires_fn) — a MATERIALIZED or ALIAS column has a plain string default, which renders as a quoted literal instead of SQL; use { expression: 'toDate(ts)' }, or { expression: "'text'" } for a constant string (see defaultKind)
  • Invalid default (column_default_invalid) — default is 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) — an ALIAS or EPHEMERAL column is named directly in orderBy, primaryKey, partitionBy or an engine argument, or an EPHEMERAL column is a skip index expression
  • EPHEMERAL column in a projection (column_ephemeral_in_projection) — a projection reads an EPHEMERAL column
  • Codec on an unstored column (column_kind_codec_unsupported) — an ALIAS column, or an EPHEMERAL column without a default or comment, has a codec
  • Dictionary missing primary key (dictionary_missing_primary_key) — a dictionary’s primaryKey is empty
  • Dictionary primary key references missing attribute (dictionary_primary_key_missing_attribute) — a primaryKey entry 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 both default and expression
  • Dictionary range references missing attribute (dictionary_range_missing_attribute) — range.min/range.max doesn’t name a declared attribute
  • Dictionary bidirectional requires hierarchical (dictionary_bidirectional_requires_hierarchical) — an attribute sets bidirectional without hierarchical

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).