---
title: Schema DSL Reference
description: Complete reference for chkit schema definition functions, column types, and table options — in TypeScript and Python.
sidebar:
  order: 3
---

import { Tabs, TabItem } from '@astrojs/starlight/components';

Schema files export definitions using functions from `@chkit/core` (TypeScript) or `chkit` (Python, via [chkit-py](/python/overview/)). All exported definitions are collected when chkit loads schema files matched by the `schema` glob in your [configuration](/configuration/overview/). The two implementations share every field's semantics — pick your language once and the whole page follows.

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    import { schema, table, view, materializedView, dictionary } from '@chkit/core'
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    from chkit import schema, table, view, materialized_view, dictionary
    ```
  </TabItem>
</Tabs>

:::note[Python calling conventions]
The Python DSL accepts both the TypeScript camelCase names and snake_case equivalents everywhere — `primary_key` or `primaryKey`, `renamed_from` or `renamedFrom`, `maxRows` or `max_rows` — so examples port with their keys unchanged. Because `as` is a Python keyword, `view()` and `materialized_view()` take the SELECT body as `as_`. Columns, indexes, projections, and attributes accept plain dicts (validated on entry) or model instances (`ColumnDefinition`, `SkipIndexSet`, ...). Field tables on this page use the camelCase names.
:::

## `schema()`

Groups definitions into a single array for export.

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    export default schema(users, events)
    ```
    Any exported value with a valid `kind` is also discovered automatically.
  </TabItem>
  <TabItem label="Python">
    ```python
    definitions = schema(users, events)
    ```
    Any module-level definition is also discovered automatically, including definitions nested in lists or tuples.
  </TabItem>
</Tabs>

## `table()`

Creates a table definition.

**Minimal example:**

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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)
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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)
    ```
  </TabItem>
</Tabs>

**Comprehensive example (all features):**

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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',
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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",
    )
    ```
  </TabItem>
</Tabs>

### Required fields

