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

Schema files are TypeScript files that export definitions using functions from `@chkit/core`. All exported definitions are collected when chkit loads schema files matched by the `schema` glob in your [configuration](/configuration/overview/).

```ts
import { schema, table, view, materializedView, dictionary } from '@chkit/core'
```

## `schema()`

Groups definitions into a single array for export.

```ts
schema(...definitions: SchemaDefinition[]): SchemaDefinition[]
```

```ts
export default schema(users, events)
```

You can also export definitions individually -- any exported value with a valid `kind` is discovered automatically.

## `table()`

Creates a table definition.

```ts
table(input: Omit<TableDefinition, 'kind'>): TableDefinition
```

**Minimal example:**

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

**Comprehensive example (all features):**

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

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

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

```ts
{ name: 'payload', type: 'String', nullable: true }
// SQL: `payload` Nullable(String)
```

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

Default value for the column.

- **String** values are single-quoted in SQL: `default: 'pending'` produces `DEFAULT 'pending'`
- **Number/boolean** values are rendered literally: `default: 0` produces `DEFAULT 0`
- **`fn:` prefix** -- for function-call defaults, prefix the string with `fn:` to emit a raw SQL expression:

```ts
{ name: 'received_at', type: 'DateTime64(3)', default: 'fn:now64(3)' }
// SQL: `received_at` DateTime64(3) DEFAULT now64(3)
```

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

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

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

```ts
{ name: 'blob', type: 'String', codec: { kind: 'raw', expression: 'T64, LZ4' } }
// → CODEC(T64, LZ4)
```

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

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'` | Index type |
| `granularity` | `number` | Index granularity |

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

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

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

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

Creates a view definition.

```ts
view(input: Omit<ViewDefinition, 'kind'>): ViewDefinition
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `database` | `string` | yes | Database name |
| `name` | `string` | yes | View name |
| `as` | `string` | yes | SELECT query |
| `comment` | `string` | no | View comment |

```ts
import { view } from '@chkit/core'

const activeUsers = view({
  database: 'app',
  name: 'active_users',
  as: 'SELECT id, email FROM app.users WHERE active = 1',
})
```

## `materializedView()`

Creates a materialized view definition.

```ts
materializedView(input: Omit<MaterializedViewDefinition, 'kind'>): MaterializedViewDefinition
```

| 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 |
| `comment` | `string` | no | View comment |

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

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

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

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

```ts
dictionary(input: Omit<DictionaryDefinition, 'kind'>): DictionaryDefinition
```

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

### 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 through to ClickHouse verbatim inside `SOURCE(...)`, `LAYOUT(...)`, and `LIFETIME(...)` respectively. 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. 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 TypeScript config file:

```ts
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](/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).

## Type system reference

The [codegen plugin](/plugins/codegen/) maps ClickHouse types to TypeScript types using these rules:

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

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

### Column rename

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

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

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

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

## Plugin configuration

The `plugins` field on a table definition provides per-table configuration for plugins. Each plugin that supports table-level config augments the `TablePlugins` interface via TypeScript declaration merging, so the available keys and their types depend on which plugin packages are imported.

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

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

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