| Field | Type | Description |
|-------|------|-------------|
| `database` | `string` | ClickHouse database name |
| `name` | `string` | Table name |
| `columns` | `ColumnDefinition[]` | Column definitions (see [Columns](#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

For [Kafka tables](/schema/kafka/), 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](#skip-indexes)) |
| `projections` | `ProjectionDefinition[]` | Projections (see [Projections](#projections)) |
| `comment` | `string` | Table comment |
| `renamedFrom` | `{ database?: string; name: string }` | Previous identity for rename tracking (see [Rename support](#rename-support)) |
| `plugins` | `TablePlugins` | Per-table plugin configuration (see [Plugin configuration](#plugin-configuration)) |

:::note
The `engine` field accepts any string. Common engines include `MergeTree`, `ReplacingMergeTree`, `SummingMergeTree`, `AggregatingMergeTree`, and `CollapsingMergeTree(sign)`. Empty parentheses are optional for parameterless engines (`'MergeTree'` and `'MergeTree()'` are equivalent), and chkit normalizes them when comparing schemas.
:::

:::note
Key clause arrays support comma-separated strings: `['id, org_id']` is normalized to `['id', 'org_id']`. Prefer one column per array element for clarity.
:::

:::caution
`primaryKey`/`orderBy` entries may be **function expressions**, not just column names — e.g. `['toStartOfHour(session_end)', 'id']`. Bare column names are validated against `columns` and quoted; expressions are passed through to ClickHouse unchanged, and spacing differences are ignored when detecting drift.

Write expressions in ClickHouse's **canonical form**, because ClickHouse rewrites some syntax when it stores the key, and chkit compares against that stored form. A mismatch makes `drift`/`check` report perpetual drift and `migrate` recreate the table on every run. Known rewrites to avoid in keys:

- `INTERVAL 1 HOUR` → write `toIntervalHour(1)` (e.g. `toStartOfInterval(ts, toIntervalHour(1))`)
- `x::Date` or `CAST(x AS Date)` → write `CAST(x, 'Date')`
- Do not use `ASC`/`DESC` in a key — ClickHouse drops it and the key no longer matches.

Plain function chains like `toStartOfHour(ts)`, `toDate(ts)`, and arithmetic (`a + 1`, `h % 8`) round-trip unchanged.
:::

## Columns

Each entry in the `columns` array is a `ColumnDefinition`.

### `name` (string, required)

Column name.

### `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

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](https://clickhouse.com/docs/sql-reference/data-types) for the complete alias list.

:::caution
**Prefer the native type.** chkit compares column types literally. A column declared as `BIGINT` is created as `Int64`, but [`chkit drift`](/cli/drift/) and [`chkit check`](/cli/check/) then compare your declared `BIGINT` against the live `Int64` and report a permanent `changed_column` drift — failing `chkit check --strict` on every run. The [codegen plugin](/plugins/codegen/) likewise recognizes only native names (see [Type system reference](#type-system-reference)) — an alias raises `codegen_unsupported_type`, or emits `unknown` when `failOnUnsupportedType` is `false`. Use the native ClickHouse type (`Int64`, not `BIGINT`) unless you have a specific reason not to.
:::

### `nullable` (boolean, optional)

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

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    { name: 'payload', type: 'String', nullable: true }
    // SQL: `payload` Nullable(String)
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    {"name": "payload", "type": "String", "nullable": True}
    # SQL: `payload` Nullable(String)
    ```
  </TabItem>
</Tabs>

### `default` (string | number | boolean | SQLExpression, optional)

The value of the column's `DEFAULT` clause, or of the clause that [`defaultKind`](#defaultkind-optional) 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.

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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)
    ]
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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.
  </TabItem>
</Tabs>

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`](/cli/drift/#expression-defaults) and [`chkit check`](/cli/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()`.

:::caution
ClickHouse stores some expressions in a canonical form, such as `now()` for `NOW()`, `CAST(ts, 'String')` for `cast(ts as String)` or `ts::String`, and `now() + toIntervalDay(1)` for `now() + INTERVAL 1 DAY`. Write the stored form in `{ expression }` to avoid permanent drift; `chkit pull` and `system.columns` show it.
:::

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

:::note
`{ expression }` needs chkit newer than 0.2.0-beta.8. Older versions render it as `DEFAULT [object Object]`; use the `fn:` prefix with them.
:::

:::caution[A function call in a plain string is a literal]
`default: 'now64(3)'` renders `DEFAULT 'now64(3)'`: the text `now64(3)`, not the current time. ClickHouse rejects that literal for a `DateTime64` column. On a `Nullable` number, date, time, UUID, or IP address column it accepts the literal but stores `NULL`. `chkit generate` therefore fails with `column_default_looks_like_expression` when the plain string default of a `DEFAULT` column starts with a function call and the column type cannot hold a string. An `EPHEMERAL` column gets the same check, because a plain string there renders the same quoted literal, such as `EPHEMERAL 'now64(3)'`. `MATERIALIZED` and `ALIAS` columns reject every plain string with `column_expression_requires_fn` (see [`defaultKind`](#defaultkind-optional)).

Columns that store text are exempt, because there the text is a valid value: `String` and its SQL aliases (`TEXT`, `VARCHAR`, ...), `FixedString`, `Enum`, `Enum8`/`Enum16`, `Dynamic`, a `Variant` with a string member, and any of these inside `Nullable(...)`, `LowCardinality(...)`, or `SimpleAggregateFunction(...)`. To keep the quoted text on a column that holds a string chkit does not recognize, write it as an expression: `default: { expression: "'now()'" }`.

If a migration that an older chkit generated already failed this way, see [Troubleshooting](/guides/troubleshooting/#default-expression-and-column-type-are-incompatible).
:::

### `defaultKind` (optional)

Choose `DEFAULT` (the implicit default), `MATERIALIZED`, `ALIAS`, or `EPHEMERAL`.
Python also accepts `default_kind`. The [`default`](#default-string--number--boolean--sqlexpression-optional)
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. |

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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)' } },
    ]
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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)"},
    ]
    ```
  </TabItem>
</Tabs>

`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](/plugins/codegen/#tables-with-generated-columns)),
and the backfill plugin checks the target's column kinds before an automatic
backfill (see [Target safety checks](/plugins/backfill/#target-safety-checks)).

### `comment` (string, optional)

Column-level comment rendered in SQL.

### `renamedFrom` (string, optional)

Previous column name for rename tracking. See [Rename support](#rename-support).

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

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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 }] },
    ]
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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}]},
    ]
    ```
  </TabItem>
</Tabs>

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

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    { name: 'blob', type: 'String', codec: { kind: 'raw', expression: 'T64, LZ4' } }
    // → CODEC(T64, LZ4)
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    {"name": "blob", "type": "String", "codec": {"kind": "raw", "expression": "T64, LZ4"}}
    # → CODEC(T64, LZ4)
    ```
  </TabItem>
</Tabs>

:::caution
Use the typed `{ kind: 'ZSTD', level: 3 }` shape — an unrecognized shape such as `codec: { general: 'ZSTD' }` is **not** a valid `ColumnCodecSpec` and renders an empty `CODEC()`, silently shipping a column with no codec. (The Python port rejects unknown codec keys at validation time.)
:::

Codec chains are validated (see [Validation rules](#validation-rules)): a chain must be non-empty, contain at most one general codec, and end with the general codec.

## 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

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 |

```ts
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.

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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,
      },
    ]
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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`.
  </TabItem>
</Tabs>

## 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` |

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    projections: [
      { name: 'p_recent', query: 'SELECT id ORDER BY received_at DESC LIMIT 10' },
      { name: 'by_receiver', index: 'receiver, sender', type: 'basic' },
    ]
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    projections=[
        {"name": "p_recent", "query": "SELECT id ORDER BY received_at DESC LIMIT 10"},
        {"name": "by_receiver", "index": "receiver, sender", "type": "basic"},
    ]
    ```
  </TabItem>
</Tabs>

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()`

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](#sql-fragments)) |
| `comment` | `string` | no | View comment |

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    import { view } from '@chkit/core'

    const activeUsers = view({
      database: 'app',
      name: 'active_users',
      as: 'SELECT id, email FROM app.users WHERE active = 1',
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    from chkit import view

    active_users = view(
        database="app",
        name="active_users",
        as_="SELECT id, email FROM app.users WHERE active = 1",
    )
    ```
  </TabItem>
</Tabs>

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](/cli/generate/#operation-order).

## `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](/schema/refreshable-views/) |
| `as` | `string` | yes | SELECT query; may span lines and contain comments (see [SQL fragments](#sql-fragments)) |
| `comment` | `string` | no | View comment |

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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',
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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",
    )
    ```
  </TabItem>
</Tabs>

For a refreshable (scheduled) materialized view, add the `refresh` field:

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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',
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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",
    )
    ```
  </TabItem>
</Tabs>

See [Refreshable materialized views](/schema/refreshable-views/) for the full `refresh` field reference, including APPEND mode, `DEPENDS ON`, and the ClickHouse rules that chkit validates.

## `dictionary()`

Creates a [ClickHouse dictionary](https://clickhouse.com/docs/sql-reference/dictionaries) definition — a key-value lookup structure backed by an external or in-database source, queried with `dictGet()`.

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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',
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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",
    )
    ```
  </TabItem>
</Tabs>

### Required fields

| Field | Type | Description |
|-------|------|-------------|
| `database` | `string` | ClickHouse database name |
| `name` | `string` | Dictionary name |
| `attributes` | `DictionaryAttribute[]` | Attribute definitions (see [Dictionary attributes](#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

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

:::note
`source`, `layout`, and `lifetime` are **raw strings**, not a typed sub-DSL — chkit passes them to ClickHouse inside `SOURCE(...)`, `LAYOUT(...)`, and `LIFETIME(...)` respectively, after removing comments and collapsing whitespace (see [SQL fragments](#sql-fragments)). There is no key/layout coupling validation or required-parameter checking (e.g. `size_in_cells` for `HASHED`); ClickHouse validates the DDL when it's applied.
:::

### 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`

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:

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    source: `MYSQL(host 'db' password '${process.env.MYSQL_PASSWORD}' ...)`,
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    source=f"MYSQL(host 'db' password '{os.environ['MYSQL_PASSWORD']}' ...)",
    ```
  </TabItem>
</Tabs>

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](/plugins/pull/#credential-handling-hidden-passwords).

### 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](#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](#dictionary-rename).

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

```ts
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.

:::caution[chkit-py]
chkit-py does not remove comments from these fields yet: it collapses the fragment onto one line with its comments, so a line comment swallows the rest of the query. With chkit-py, keep comments out of SQL fragments.
:::

## Type system reference

The [codegen plugin](/plugins/codegen/) 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

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

### Table rename

Set `renamedFrom` on a table definition to rename a table:

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    const users = table({
      database: 'app',
      name: 'accounts', // new name
      renamedFrom: { name: 'users' }, // old name
      // ...
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    users = table(
        database="app",
        name="accounts",                 # new name
        renamed_from={"name": "users"},  # old name
        # ...
    )
    ```
  </TabItem>
</Tabs>

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

### Column rename

Set `renamedFrom` on a column definition to rename a column:

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    columns: [
      { name: 'user_email', type: 'String', renamedFrom: 'email' },
    ]
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    columns=[
        {"name": "user_email", "type": "String", "renamedFrom": "email"},
    ]
    ```
  </TabItem>
</Tabs>

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

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    const lookupDict = dictionary({
      database: 'app',
      name: 'lookup_dict', // new name
      renamedFrom: { name: 'users_dict' }, // old name
      // ...
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    lookup_dict = dictionary(
        database="app",
        name="lookup_dict",                   # new name
        renamed_from={"name": "users_dict"},  # old name
        # ...
    )
    ```
  </TabItem>
</Tabs>

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

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.

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    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' },
      },
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    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"},
        },
    )
    ```
  </TabItem>
</Tabs>

Currently supported plugin keys:

| Key | Plugin | Fields | Description |
|-----|--------|--------|-------------|
| `backfill` | [`@chkit/plugin-backfill`](/plugins/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

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](#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`](#default-string--number--boolean--sqlexpression-optional))
- **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`](#defaultkind-optional))
- **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`

## Structural vs. alterable properties

The table rules below apply to MergeTree-family tables. Kafka changes require an
[explicit replacement](/schema/kafka/#changing-a-queue); 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`](#credentials-in-source)). Removing a dictionary from schema emits `DROP DICTIONARY` (`risk=danger`, requires `--allow-destructive`).

:::danger
Changing a structural property on an existing table generates a `DROP TABLE` followed by `CREATE TABLE` — **all rows are permanently deleted and the table is recreated empty**. The data is not copied over. The drop is classified `risk=danger` (blocked without `--allow-destructive`) and `chkit migrate` flags it with the distinct `table_recreate_data_loss` warning. To preserve data, migrate by hand instead: create a new table with the desired structure, `INSERT INTO new SELECT ... FROM old`, then swap names and drop the old table.
:::
