# SQL Yodeler
> Schema management and migrations for ClickHouse and Postgres. The schema is declared in TypeScript with chant's sql lexicon; the yodel CLI writes versioned migrations from it, lints them, and applies them behind an approval bound to a digest of the plan.
Agents setting SQL Yodeler up in a repository: start with https://intentius.io/sql-yodeler/agents/. A person new to it: https://intentius.io/sql-yodeler/getting-started/.
A page with a prompt carries it under "Optional: hand this page to your coding agent", and this file lists it under the page. Every prompt forbids applying to a shared environment, approving and merging; those stay with people.
---
# Set up with a coding agent
Source: https://intentius.io/sql-yodeler/agents/
## Optional: hand this page to your coding agent
```text
Set up SQL Yodeler in this repository.
Read https://intentius.io/sql-yodeler/llms.txt first, then
https://intentius.io/sql-yodeler/agents/ and follow it.
Open a pull request with the result.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
Setup needs no agent: [Your first migration](/sql-yodeler/getting-started/) and [Starting a project](/sql-yodeler/adoption/) give every step by hand. This page is for a coding agent adding SQL Yodeler to a repository, and for the person handing it the task. Run the agent in the repository and give it this prompt:
```text
Set up SQL Yodeler in this repository.
Read https://intentius.io/sql-yodeler/llms.txt first, then
https://intentius.io/sql-yodeler/agents/ and follow it.
Open a pull request with the result.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
The last line is in every prompt on this site. Applying to a shared environment, approving a plan, changing the `chant/lifecycle` branch (where chant keeps approvals) and merging stay with people; the agent prepares the change and hands those steps over. So does overriding a policy rule with `yodel override`, which records a person's decision to apply a plan the rule denies, and editing `.chant/allowed_signers`, the signers file that decides whose sealed approvals count.
## What the agent reads
| File | Holds |
|---|---|
| [`llms.txt`](https://intentius.io/sql-yodeler/llms.txt) | every page, with a one-line description and its prompt |
| [`llms-full.txt`](https://intentius.io/sql-yodeler/llms-full.txt) | the text of every page in one file |
| Copy page as Markdown, under each page's title | that page's text, with its prompt |
A task page's own prompt sits under its title as "Optional: hand this page to your coding agent". The [Glossary](/sql-yodeler/glossary/) explains the chant terms the commands use.
## Steps for the agent
1. Check the repository: Node 22.12 or later, a git repository, and whether it already holds other code. The template's CI pipelines go in the project's `.github/workflows`, `.forgejo/workflows` and `.gitlab-ci.yml`, which a forge reads only at the repository root. If the repository holds other code, ask the user where the project goes.
2. Ask the user for the dialect (ClickHouse or Postgres), the database (ClickHouse) or schema (Postgres) the tables live in, and whether it exists already.
3. Make the project from the template with `yodel create`, from npm. It makes the project in a new directory, or one that holds nothing but `.git`, and refuses any other; check `git status` afterwards.
```sh
npx @intentius/sql-yodeler@latest create
--clickhouse --database --name
npx @intentius/sql-yodeler@latest create --postgres --schema --name
cd && npm install
```
4. Edit `chant.config.ts`: each environment's default server address (`dev`, `prod`, and any the user names). Credentials stay out of files: the profiles name environment variables, and the template's README lists them. For another environment, add its profile, its entry in `yodel.config.ts`, and copies of `ops/migrate-dev.op.ts` and `ops/watch-dev.op.ts` with `dev` changed, then run `npm run ci` to render the pipelines again.
5. For a new database, declare the tables in `src/` (chant's `sql` lexicon; the template's `src/schema.ts` shows the form) and write the first migration with `npx yodel new init`. For a database that exists, do not write `src/` by hand: `npx yodel init --from --force` adopts it ([Starting a project](/sql-yodeler/adoption/)). It writes one row to that environment's history, so ask the user first and run it with the credentials they give you.
6. Run `npx yodel lint` and `npm run ci:check`. Both must pass.
7. Optionally, check the migrations on a local database: `npx yodel emulator up` starts one in Docker, and [Your first migration](/sql-yodeler/getting-started/) has the variables. `npx yodel plan dev` and `npx yodel apply dev` against it are safe; `yodel apply` stops at the approval, and that is where the agent stops too.
8. Commit the project (with `package-lock.json`) on a new branch and open a pull request. Check `git status` so the commit holds nothing else.
9. Tell the user what is left for them, from the template's README: create each environment's reader and writer database users, add the forge's secrets where [Setting up each forge](/sql-yodeler/forges/) says, approve each plan the pull request comment shows, and merge.
## Rules for the agent
- `npx yodel --help` lists each command's options and exit codes; [the exit codes](/sql-yodeler/cli/#exit-codes) says what they mean.
- Print the approve command a plan shows, and never run it. The approval is bound to that plan's digest, and it is the user's.
- Never edit a migration that is committed on the default branch: write a new one with `npx yodel new `. `npx yodel lint` reports an edited one as a `checksum` error.
- Print the `yodel override` command a plan shows for a denied policy rule, and never run it. An override is the user's decision, with the user's reason.
- Never edit `.chant/allowed_signers`, the file that lists whose sealed approvals count. Adding or removing a signer is the user's change.
- Silence a lint finding (`-- yodel:allow `) only with a reason the user gives.
- Credentials go in the forge's secrets, never in a file or a commit.
- Read a command's result from its `--json` output, never from its text. Each document has a JSON Schema; [JSON output](/sql-yodeler/cli/#json-output) lists them.
## Read-only tools (yodel mcp)
An agent that should look at a project's state without a shell can use `yodel mcp`, a Model Context Protocol server on stdin and stdout. Register it with the agent's MCP client, started in the project with the reader's credentials in its environment (the ones `yodel plan` uses):
```json
{ "mcpServers": { "yodel": { "command": "npx", "args": ["yodel", "mcp"] } } }
```
`--dir ` serves a project in another directory. The tools are read-only:
| Tool | Runs | Gives |
|---|---|---|
| `status` | `yodel status --json` | applied, pending, out-of-order and part-way migrations, checksum mismatches, the plan digest apply would ask approval for |
| `plan` | `yodel plan --json` | what `yodel apply` would run; in `forPerson`, the approve command when the plan waits for approval, and `yodel override` for each policy rule that denies it |
| `lint` | `yodel lint [--env ] --json` | the findings and silences; with `env`, against that environment's history |
| `drift` | `yodel drift --json` | the declared objects changed out of band or gone |
| `history` | `yodel status --json` | the migrations the history records applied: who, when, under which digest, and any repairs |
| `explain` | `yodel lint --rules --json` | what a lint rule checks and whether it can be silenced (`topic`: the rule's id), or what an exit code means (`topic`: `0` to `5`) |
Each tool answers with one envelope ([`mcp.schema.json`](https://intentius.io/sql-yodeler/schemas/v1/mcp.schema.json)): `schema` (the envelope's version, `1`), `tool`, `command` (the command line it ran), `exit` (its exit code, the same as on a terminal), `results` (what it printed with `--json`, or `null` when it failed), `resultsSchema` (the `$id` of the schema `results` follows), `error` (what it wrote to stderr) and `forPerson` (steps that belong to a person, each with the command the person runs).
There is no tool that applies, approves, overrides a policy rule, repairs, rebases, writes a migration or touches `chant/lifecycle`. The server refuses to start with a tool named for `apply`, `approve`, `repair`, `rebase`, `new`, `init` or `override`, a tool that runs any command besides `status`, `plan`, `lint` and `drift`, or a tool not marked read-only, and no tool passes a flag that writes (`--replay`, `--update-checksum`, `--comment`, `--execute`). The commands in `forPerson` are the person's to run: the agent shows them and stops, as the never-line above says.
---
# SQL Yodeler
Source: https://intentius.io/sql-yodeler/
SQL Yodeler manages the schemas of ClickHouse and Postgres databases declaratively. You declare what the schema should be in TypeScript with chant's `sql` lexicon ([Declaring the schema](/sql-yodeler/schema/)), and the `yodel` CLI works out the change: it either applies it directly (the declarative path) or writes versioned migrations from it and applies those (the versioned path). Both paths apply behind an approval bound to a digest of the plan. Data migrations (rebuilds, backfills, expand-and-contract column changes) are steps inside a migration, recorded in the same history as the DDL, and resume where they stopped.
| Page | What it covers |
|---|---|
| [Your first migration](/sql-yodeler/getting-started/) | from an empty directory to applied migrations on a local database: the template, the emulator, the variables, plan, approve and apply |
| [Installing and configuring](/sql-yodeler/configuration/) | installing `yodel`, the project layout, `chant.config.ts` profiles, `yodel.config.ts`, the environment variables |
| [Starting a project](/sql-yodeler/adoption/) | a new project from a starter template, `yodel init --from` to adopt a database that already exists, and `yodel init --baseline` for another environment that holds the same schema |
| [Coming from another migration tool](/sql-yodeler/from-other-tools/) | for a database golang-migrate, goose, Flyway or dbmate manages: the concepts mapped, adopting it with `yodel init --from`, the old tool's files and table, and rollback with `yodel revert` |
| [Declaring the schema](/sql-yodeler/schema/) | the schema as TypeScript in `src/`: one export per object in the database's own DDL, references with `${}`, splitting it across files, generating objects with code, composites, what the build checks and the editor |
| [The two workflows](/sql-yodeler/workflows/) | the declarative path (`yodel plan`, `yodel apply` against the declared schema) and the versioned path (`yodel new`, then `yodel apply`) |
| [Migrations](/sql-yodeler/migrations/) | the migrations directory, the history table, the apply lock, resume, out-of-order refusal, `yodel status`, `yodel rebase`, `yodel repair`, `yodel checkpoint`, and rollback with `yodel revert` |
| [Data migrations](/sql-yodeler/steps/) | ClickHouse rebuilds, backfills, Postgres `PostgresMigrationOp` steps, `yodel cleanup` of what they keep, and manual steps |
| [Access control](/sql-yodeler/access/) | Postgres row-level security, policies, roles and grants, and ClickHouse users, roles, row policies and grants: `access` per environment, what the schema owns and what the environment owns, the plan's access section, adopting them |
| [Lint](/sql-yodeler/lint/) | the rules, `yodel:allow` silences, rule levels, `--env`, and the replay check (`--replay`) |
| [Lint and plan on pull requests only](/sql-yodeler/lint-and-plan/) | `ci: { apply: false }`: lint, the replay check and the plan comment on every pull request with readers only, no apply pipeline, and turning apply on later |
| [Setting up each forge](/sql-yodeler/forges/) | GitHub, GitLab and Forgejo side by side: the pipeline files, where the readers' and the writer's secrets go, the tokens for the plan comment, the drift watch and resuming, cloud roles over OIDC, runners and pull requests from forks |
| [Approval](/sql-yodeler/approval/) | the migrations Op and its gate, the plan digest, the pull request comment, and what happens when the digest moves |
| [Reports and the audit log](/sql-yodeler/audit/) | `yodel report`: every environment's migrations side by side, the audit log derived from the history and the gate ledger, its gaps, `audit.jsonl` and the HTML run view |
| [Schema from an ORM](/sql-yodeler/orm/) | `sources` in `yodel.config.ts`: tables an ORM defines, read from the DDL it prints, planned and applied with the rest |
| [Drift](/sql-yodeler/drift/) | `yodel drift` and the scheduled `WatchOp` |
| [Webhook events](/sql-yodeler/notify/) | `notify` in `yodel.config.ts`: signed events for an apply waiting for approval, a refusal, a failure and drift, and checking them in a receiver |
| [Generated schema reference](/sql-yodeler/schema-docs/) | `yodel docs`: an HTML reference and a Mermaid ERD generated from the declared schema |
| [Topology](/sql-yodeler/topology/) | ClickHouse on a single node, a cluster, a `Replicated` database and ClickHouse Cloud |
| [The commands](/sql-yodeler/cli/) | which command runs what (`yodel`, chant's CLI, the template's `just` targets), `yodel --help`, the exit codes, and the JSON Schemas of `--json` output |
| [Set up with a coding agent](/sql-yodeler/agents/) | the prompt to hand a coding agent, the steps it follows to add SQL Yodeler to a repository, and what it leaves to people |
| [Glossary](/sql-yodeler/glossary/) | the chant terms yodel's commands and output use, in yodel's terms |
| [Claims status](/sql-yodeler/claims/) | every scenario claim, what it says, and whether it passed plain and was caught broken on ClickHouse and on Postgres, with the pages each one proves |
Two example projects, one per dialect, show each of these on a real server. They are finished projects, and their READMEs are transcripts of the runs that made them, against chant's emulator, with the output each command printed. To follow along step by step, start with [Your first migration](/sql-yodeler/getting-started/):
- `examples/clickhouse`: adoption, a migration, a sort-key rebuild, a backfill, a lint silence, an approval that stops holding, drift, the replay check.
- `examples/postgres`: adoption, lint findings, a migration that fails part way and resumes, a column rename as an expand-and-contract step, a destructive change silenced, a fork resolved with `yodel rebase` and `yodel repair`, an out-of-order migration, drift, the replay check.
The pages quote those runs.
---
# Start a new schema
Source: https://intentius.io/sql-yodeler/for/new-schema/
For a team starting a schema from nothing: declare it in TypeScript in a project from a starter template, and yodel writes the first migration from it on a local database.
## Works
- A project with a profile, a migrate Op and a drift watch per environment, and pipelines for GitHub, GitLab and Forgejo
- The first migration written from `src/schema.ts`, planned, approved and applied on chant's local emulator
- Lint, the replay check and a plan comment on every pull request
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
#### ClickHouse
```sh
npx @intentius/sql-yodeler@latest create my-schema --clickhouse --database events
```
#### Postgres
```sh
npx @intentius/sql-yodeler@latest create my-schema --postgres --schema app
```
Next: [Your first migration](/sql-yodeler/getting-started/).
## Proof
The scenario claims below run what this room relies on against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `new` | yodel new writes the next migration offline, with no dev database, and it applies; on Postgres, functions, procedures and triggers too, which lint checks | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `lint` | yodel lint --replay replays the migrations into a fresh database and each gives the schema it recorded; offline, yodel lint fails a migrations directory with a fork (exit 3), naming both migrations; a checkpoint replays alone to its recorded schema, and a fresh environment starts from it; yodel revert undoes the newest migration behind the gate, with a hand-written step for its data step, back to its parent's recorded schema, and refuses a checkpoint or a migration before one; yodel test runs a project's tests on databases replayed from the migrations, a seed meeting the backfill after it, fails a case whose assertion does not hold, and refuses an environment with a history; on Postgres, a unique index over rows with duplicates is refused before the gate by its generated pre-check (exit 4, naming the statement and the count), and yodel lint flags it as data-dependent | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `approval` | a pending migration applies only after chant approve of its plan digest, and the history records that digest; a failing pre-migration check refuses it, and so does a policy rule, read at the base commit, unless an override is recorded for that digest, for a yodel revert as for an apply; the audit log derived from the history and the ledger accounts for every approval and apply, and names one removed; a reader and a writer that are one user are refused, and the reader, its password a minted token, cannot write; an approval is refused once the migration changed after it, and nothing is applied | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `drift` | yodel drift reports a declared object changed out of band, naming the property, and one dropped; on the versioned path it compares with the newest applied migration's recorded schema, so a pending migration is not drift | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
## Then read
### Tasks
- [Your first migration](/sql-yodeler/getting-started/)
- [Setting up each forge](/sql-yodeler/forges/)
### Background
- [Declaring the schema](/sql-yodeler/schema/)
- [The two workflows](/sql-yodeler/workflows/)
- [Migrations](/sql-yodeler/migrations/)
- [Approval](/sql-yodeler/approval/)
### Reference
- [The commands](/sql-yodeler/cli/)
- [Installing and configuring](/sql-yodeler/configuration/)
- [Glossary](/sql-yodeler/glossary/)
---
# Adopt a live database
Source: https://intentius.io/sql-yodeler/for/adopt/
For a team whose database already exists: read it into declarations and a baseline migration, then change it only through migrations.
## Works
- `yodel init --from ` reads the live database into `src/` and plans it back to no change before it writes anything
- A baseline migration recorded as applied, with none of its statements run
- Access adopted with the tables when the environment manages it: policies and grants, and on ClickHouse users and roles
- `yodel init --baseline` for another environment that holds the same schema
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
```sh
YODEL_CREDENTIALS=writer npx yodel init --from --force
```
Next: [Adopting an existing database](/sql-yodeler/adoption/#adopting-an-existing-database-yodel-init-from).
## Proof
The scenario claims below run what this room relies on against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `adopt` | yodel init --from adopts a live database without touching it, and yodel plan then shows no change; on Postgres its policies, row-level security and grants too, on ClickHouse its dictionaries, functions, roles, users, row policies and grants; yodel init --baseline records the baseline, behind the gate, in a second environment that holds the same schema, and refuses one that differs | ClickHouse: pass, caught; Postgres: pass, caught | `9329873`, 2026-10-10 |
## Then read
### Tasks
- [Starting a project](/sql-yodeler/adoption/)
- [Setting up each forge](/sql-yodeler/forges/)
### Background
- [The two workflows](/sql-yodeler/workflows/)
- [Access control](/sql-yodeler/access/#adopting-a-database)
### Reference
- [The commands](/sql-yodeler/cli/)
- [Installing and configuring](/sql-yodeler/configuration/)
---
# Lint and plan on pull requests
Source: https://intentius.io/sql-yodeler/for/lint-and-plan/
For a team that wants every migration reviewed in its pull request before SQL Yodeler applies anything.
## Works
- `yodel lint` and the replay check on every pull request, with no database credentials
- A plan comment per environment, made with read-only credentials, and a write probe that fails the job if they can write
- The drift watch on its schedule, with a tracking issue
- No apply pipeline and no writer in CI until you turn apply on
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
Set `ci: { forges: ["github", "gitlab", "forgejo"], apply: false }` in `yodel.config.ts`, then render the pipelines:
```sh
npm run ci
```
Next: [Lint and plan on pull requests only](/sql-yodeler/lint-and-plan/).
## Proof
The scenario claims below run what this room relies on against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `lint` | yodel lint --replay replays the migrations into a fresh database and each gives the schema it recorded; offline, yodel lint fails a migrations directory with a fork (exit 3), naming both migrations; a checkpoint replays alone to its recorded schema, and a fresh environment starts from it; yodel revert undoes the newest migration behind the gate, with a hand-written step for its data step, back to its parent's recorded schema, and refuses a checkpoint or a migration before one; yodel test runs a project's tests on databases replayed from the migrations, a seed meeting the backfill after it, fails a case whose assertion does not hold, and refuses an environment with a history; on Postgres, a unique index over rows with duplicates is refused before the gate by its generated pre-check (exit 4, naming the statement and the count), and yodel lint flags it as data-dependent | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `pr-comment` | the pull request comment lists the pending migrations with each statement's class, and the digest the gate asks for | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
## Then read
### Tasks
- [Lint and plan on pull requests only](/sql-yodeler/lint-and-plan/)
- [Setting up each forge](/sql-yodeler/forges/)
### Background
- [Lint](/sql-yodeler/lint/)
- [The pull request comment](/sql-yodeler/approval/#the-pull-request-comment)
- [Drift](/sql-yodeler/drift/)
### Reference
- [The commands](/sql-yodeler/cli/)
- [The lint rules](/sql-yodeler/lint/#the-rules)
---
# Coming from another tool
Source: https://intentius.io/sql-yodeler/for/coming-from/
For a team whose database another migration tool manages today: the concepts mapped, then the database adopted with init --from.
## Works
- Each concept of a numbered-files migration tool mapped to its SQL Yodeler counterpart
- The database adopted as it is, with the old tool's history table left out
- Rollback with `yodel revert`, planned from the recorded schema, behind the same approval
- A migration that fails part way resumes where it stopped; one out of order is refused
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
```sh
YODEL_CREDENTIALS=writer npx yodel init --from --force
```
Next: [Coming from another migration tool](/sql-yodeler/from-other-tools/).
## Proof
The scenario claims below run what this room relies on against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `adopt` | yodel init --from adopts a live database without touching it, and yodel plan then shows no change; on Postgres its policies, row-level security and grants too, on ClickHouse its dictionaries, functions, roles, users, row policies and grants; yodel init --baseline records the baseline, behind the gate, in a second environment that holds the same schema, and refuses one that differs | ClickHouse: pass, caught; Postgres: pass, caught | `9329873`, 2026-10-10 |
| `resume` | an interrupted apply resumes where it stopped: a backfill step written as yodel's form (table, key, batch size, SQL) from its receipts, running each batch once, and a failed statement at that statement, never resending one that ran | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `out-of-order` | a migration merged late, before one already applied, is refused unless --allow-out-of-order, and never skipped | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
## Then read
### Tasks
- [Coming from another migration tool](/sql-yodeler/from-other-tools/)
- [Cutting CI over](/sql-yodeler/from-other-tools/#cutting-ci-over)
- [Setting up each forge](/sql-yodeler/forges/)
### Background
- [Migrations](/sql-yodeler/migrations/)
- [Data migrations](/sql-yodeler/steps/)
### Reference
- [The commands](/sql-yodeler/cli/)
- [Glossary](/sql-yodeler/glossary/)
---
# Setting up CI for many teams
Source: https://intentius.io/sql-yodeler/for/platform/
For a platform team that runs the pipelines of many schema projects: one renderer, the same jobs on each forge, and a pinned image.
## Works
- `yodel ci` renders every project's pipelines from the installed package, and `yodel ci --check` fails a pipeline edited by hand or not rendered again
- `ci.forges` picks GitHub, GitLab or Forgejo, and `ci.jobs` keeps a project's own jobs across renders
- `ci.image` runs every job in the CI image, pinned by its digest
- Waves: one environment after another, each behind its own gate
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
```sh
npm run ci
```
Next: [The pipelines: yodel ci](/sql-yodeler/workflows/#the-pipelines-yodel-ci).
## Proof
The scenario claims below run what this room relies on against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `waves` | the apply pipeline runs one wave per environment, in order, each behind its gate policy read from the base commit, applies only what the wave before applied, and applies a tenant set's migrations to every tenant behind one gate; a sealed wave counts only an approval sealed by a signer the base commit lists | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `template` | a project from the starter template, on Forgejo: apply only after approval, lint with replay and the plan comment on a pull request, and the approved change applied on merge; a sealed wave applies only on an approval sealed by a signer listed at the base, and a pr-review wave on the review of a writer other than the author; a pull request job cannot write, a forked migration fails lint and is annotated, a stale or hand-edited pipeline fails yodel ci --check, the CI image pinned by digest runs a pull request's jobs, a command token source mints the reader's password, and the drift watch keeps one tracking issue | ClickHouse: pass, caught; Postgres: pass, caught | `868ff97`, 2026-10-10 |
## Then read
### Tasks
- [Setting up each forge](/sql-yodeler/forges/)
- [Lint and plan on pull requests only](/sql-yodeler/lint-and-plan/)
### Background
- [The pipelines: yodel ci](/sql-yodeler/workflows/#the-pipelines-yodel-ci)
- [Waves: a gate per environment](/sql-yodeler/approval/#waves-a-gate-per-environment)
- [Least privilege on each forge](/sql-yodeler/configuration/#least-privilege-on-each-forge)
### Reference
- [yodel.config.ts](/sql-yodeler/configuration/#yodelconfigts)
- [The commands](/sql-yodeler/cli/)
---
# Working with a coding agent
Source: https://intentius.io/sql-yodeler/for/agents/
For someone who hands SQL Yodeler's setup to a coding agent, and keeps approvals and applies for people.
## Works
- One prompt that sets SQL Yodeler up in a repository and opens a pull request, and stops short of applying, approving or merging
- `yodel mcp`: read-only tools for status, plan, lint and drift
- JSON Schemas for the `--json` output of each command
- llms.txt and a prompt on each task page
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
Hand the agent this prompt:
```text
Set up SQL Yodeler in this repository.
Read https://intentius.io/sql-yodeler/llms.txt first, then
https://intentius.io/sql-yodeler/agents/ and follow it.
Open a pull request with the result.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
Next: [Set up with a coding agent](/sql-yodeler/agents/).
## Proof
No scenario claim runs what this room describes. [Claims status](/sql-yodeler/claims/) lists every claim.
## Then read
### Tasks
- [Set up with a coding agent](/sql-yodeler/agents/)
- [Your first migration](/sql-yodeler/getting-started/)
### Background
- [Rules for the agent](/sql-yodeler/agents/#rules-for-the-agent)
- [Approval](/sql-yodeler/approval/)
### Reference
- [Read-only tools (yodel mcp)](/sql-yodeler/agents/#read-only-tools-yodel-mcp)
- [JSON output](/sql-yodeler/cli/#json-output)
- [Exit codes](/sql-yodeler/cli/#exit-codes)
---
# Security review
Source: https://intentius.io/sql-yodeler/for/security/
What each job can reach, who can approve an apply, and the record every change leaves.
## Works
- A reader and a writer per environment: only the apply wave holds the writer, and a write probe fails a pull request job that can write
- Each approval bound to a digest of the plan; when the plan moves, the approval no longer holds
- Approval modes, including approvals sealed with a signer listed in the repository
- An audit log derived from the history and the gate ledger
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
```sh
npx yodel config check --write-probe
```
Next: [Credentials: a reader and a writer](/sql-yodeler/configuration/#credentials-a-reader-and-a-writer).
## Proof
The scenario claims below run what this room relies on against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `approval` | a pending migration applies only after chant approve of its plan digest, and the history records that digest; a failing pre-migration check refuses it, and so does a policy rule, read at the base commit, unless an override is recorded for that digest, for a yodel revert as for an apply; the audit log derived from the history and the ledger accounts for every approval and apply, and names one removed; a reader and a writer that are one user are refused, and the reader, its password a minted token, cannot write; an approval is refused once the migration changed after it, and nothing is applied | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `waves` | the apply pipeline runs one wave per environment, in order, each behind its gate policy read from the base commit, applies only what the wave before applied, and applies a tenant set's migrations to every tenant behind one gate; a sealed wave counts only an approval sealed by a signer the base commit lists | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
## Then read
### Tasks
- [Setting up each forge](/sql-yodeler/forges/)
- [Access control](/sql-yodeler/access/)
### Background
- [Approval modes](/sql-yodeler/approval/#approval-modes-which-approvals-count)
- [Sealed approvals](/sql-yodeler/approval/#sealed-approvals)
- [The plan digest](/sql-yodeler/approval/#the-plan-digest)
- [The audit log](/sql-yodeler/audit/#the-audit-log)
### Reference
- [Least privilege on each forge](/sql-yodeler/configuration/#least-privilege-on-each-forge)
- [yodel config check](/sql-yodeler/configuration/#yodel-config-check)
- [Claims status](/sql-yodeler/claims/)
---
# Evaluating SQL Yodeler
Source: https://intentius.io/sql-yodeler/for/evaluate/
For someone deciding whether SQL Yodeler fits: two finished example projects, the claims that run against real servers, and a first migration on a local database.
## Works
- Two example projects, one per dialect, whose READMEs are transcripts of real runs
- Scenario claims that run each feature against a real server, plain and with the behaviour broken
- Your first migration on chant's local emulator, with no cloud account
## Differs
### By database
#### ClickHouse
- A project owns ClickHouse databases (`yodel create --clickhouse --database `).
- A rebuild keeps the old table until `yodel cleanup` drops it, and lint flags mutations and rebuilds (`ch-mutation`, `ch-rebuild`).
#### Postgres
- A project owns Postgres schemas (`yodel create --postgres --schema `).
- Roles stay the environment's: the schema declares the policies and grants that name them.
### By forge
#### GitHub
- The writer's secrets are secrets of a GitHub environment limited to `main`.
- The plan comment and the drift issue use the job's own token.
#### GitLab
- The writer's variables are protected and scoped to the environment; the readers' are not protected.
- The plan comment and the drift issue need `GITLAB_TOKEN`, and each drift watch runs from a pipeline schedule you create.
#### Forgejo
- Secrets belong to the repository, with no environments, so limit who can push branches.
- Jobs need a runner with the `docker` label, and a job gets no OIDC token for cloud roles.
## First step
#### ClickHouse
```sh
npx @intentius/sql-yodeler@latest create my-schema --clickhouse --database events
```
#### Postgres
```sh
npx @intentius/sql-yodeler@latest create my-schema --postgres --schema app
```
Next: [Your first migration](/sql-yodeler/getting-started/).
## Proof
The scenario claims below run what this room relies on against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `new` | yodel new writes the next migration offline, with no dev database, and it applies; on Postgres, functions, procedures and triggers too, which lint checks | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `template` | a project from the starter template, on Forgejo: apply only after approval, lint with replay and the plan comment on a pull request, and the approved change applied on merge; a sealed wave applies only on an approval sealed by a signer listed at the base, and a pr-review wave on the review of a writer other than the author; a pull request job cannot write, a forked migration fails lint and is annotated, a stale or hand-edited pipeline fails yodel ci --check, the CI image pinned by digest runs a pull request's jobs, a command token source mints the reader's password, and the drift watch keeps one tracking issue | ClickHouse: pass, caught; Postgres: pass, caught | `868ff97`, 2026-10-10 |
| `template-github` | a project from the starter template, on GitHub Actions (act and a mock GitHub): apply only after approval, lint with replay and the plan comment on a pull request, and the approved change applied on merge; a sealed wave applies only on an approval sealed by a signer listed at the base, and a pr-review wave on the review of a writer other than the author; a pull request job cannot write, a forked migration fails lint and is annotated, a stale or hand-edited pipeline fails yodel ci --check, the CI image pinned by digest runs a pull request's jobs, a command token source mints the reader's password, and the drift watch keeps one tracking issue | ClickHouse: pass, caught; Postgres: pass, caught | `868ff97`, 2026-10-10 |
## Then read
### Tasks
- [Your first migration](/sql-yodeler/getting-started/)
### Background
- [The ClickHouse example](https://github.com/INTENTIUS/sql-yodeler/tree/main/examples/clickhouse)
- [The Postgres example](https://github.com/INTENTIUS/sql-yodeler/tree/main/examples/postgres)
- [The two workflows](/sql-yodeler/workflows/)
### Reference
- [Claims status](/sql-yodeler/claims/)
- [The commands](/sql-yodeler/cli/)
---
# Declaring the schema
Source: https://intentius.io/sql-yodeler/schema/
SQL Yodeler is declarative: `src/` says what the database should be, and yodel works out the change from what it is. The schema is TypeScript in `src/`. Each exported constant is one object: a database or schema, a table, a view, an index. Its body is the database's own DDL, in a tagged template from chant's `sql` lexicon, so a statement reads the way `SHOW CREATE` prints it. TypeScript holds the statements together: objects import each other, refer to each other with `${}`, and composites make families of them. `yodel new` writes the next migration from the change since the last one, and `yodel plan` shows a declarative change against the live server ([The two workflows](/sql-yodeler/workflows/)).
#### ClickHouse
```ts
// src/schema.ts
import { database, table } from "@intentius/chant-lexicon-sql/clickhouse";
export const db = database`
CREATE DATABASE events
ENGINE = Atomic`;
export const events = table`
CREATE TABLE ${db}.events (
id UInt64,
kind LowCardinality(String),
at DateTime
)
ENGINE = MergeTree
ORDER BY (kind, at)`;
```
#### Postgres
```ts
// src/schema.ts
import { index, schema, table } from "@intentius/chant-lexicon-sql/postgres";
export const app = schema`CREATE SCHEMA app`;
export const orders = table`
CREATE TABLE ${app}.orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL DEFAULT 'placed',
amount numeric(12,2) NOT NULL,
placed_at timestamptz NOT NULL DEFAULT now()
)`;
export const ordersByStatus = index`
CREATE INDEX orders_status_idx ON ${orders} (${orders.columns.status})`;
```
The export name is the object's identity, and the name in the SQL is its name on the server. Keep the export name when you change the name in the SQL, and the change is planned as a rename, not as a drop and a create.
## Schema as data
The build reads `src/` as data: it reduces each file to the objects it declares, without running it. So the same source gives the same schema on any machine and in every environment, the schema at two commits can be compared with no database, and nothing in it depends on where or when it was built.
That makes `src/` a small part of TypeScript: exported objects, constants and strings, imports between files, `${}` references, and composites called with literal options. What a program would do is not schema:
- Reading an environment variable or a file, or querying a database. The schema is the same in every environment; what differs between them, the server and the credentials, is in `chant.config.ts`'s profiles ([Installing and configuring](/sql-yodeler/configuration/)).
- `if` or a loop around exports, `let`, and functions or callbacks in `src/`. Write each object as an export, or put the repetition in a composite ([Generating objects](#generating-objects)).
A file outside that part still builds, but chant runs it instead of reading it, and `chant build --verbose` names the file and the reason. chant's [TypeScript as data](https://intentius.io/chant/concepts/typescript-as-data/) page has the whole subset.
## References
An interpolated object is a reference, not text. `${db}.events` still reads `events.events` in the statement sent to the server, and the build also knows the table belongs to that database, so it creates the database first. A column is referenced through `.columns`:
```ts
// src/views.ts
import { view } from "@intentius/chant-lexicon-sql/clickhouse";
import { db, events } from "./schema.js";
export const byKind = view`
CREATE VIEW ${db}.events_by_kind AS
SELECT ${events.columns.kind} AS kind, count() AS n
FROM ${events}
GROUP BY kind`;
```
A misspelled reference, `${events.columns.knd}`, fails the build and says which interpolation is undefined. When a view reads every column through a reference, the build also records which columns each of its output columns comes from.
A name written as plain text is SQL, not a reference: `FROM events.events` builds and runs, but the build cannot order the view after the table. Plain text is the way to name something the project does not declare, such as a table another team owns (`REFERENCES billing.accounts (id)`) or a role the environment keeps.
## Splitting the schema
Any number of files in `src/` make up the schema, and they import each other like any TypeScript modules: a database in `src/db.ts`, a table per file, the views beside the tables they read. The build orders the objects by their references, whatever the file order. Plain `.sql` files in `src/` and DDL an ORM prints join the same build ([Schema from an ORM](/sql-yodeler/orm/)).
## Generating objects
A family of objects with the same shape is a composite of your own: the shape once, in a module outside `src/`, and one call per object in `src/`. A rollup table per region:
```ts
// lib/rollup.ts
import { Composite } from "@intentius/chant/composite";
import { table, type ClickHouseDatabase } from "@intentius/chant-lexicon-sql/clickhouse";
export const DailyRollup = Composite((props: { db: ClickHouseDatabase; region: string }) => ({
table: table`
CREATE TABLE ${props.db}.${`daily_${props.region}`} (
day Date,
kind LowCardinality(String),
n UInt64
)
ENGINE = SummingMergeTree
ORDER BY (day, kind)`,
}), "DailyRollup");
```
```ts
// src/daily.ts
import { DailyRollup } from "../lib/rollup.js";
import { db } from "./schema.js";
export const { table: dailyEu } = DailyRollup({ db, region: "eu" });
export const { table: dailyUs } = DailyRollup({ db, region: "us" });
export const { table: dailyApac } = DailyRollup({ db, region: "apac" });
```
The build reads `src/daily.ts` as data and writes three `CREATE TABLE` statements. A fourth region is one more line, and `yodel new` writes its table into the next migration.
## Composites
The `sql` lexicon ships composites of its own, for shapes many schemas need. On Postgres, `TenantTable` puts the tenant column first in the table's primary key and in an index:
```ts
// src/tickets.ts
import { TenantTable } from "@intentius/chant-lexicon-sql/postgres";
import { app } from "./schema.js";
export const { table: tickets, index: ticketsByTenant } = TenantTable({
name: "tickets",
schema: app,
columns: "id bigint GENERATED ALWAYS AS IDENTITY, title text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()",
primaryKey: "id",
indexOn: "created_at DESC",
});
```
That builds a table with `PRIMARY KEY (tenant_id, id)` and the index `tickets_tenant_idx` on `(tenant_id, created_at DESC)`. The others: `SoftDeleteTable`, `AuditLogTable`, `JoinTable` and `RefreshedView` on Postgres; `EventsTable`, `ReplacingTable`, `RollupView`, `CdcMirror` and `ShardedTable` on ClickHouse. chant's [composites page](https://intentius.io/chant/lexicons/sql/composites/) has each one's options.
## Data changes
The declaration says what the schema should be; some changes also move data. When the change from the last migration is one no statement makes in place, `yodel new` writes it as a data step of the same migration: a ClickHouse sort-key or engine change as a rebuild into a new table, a Postgres column rename or type change as expand and contract, and a backfill you write with `yodel new --backfill`. A data step runs under the same lock and approval as the statements, and resumes where it stopped ([Data migrations](/sql-yodeler/steps/)).
## What the build checks
`chant build`, which yodel runs itself before it writes or plans a migration, parses every statement and checks it against the catalog of a pinned server, read from that release's own system tables: ClickHouse 26.8, and each Postgres major from 14 to 18. A mistake fails the build with a message that names the object, the column and the release:
- a column type the server does not have, in any spelling it does not accept (`UInt46`, `uint64`, `numerik`), wrong type parameters (`Decimal(100, 2)`, `numeric(2000,2)`, `bigint(8)`), or a nesting it refuses (`Nullable(Array(String))`)
- an engine, codec, codec parameter, MergeTree setting, Postgres storage parameter, index access method or operator class the server does not have
- a function the server does not have, in a default, a key, a partition, a TTL, a check or an index
- a column named in a key, an index, a foreign key or a grant that the table does not declare, and a foreign key between columns whose types cannot compare
- a column declared twice, or two exports that declare the same object
Names written as plain text for objects the project does not declare, and column names inside expressions, are left to the server. Lint rules then flag what the server would accept but should not: a primary key that is not a prefix of the sort key, a partition finer than a day, a timestamp without time zone, a foreign key no index leads with. [Lint](/sql-yodeler/lint/) has yodel's own rules on migrations, and chant's [lint rules page](https://intentius.io/chant/lexicons/sql/lint-rules/) has the build's.
## In the editor
chant's language server (`chant serve lsp`, set up as [chant's LSP page](https://intentius.io/chant/cli/lsp/) shows) completes inside the templates from the same catalog: engines after `ENGINE =`, column types, codecs, settings and functions, and after `${` the tables and views the project declares and their columns. Hovering a type, an engine or a `${}` reference shows what it is, and chant's lint findings appear as you type.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `new` | yodel new writes the next migration offline, with no dev database, and it applies; on Postgres, functions, procedures and triggers too, which lint checks | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `declarative` | the declarative path: yodel plan shows the change against the live server and yodel apply makes it behind the plan-bound gate, for src/ declarations (on Postgres, functions, procedures and triggers, and access control: a role, a policy and grants, with a grant made by hand revoked; on ClickHouse, a dictionary, a function, and access control: a role, a user, a row policy and grants, with a grant made by hand revoked) and for an ORM's exported DDL | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
---
# Data migrations
Source: https://intentius.io/sql-yodeler/steps/
## Optional: hand this page to your coding agent
```text
Write the data change I describe as a step of a migration, following https://intentius.io/sql-yodeler/steps/.
For a backfill, run `npx yodel new --backfill`, fill in `backfills/.json` (the table, the key, the batch size and the SQL of one batch between `{from}` and `{to}`), and run the same command again. For a Postgres column rename, write `-- previously: ` on the new column's line in src/ before `npx yodel new `.
Run `npx yodel lint`, and open a pull request with src/, backfills/ and the new migration.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
A migration changes data as well as schema. Most of a migration is statements; a change that is not one statement, such as a backfill, a ClickHouse rebuild or a Postgres rename that readers must survive, is a data step of the migration, behind the same approval and recorded in the same history as the DDL: `yodel new` writes it into `migration.json` with the Op that makes it, and into `migration.sql` as a comment. `yodel apply` runs three kinds of Op step itself, in their place among the statements (the statements before a step first, the ones after it only once it succeeded), under the same lock and behind the same approval:
| Step | Dialect | Written by `yodel new` for |
|---|---|---|
| `ClickHouseRebuildOp` | ClickHouse | a change `ALTER` cannot make: the sort key, the engine, a key column's type or name |
| backfill | ClickHouse, Postgres | `yodel new --backfill`: a data migration you write, as a table, a key, a batch size and the SQL of one batch |
| `PostgresMigrationOp` | Postgres | a column rename (SQLPG205) or a type change across kinds (SQLPG208) |
A step and any file it runs are in the migration's checksum, so in the plan digest the approval binds. Its history rows have `kind = 'step'`, and the `succeeded` (or `failed`) row's `note` is the step's summary as JSON. A step that failed or was stopped runs again on the next `yodel apply` and resumes from its receipts, skipping the partitions or batches that finished. `yodel lint --replay` runs rebuild and `PostgresMigrationOp` steps in the replayed database and skips backfills, which move data the replayed database does not have.
Anything else, a manual step or an Op step `yodel apply` does not run (a rebuild in app mode), is refused before anything runs: `yodel plan` and `yodel status` exit 2, `yodel apply` exits 2 and applies nothing, and `yodel lint` reports it (rule `refused-step`). The output quoted here is from the examples' runs; `...` marks lines left out.
## ClickHouse rebuilds
Changing the sort key of the ClickHouse example's `events` table, `ORDER BY (kind, at)` to `ORDER BY (kind, id)`:
```console
$ npx yodel new events-by-id
Wrote migrations/20261010T1722-events-by-id/ (0 statements; follows 20261010T1722-add-country)
This migration contains an Op step: ClickHouseRebuildOp for events (shop.events), SQLCH220.
It is not SQL. migration.json holds the Op's declaration:
export const { op } = ClickHouseRebuildOp({ name: "rebuild-shop-events", env: "", table: "shop.events", dualWrite: { mode: "materialized-view", cutoverColumn: "at" } });
```
```sql
-- yodel migration 20261010T1722-events-by-id
-- parent: 20261010T1722-add-country
-- This migration contains an Op step, not only SQL:
-- ClickHouseRebuildOp for events (shop.events), SQLCH220: not SQL; migration.json holds its declaration
-- yodel:allow ch-rebuild 600 rows; the copy takes seconds
-- events (shop.events): SQLCH220 made by ClickHouseRebuildOp, not a statement:
-- export const { op } = ClickHouseRebuildOp({ name: "rebuild-shop-events", env: "", table: "shop.events", dualWrite: { mode: "materialized-view", cutoverColumn: "at" } });
```
(The `yodel:allow` line was added after `yodel lint` flagged the rebuild; see [Lint](/sql-yodeler/lint/#silencing-a-finding).)
The step is chant's `ClickHouseRebuildOp`, run by chant's local executor inside the apply, with the migration's recorded schema as its declaration (the table is rebuilt as this migration declares it, however far `src/` has moved since). In materialized-view mode it:
1. creates `__chant_new` with the new definition;
2. creates a materialized view, `__chant_dual`, that writes every row inserted at or after a cut-over time (now plus a minute, by the table's time column, `cutoverColumn`) into the new table;
3. waits for the cut-over, then copies the table partition by partition, writing a receipt for each partition: the rows from before the cut-over, then the rows at or after it (future timestamps) that the view has not written, so every row the table held is copied once;
4. verifies that counts and checksums of every row, per partition, match in both tables, and fails the step with nothing swapped if they do not;
5. compares the two tables again just before the swap, and fails with nothing swapped if a row reached only the old table since the verification; otherwise swaps the tables (`EXCHANGE TABLES`), drops the view, and keeps the old table as `__chant_old` until a retention date 7 days on. [`yodel cleanup`](#cleaning-up-what-steps-kept) drops it after that date.
yodel builds the Op with `gates: "outer"`, so it has no gate and no Drop phase of its own: its approval is the migration's. The replay check runs the same step in an empty database, which shows the statements it sends:
```console
$ npx yodel lint --replay replay --reset --reset-shared dev
...
replay: 20261010T1722-events-by-id: replaying
-- shop.events needs a rebuild (SQLCH220 Change the sorting key); 4 column(s) copied
-- SQLCH220 orderBy: ( kind , at ) -> ( kind , id )
CREATE TABLE `shop`.`events__chant_new`
(
id UInt64,
kind LowCardinality(String),
at DateTime,
country LowCardinality(String) DEFAULT ''
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(at)
ORDER BY (kind, id) COMMENT '[chant managed-by=chant rebuild=shop.events role=new]'
CREATE MATERIALIZED VIEW `shop`.`events__chant_dual` TO `shop`.`events__chant_new` AS SELECT `id`, `kind`, `at`, `country` FROM `shop`.`events` WHERE `at` >= toDateTime64('2026-10-10 17:23:41.000', 3, 'UTC') COMMENT 'chant rebuild of shop.events: rows at or after the cut-over [chant managed-by=chant rebuild=shop.events role=dual cutover=2026-10-10T17%3A23%3A41.000Z]'
-- waiting 6s for the cut-over at 2026-10-10T17:23:41.000Z
-- backfill of shop.events: 0 partition(s), 0 copied, 0 already copied, 0 cleared first
-- shop.events: 0 partition(s), 0 row(s) (0 of them before the cut-over at 2026-10-10T17:23:41.000Z), counts and checksums equal in the old and new tables
EXCHANGE TABLES `shop`.`events` AND `shop`.`events__chant_new`
DROP VIEW `shop`.`events__chant_dual` SYNC
RENAME TABLE `shop`.`events__chant_new` TO `shop`.`events__chant_old`
ALTER TABLE `shop`.`events__chant_old` MODIFY COMMENT 'chant rebuild of shop.events: the old table, kept until 2026-10-17T17:23:41.114Z [chant managed-by=chant rebuild=shop.events role=old retain-until=2026-10-17T17%3A23%3A41.114Z]'
ALTER TABLE `shop`.`events` MODIFY COMMENT '[chant managed-by=chant]'
replay: 20261010T1722-events-by-id: matches its recorded schema
...
```
In the example's apply, with 600 rows in six monthly partitions:
```console
$ npx yodel apply dev
...
Running migrate-dev (/clickhouse/ops/migrate-dev.op.ts)
lock: no KeeperMap on this server (KeeperMap is disabled because 'keeper_map_path_prefix' config is not defined. (BAD_ARGUMENTS) (version 26.8.15.10 (official build))); the lock is a file on this machine, so cross-runner locking needs Keeper (and keeper_map_path_prefix)
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:82c892052401d2b5a35d49964395124736586c6a8222858f8f0905155d29eb47, by yodel at 2026-10-10T17:22:36.240Z
20261010T1722-events-by-id: applying
step 0: ClickHouseRebuildOp for events (shop.events)
step 0: ok {"op":"ClickHouseRebuildOp","table":"shop.events","state":"rebuild","partitions":6,"copied":6,"skipped":0,"cleared":0,"repaired":0,"verifiedRows":600,"swapped":true,"oldTable":"shop.events__chant_old","retainUntil":"2026-10-17T17:22:47.304Z"}
20261010T1722-events-by-id: applied
...
```
The Op is also built with `onFailure: "keep"`, so a rebuild that failed or was killed keeps its new table and resumes on the next apply from the backfill's receipts: every partition with a receipt is skipped. To start it over instead, drop `__chant_new` and `__chant_dual` (a refusal or a failed verification names them). On a cluster of more than one shard the rebuild copies and verifies every shard; see [Topology](/sql-yodeler/topology/).
`yodel new` picks materialized-view mode when the table has a time column to cut over on. A rebuild in app mode (the application writes both tables) is refused by `yodel apply`.
The topology the rebuild renders for is the environment's, the same as the statements around it ([Topology](/sql-yodeler/topology/)). `yodel lint` reports every rebuild as `ch-rebuild`, an error unless silenced or lowered ([Lint](/sql-yodeler/lint/)).
## Backfills
A backfill is a data migration in the same history as the DDL: "fill this column, a range of ids at a time". You write it as a form of a few fields, and yodel runs it as batches, each with its own receipt, so an apply that stops part way resumes at the first batch that has none.
### Writing one
`yodel new --backfill` writes a template at `backfills/.json` when there is none, and no migration:
```json
{
"table": ".",
"key": "id",
"batchSize": 100000,
"settings": {
"mutations_sync": "2"
},
"sql": "write the backfill of fill-country: ALTER TABLE . UPDATE = WHERE id >= {from} AND id < {to}"
}
```
Fill it in. Filling `events.country` in the ClickHouse example's schema, 100,000 ids a batch:
```json
{
"table": "shop.events",
"key": "id",
"batchSize": 100000,
"settings": { "mutations_sync": "2" },
"sql": "ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE id >= {from} AND id < {to} AND country = ''"
}
```
Run the same command again. `yodel new` checks the form, writes it into the migration's step in `migration.json` (so it counts in the checksum and the plan digest), and ends the migration with the step. With `--backfill`, a migration is written even when the declared schema has not changed. It prints:
```text
Wrote migrations/20261010T1212-fill-country/ (0 statements; follows 20261010T1212-baseline)
This migration ends with a backfill of shop.events by id, 100000 a batch. Each batch runs:
ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE id >= {from} AND id < {to} AND country = '';
yodel apply runs the batches after the statements before it, each with its own receipt, and resumes from them after a failure.
```
and `migration.sql` shows the step as comments:
```sql
-- yodel migration 20261010T1212-fill-country
-- parent: 20261010T1212-baseline
-- This migration contains an Op step, not only SQL:
-- a backfill of shop.events by id, 100000 a batch, its batches resumable from their receipts
-- backfill (fill-country): a backfill of shop.events by id, 100000 a batch, not a statement; each batch runs:
-- ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE id >= {from} AND id < {to} AND country = '';
```
`yodel plan` lists the step with the table, the batches and the SQL of one batch, and the pull request comment shows the same under "Op steps", with the form as written. `--backfill ` reads the form from another `.json` file in the project.
### The form
| Field | |
|---|---|
| `table` | the table the backfill fills, qualified as in your SQL |
| `key`, `batchSize` | batches by ranges of an integer column: each range is `batchSize` wide, `{from}` inclusive to `{to}` exclusive |
| `batches` | instead of `key` and `batchSize`: the batches as a list, strings or numbers, each one `{batch}` in the SQL |
| `sql` | the SQL of one batch, an `UPDATE` or an `INSERT ... SELECT`: one statement, or a list run in order |
| `settings` | optional: ClickHouse query settings for each statement (`mutations_sync: "2"` makes an `ALTER TABLE ... UPDATE` finish before the batch's receipt is written); on Postgres, settings for the batch's transaction (`work_mem`) |
With `key`, the ranges come from the table's smallest and largest key when the step runs, aligned to multiples of `batchSize`, so a rerun sees the same ranges and skips the ones with a receipt. Rows added since the first run that fall in a range already done are not filled again; filter on the value still being empty (as `country = ''` does) and run the backfill as a new migration if that matters. A key that is not an integer (a date, a string) is refused when the step runs; list the batches with `batches` instead, for example one per month:
```json
{
"table": "shop.events",
"batches": ["202606", "202607", "202608"],
"settings": { "mutations_sync": "2" },
"sql": "ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = {batch} AND country = ''"
}
```
`{batch}` is replaced as written, so quote it in the SQL when it is a string (`'{batch}'`).
### How it runs
`yodel apply` runs the step after the statements before it, under the migration's lock and approval. For each batch it reads the batch's receipt, skips the batch when the receipt is there, and otherwise runs the batch's statements and writes the receipt last, only when they all succeeded. A run that fails or is stopped leaves the finished batches' receipts, and the next `yodel apply` resumes at the first batch without one. The step's history note counts the batches: `{"op":"backfill","table":"shop.events","name":"backfill-fill-country","effects":3,"skipped":2,"ran":1}` is a rerun that found two batches done and ran the third.
On ClickHouse, make each batch safe to run twice: the batch that was running when a run stopped runs again on the next one. The example's `UPDATE` touches only rows still empty. On Postgres each batch is one transaction with its receipt, so a batch is never half done ([Backfills on Postgres](#backfills-on-postgres)).
The receipts are rows on the environment's server, addressed `yodel///`, so the same batch in two migrations is two receipts. They are kept by the sql lexicon's receipt store (`sqlReceiptStore` from `@intentius/chant-lexicon-sql/receipts`). On ClickHouse they are in `chant_receipts.receipts`, the table the rebuild keeps its partition receipts in.
### Backfills on Postgres
On Postgres the batches' SQL runs on the apply's own connection to the environment's server, and the receipts are rows in `.__chant_receipts` (chant's Postgres receipts table, the one `PostgresMigrationOp` keeps in the migrated table's schema), in the schema yodel's history is in: `yodeler.__chant_receipts` by default.
Each batch runs in one transaction: `BEGIN` at its first statement, then its statements, then its receipt, then `COMMIT`. A batch whose statement fails is rolled back whole and leaves no receipt, and a run killed in the middle of a batch leaves neither its changes nor its receipt, so the next `yodel apply` runs that batch again from its first statement and skips every batch that committed. A Postgres batch therefore does not need to be safe to run twice, but its statements must be ones Postgres runs in a transaction block: no `CREATE INDEX CONCURRENTLY`, no `VACUUM`. Keep batches small enough that holding their row locks until the batch commits is acceptable.
The batch's transaction has `lock_timeout` and `statement_timeout` set from the environment's profile (`lockTimeoutMs`, default 5 s, and `scanTimeoutMs`, default none). A batch blocked behind another session's lock fails when the lock timeout passes; the next `yodel apply` resumes at it. The form's `settings` are set for the batch's transaction only (`set_config(name, value, true)`), for example `{ "work_mem": "256MB" }`.
A Postgres backfill filling a new column, 10,000 ids a batch:
```json
{
"table": "shop.orders",
"key": "id",
"batchSize": 10000,
"sql": "UPDATE shop.orders SET region = lower(country) WHERE id >= {from} AND id < {to}"
}
```
An `INSERT ... SELECT` works the same way, for example copying rows into a new table a range at a time: `INSERT INTO shop.orders_archive SELECT * FROM shop.orders WHERE id >= {from} AND id < {to} AND placed_at < '2025-01-01'`.
### A backfill as a chant Op
For a backfill the form cannot say (batches that are not a list or ranges of one key, a step that calls another activity, a check between batches), write the Op yourself. `yodel new --backfill ` with a `` that is not `.json` takes a module exporting a chant Op whose every step is an `effect()` batch with its own receipt, and writes a template at `` when it does not exist, and no migration:
```console
$ npx yodel new fill-country --backfill backfills/country.ts
Wrote a backfill template at backfills/country.ts. Write its batches, then run yodel new fill-country --backfill backfills/country.ts again. No migration written.
```
The ClickHouse example's backfill fills a column added two migrations earlier, one month per batch:
```ts
/**
* Fills events.country, one month of events per batch. Each batch is an
* effect() with its own receipt, so a run that stops part way resumes at the
* first month without one. The UPDATE touches only rows still empty, so a
* batch that ran half way can run again.
*
* yodel new --backfill copies this file into the migration's directory as
* backfill.ts; that copy is the one yodel apply runs, and it counts in the
* migration's checksum.
*/
import { EffectReceipt } from "@intentius/chant";
import { Op, activity, effect, phase } from "@intentius/chant/op";
const months = ["202606", "202607", "202608", "202609", "202610", "202611"];
const batch = (month: string) =>
effect(EffectReceipt(`fill-country-${month}`, { effect: `fill-country/${month}`, flavor: "existence" }), [
activity(
"yodelSql",
{
sql: `ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = ${month} AND country = ''`,
settings: { mutations_sync: "2" },
},
"atMostOnce",
),
]);
export const op = Op({
name: "backfill-fill-country",
overview: "events.country from the id, a month at a time",
phases: [phase("Backfill", months.map(batch))],
});
```
Run again, `yodel new` copies the file into the migration's directory as `backfill.ts` and ends the migration with the step. With `--backfill`, a migration is written even when the declared schema has not changed.
```console
$ npx yodel new fill-country --backfill backfills/country.ts
Wrote migrations/20261010T1722-fill-country/ (0 statements; follows 20261010T1722-events-by-id)
This migration ends with a backfill step: the Op backfill.ts exports, copied into the migration's directory.
yodel apply runs its effect() batches after the statements before it, resuming from their receipts after a failure.
```
```sql
-- yodel migration 20261010T1722-fill-country
-- parent: 20261010T1722-events-by-id
-- This migration contains an Op step, not only SQL:
-- a backfill: the Op backfill.ts exports, its effect() batches resumable from their receipts
-- backfill (fill-country): made by the Op backfill.ts exports, not a statement
```
At apply time, chant's `effect()` cycle runs each batch: read its receipt, skip the batch if the receipt is there, otherwise run its steps and write the receipt last, only when they all succeeded.
```console
$ npx yodel apply dev
...
Running migrate-dev (/clickhouse/ops/migrate-dev.op.ts)
lock: no KeeperMap on this server (KeeperMap is disabled because 'keeper_map_path_prefix' config is not defined. (BAD_ARGUMENTS) (version 26.8.15.10 (official build))); the lock is a file on this machine, so cross-runner locking needs Keeper (and keeper_map_path_prefix)
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:96f8f3625b2628eedd9a5567254b9b7ecf5e53ae0a22550d6da7cf3a37ff200e, by yodel at 2026-10-10T17:22:58.009Z
20261010T1722-fill-country: applying
step 0: backfill backfill.ts
ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = 202606 AND country = ''
ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = 202607 AND country = ''
ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = 202608 AND country = ''
ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = 202609 AND country = ''
ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = 202610 AND country = ''
ALTER TABLE shop.events UPDATE country = ['DE', 'FR', 'US'][id % 3 + 1] WHERE toYYYYMM(at) = 202611 AND country = ''
step 0: ok {"op":"backfill","file":"backfill.ts","name":"backfill-fill-country","effects":6,"skipped":0,"ran":6}
20261010T1722-fill-country: applied
...
```
The rules for a backfill module:
- Each step is an `effect()` batch. Its SQL runs with `activity("yodelSql", { sql, settings? })`, which yodel provides, on the environment's server; any activity chant has works too.
- On ClickHouse, make each batch safe to run twice. A batch that was running when a run stopped runs again on the next one. The example's `UPDATE` touches only rows still empty. (On Postgres a batch is one transaction with its receipt; see [Backfills on Postgres](#backfills-on-postgres).)
- Import packages only, no relative paths: the copy in the migration's directory is the one that runs.
- No gates: the migration's approval covers it.
- It counts in the migration's checksum, so editing `backfill.ts` after the migration ran is a checksum mismatch.
The receipts are rows on the environment's server, addressed `yodel///`, so the same effect name in two migrations is two receipts. They are kept by the sql lexicon's receipt store (`sqlReceiptStore` from `@intentius/chant-lexicon-sql/receipts`). On ClickHouse they are in `chant_receipts.receipts`, the table the rebuild keeps its partition receipts in.
The template `yodel new --backfill ` writes in a Postgres project raises an exception (`DO $$ BEGIN RAISE EXCEPTION ... END $$`) in place of the SQL until you write it.
## Postgres: PostgresMigrationOp
Renaming a column in place breaks every reader still using the old name the moment it runs. `yodel new` cannot tell a rename from a dropped column and an added one unless the declaration says so: write `-- previously: ` on the new column's line. Without it, the Postgres example's rename came out as a manual step, with a hint (from a run made while writing the example; the migration was deleted after):
```console
$ npx yodel new rename-email
Wrote migrations/20261010T1724-rename-email/ (0 statements; follows 20261010T1724-refunds)
This migration contains a manual step: orders (shop.orders), SQLPG203, SQLPG204. No statement and no Op makes it:
shop.orders needs expand and contract, which no in-place statement makes, so nothing was sent for it. SQLPG203 Add a NOT NULL column with no default (columns.email - -> email text): ADD COLUMN ... NOT NULL with no default fails on a table that has rows. Add it nullable, backfill it, then set NOT NULL. https://www.postgresql.org/docs/18/sql-altertable.html No migration Op makes this change yet: make it by hand as expand and contract (add the new, write both, backfill, move readers, then drop the old).
hint: orders: customer_email is dropped and email added with the same type in the same place; if it is a rename, write -- previously: customer_email on its line
```
With the line (`email text NOT NULL, -- previously: customer_email`), it is a `PostgresMigrationOp` step:
```console
$ npx yodel new rename-email
Wrote migrations/20261010T1724-rename-email/ (0 statements; follows 20261010T1724-refunds)
This migration contains an Op step: PostgresMigrationOp for orders (shop.orders), SQLPG205.
It is not SQL. migration.json holds the Op's declaration:
export const { op } = PostgresMigrationOp({ name: "migrate-shop-orders-email", env: "", table: "shop.orders", column: "email" });
No retain: the old column is dropped in the same yodel apply, right after the switch, so anything still reading it breaks then.
To keep it while readers move over: yodel new rename-email --replace --retain 7d
```
```sql
-- yodel migration 20261010T1724-rename-email
-- parent: 20261010T1724-refunds
-- This migration contains an Op step, not only SQL:
-- PostgresMigrationOp for orders (shop.orders), SQLPG205: not SQL; migration.json holds its declaration
-- orders (shop.orders): SQLPG205 made by PostgresMigrationOp, not a statement:
-- export const { op } = PostgresMigrationOp({ name: "migrate-shop-orders-email", env: "", table: "shop.orders", column: "email" });
```
The step is chant's expand-and-contract Op, built from the migration's recorded schema: Plan, Expand (add the new column), Dual write (a trigger keeps both columns written), Backfill (in batches, each receipt committed with its batch in `.__chant_receipts`), Carry over (indexes and constraints), Verify (every row's new column equals the expression over the old, and no NULL where NOT NULL is declared; a difference fails the step with nothing switched), Switch, Retain, Contract (drop the old column and the trigger, once its retention date has passed). yodel builds it with `gates: "outer"`, so the Op has neither of its two gates and the migration's approval covers the switch and the contract, and with `onFailure: "keep"`, so a failure drops nothing the step made.
```console
$ npx yodel apply dev
...
Running migrate-dev (/postgres/ops/migrate-dev.op.ts)
lock: held in Postgres advisory lock (1498367052, 748638931) for history schema yodeler
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:8fceea63a48a1227fd5ddc01089e850a7c827140ad376098c33fca9eee489eec, by yodel at 2026-10-10T17:24:43.127Z
20261010T1724-rename-email: applying
step 0: PostgresMigrationOp for orders (shop.orders)
step 0: ok {"op":"PostgresMigrationOp","table":"shop.orders","column":"email","state":"migrate","change":"rename","filled":1,"skipped":0,"backfilledRows":301,"carried":0,"verifiedRows":301,"switched":true,"oldColumn":"shop.orders.customer_email","retainUntil":"2026-10-10T17:24:49.958Z","dropped":true}
20261010T1724-rename-email: applied
...
```
`retainUntil` and `dropped` in the note: by default the step keeps the old column for no time, and the contract drops it in the same apply, right after the switch, as the recorded schema says it is gone. Anything still reading `customer_email` breaks at that moment, which is what `yodel new` warns about.
To keep the old column while readers move over, give the step a retention with `--retain`, when you write the migration or by writing it again before it is applied:
```sh
npx yodel new rename-email --retain 7d # when writing it
npx yodel new rename-email --replace --retain 7d # a migration written without it, not applied yet
```
`--retain` writes `"retain": "7d"` into the step's options in `migration.json` (and into its declaration), so it is in the checksum and the plan digest; never edit `migration.json` by hand to add it. Durations are whole numbers of `ms`, `s`, `m`, `h` or `d`. The lifecycle is then:
1. The apply expands, dual-writes, backfills, verifies and switches. Readers of `email` see the new column; the old column is still there under its old name, kept written by the dual-write trigger for a rename, and its comment says until when (`retain-until`). The step succeeds with `"dropped":false` and `retainUntil` in its note.
2. During those seven days, move the remaining readers and writers of `customer_email` over.
3. After the date, [`yodel cleanup `](#cleaning-up-what-steps-kept) drops the old column, and for a rename its trigger and function, behind an approval. Before the date it drops nothing.
`--retain` sets the retention of a ClickHouse rebuild too: how long `__chant_old` is kept after the swap, 7 days when the step sets none.
A `PostgresMigrationOp` step that failed or was stopped keeps the new column, its trigger and the receipts of the batches it filled, and resumes on the next apply: each phase reads the server again and does what is left, and the backfill skips every batch with a receipt (the note's `skipped`). To start over instead, drop the new column, its trigger and function (named in their comments).
The expand adds the new column at the end of the table, and no `ALTER` moves it, so the live column order differs from the declared one. `yodel drift` compares Postgres columns by name, so the order is not drift: declare the renamed column where the old one was.
## Cleaning up what steps kept
Two steps keep something on purpose after they succeed, until a retention date written in its own comment (`retain-until` in chant's trailer):
- a ClickHouse rebuild keeps the old table, `__chant_old`, 7 days after the swap, or the step's `retain`;
- a Postgres rename or type change keeps the old column after the switch when the step sets `retain`, and for a rename the trigger and function that keep it written.
`yodel plan ` names them with their dates in a note, and so does the pull request comment. `yodel cleanup ` lists them, with the server's clock, and drops the ones whose date has passed. Nothing is dropped before its date.
```text
$ npx yodel cleanup prod
Kept by steps in prod (server time 2026-10-20T00:00:00.000Z):
shop.events__chant_old (the old table of the rebuild of shop.events): kept until 2026-10-17T05:30:05.216Z, due
shop.daily__chant_old (the old table of the rebuild of shop.daily): kept until 2026-10-27T00:00:00.000Z
Plan digest: jcs1-sha256:...
1 due; nothing dropped without an approval. Approve it with:
npx yodel approve prod --plan jcs1-sha256:...
then run yodel cleanup prod again.
```
The drop is approved the way an apply is: the digest covers each due object by its identity on the server (a table by its UUID, a column by its table and attribute number) and its date, and the approval is of that digest on the migrations Op's cleanup gate: the Op's gate name with `-cleanup` (`approve-migrate-prod-cleanup`), a gate of its own, so an apply's approval and a cleanup's never stand in for each other, and `yodel plan` never names a cleanup's approval when it explains an apply's. With it, `yodel cleanup prod` takes the apply lock, reads the objects again, checks the approval against what it reads, and drops them: a ClickHouse table with `DROP TABLE ... SYNC` (rendered for the environment's topology), a Postgres column with its trigger and function in one transaction under the lock timeout, and the column migration's receipts after it. If anything due changed since the approval (another table came due, a table was rebuilt again), the digest differs and nothing is dropped. `--list` lists and never drops; `--json` prints the list, the digest and what was dropped. It exits 3 while something due waits for its approval, so a scheduled job can run it and fail visibly. The environment's approval mode counts as it does for an apply ([Approval](/sql-yodeler/approval/)): under `sealed`, only an approval sealed with `yodel approve --sign` by a key the signers file at the base commit lists for its approver drops anything, and the command `yodel cleanup` prints ends in `--sign`.
## Manual steps
A change neither a statement nor an Op makes is a manual step: `yodel new` writes it and says what it needs, and `yodel apply` refuses the migration with exit 2 until it is gone. Make the change another way (for example, split it into changes yodel can make, as `-- previously:` does for a rename), delete the migration, and write it again.
## The ownership marker
Objects yodel creates carry `[chant managed-by=chant]` at the end of their comment, on Postgres even a table that has no comment of its own (`COMMENT ON TABLE ... IS '[chant managed-by=chant]'`). chant, the library yodel declares and diffs schemas with, reads the marker to tell the objects it manages from ones it does not, and takes it off again before it compares or prints a comment, so your declared comment is what `yodel drift` and `yodel plan` compare. The working objects of a step carry more pairs in the same trailer: `__chant_old` after a rebuild carries `role=old` and `retain-until=`, and so does the old column after a Postgres rename. Leave the marker in place: an object whose marker was removed by hand reads as one yodel did not create, until the next apply of its declaration stamps it again.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `rebuild` | ClickHouse: a sort-key change is a ClickHouseRebuildOp step inside the migration, never an ALTER or a drop; approved, it runs and keeps every row, and without an approval it does not run; yodel cleanup drops the old table it kept only after its retention date, behind an approval on a gate of its own, sealed under a sealed environment | ClickHouse: pass, caught | `c6f58a4`, 2026-10-10 |
| `resume` | an interrupted apply resumes where it stopped: a backfill step written as yodel's form (table, key, batch size, SQL) from its receipts, running each batch once, and a failed statement at that statement, never resending one that ran | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `column-change` | Postgres: a column rename runs as a PostgresMigrationOp step (expand, backfill, switch, contract) and keeps every value; a step that fails part way keeps its work and resumes from its receipts | Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
---
# Your first migration
Source: https://intentius.io/sql-yodeler/getting-started/
## Optional: hand this page to your coding agent
```text
Follow https://intentius.io/sql-yodeler/getting-started/ in a new directory: make the project, start the local emulator, and write and plan the first migration.
Run `npx yodel apply dev` against the emulator, and when it stops at the approval, show me the approve command it printed and stop there.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
This page takes you from an empty directory to two migrations applied to a ClickHouse or Postgres database on your own machine. It needs Node 22.12 or later, npm, git, and Docker for the local database. Every command below ran as shown, and the output under it is what it printed; `` stands for the project's path.
## What chant is
SQL Yodeler is built on [chant](https://intentius.io/chant/) (`@intentius/chant` on npm), a declarative infrastructure-as-code toolkit in TypeScript. You declare your schema with chant's `sql` lexicon, and the `yodel` CLI does the rest, running chant's CLI underneath where it needs it:
- `yodel create` makes a new project from a template.
- `yodel emulator up` starts a local ClickHouse and Postgres in Docker.
- `yodel approve ` approves a plan before `yodel apply` runs it: it shows the plan, asks you to type the environment's name, and records the approval for you.
Before your project exists, run yodel as `npx @intentius/sql-yodeler@latest` (with `@latest`, npx does not reuse an older copy it cached). Inside the project, `npm install` puts the version the project pins in `node_modules`, so `npx yodel` works there.
## 1. Make the project
#### ClickHouse
```sh
npx @intentius/sql-yodeler@latest create my-schema --clickhouse --database events --name events-schema
cd my-schema
npm install
```
`--database events` is the ClickHouse database your schema lives in, and `--name` the package name (the directory's name when left out). The template declares one table in it, in `src/schema.ts`. This declaration is what the database should be; every migration below is worked out from it ([Declaring the schema](/sql-yodeler/schema/) has the form):
```ts
export const db = database`
CREATE DATABASE events
ENGINE = Atomic
COMMENT 'Declared in src/schema.ts'`;
export const events = table`
CREATE TABLE ${db}.events (
id UInt64,
kind LowCardinality(String),
at DateTime
)
ENGINE = MergeTree
ORDER BY (kind, at)
COMMENT 'One row per event'`;
```
#### Postgres
```sh
npx @intentius/sql-yodeler@latest create my-schema --postgres --schema app --name app-schema
cd my-schema
npm install
```
`--schema app` is the Postgres schema your objects live in. The template declares a table and an index in it, in `src/schema.ts`. This declaration is what the database should be; every migration below is worked out from it ([Declaring the schema](/sql-yodeler/schema/) has the form):
```ts
export const app = schema`
CREATE SCHEMA app;
COMMENT ON SCHEMA app IS 'Declared in src/schema.ts'`;
export const events = table`
CREATE TABLE ${app}.events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind text NOT NULL,
at timestamptz NOT NULL DEFAULT now()
)`;
export const eventsKind = index`
CREATE INDEX events_kind_idx ON ${events} (${events.columns.kind})`;
```
`yodel` finds the Op that applies migrations through git, and chant keeps approvals on a git branch, so the project must be a git repository:
```sh
git init -b main
git add -A && git commit -m "chore: a new yodel project"
```
## 2. Start a local database
```sh
npx yodel emulator up
```
This starts ClickHouse on `http://127.0.0.1:8123` (user `default`, no password) and Postgres on `127.0.0.1:5432` (user `postgres`, password `chant`), in Docker, and prints both. `npx yodel emulator status` says whether they run, and `npx yodel emulator down` stops them.
## 3. Point the dev environment at it
`chant.config.ts` names the `dev` environment's server and credentials by environment variable, and never holds a value. `dev` defaults to the emulator's address, so only the users and passwords are needed.
The template gives each environment two users: a reader (the default, used by plans, pull request comments and drift checks) and a writer, picked with `YODEL_CREDENTIALS=writer` (only CI's apply job sets it, and `just apply` on your machine). yodel refuses an environment whose reader and writer are the same user, so give the emulator a read-only user for the reader, and keep its admin user, which can do everything, as the writer:
#### ClickHouse
```sh
curl -s http://127.0.0.1:8123 --data-binary "CREATE USER IF NOT EXISTS reader IDENTIFIED WITH no_password SETTINGS readonly = 1"
curl -s http://127.0.0.1:8123 --data-binary "GRANT SELECT, SHOW ON *.* TO reader"
export DEV_CLICKHOUSE_READER_USER=reader DEV_CLICKHOUSE_READER_PASSWORD=
export DEV_CLICKHOUSE_WRITER_USER=default DEV_CLICKHOUSE_WRITER_PASSWORD=
```
The variables for every environment:
| Variable | Meaning |
|---|---|
| `_CLICKHOUSE_URL` | the server's HTTP interface; `dev` defaults to `http://127.0.0.1:8123` |
| `_CLICKHOUSE_READER_USER`, `_CLICKHOUSE_READER_PASSWORD` | the read-only user |
| `_CLICKHOUSE_WRITER_USER`, `_CLICKHOUSE_WRITER_PASSWORD` | the user that applies, read when `YODEL_CREDENTIALS=writer` |
#### Postgres
The reader is a role of its own on the emulator, made once as `postgres`:
```sql
CREATE ROLE reader LOGIN PASSWORD 'reader';
ALTER ROLE reader SET default_transaction_read_only = on;
GRANT pg_read_all_data TO reader;
```
```sh
export DEV_POSTGRES_URL=postgres://127.0.0.1:5432/postgres
export DEV_POSTGRES_READER_USER=reader DEV_POSTGRES_READER_PASSWORD=reader
export DEV_POSTGRES_WRITER_USER=postgres DEV_POSTGRES_WRITER_PASSWORD=chant
```
The variables for every environment:
| Variable | Meaning |
|---|---|
| `_POSTGRES_URL` | the server and the database, as `postgres://host:5432/database`; `dev` defaults to `postgres://127.0.0.1:5432/postgres` |
| `_POSTGRES_READER_USER`, `_POSTGRES_READER_PASSWORD` | the read-only role |
| `_POSTGRES_WRITER_USER`, `_POSTGRES_WRITER_PASSWORD` | the role that applies, read when `YODEL_CREDENTIALS=writer` |
If a Postgres of your own already listens on `127.0.0.1:5432` (Homebrew's, for example), that address reaches it rather than the emulator. `just up` warns when it does. Stop that server, or reach the emulator through another address of this machine: `DEV_POSTGRES_URL=postgres://:5432/postgres`, with the address from `ipconfig getifaddr en0` on macOS.
`` is `DEV` or `PROD`. Without the users, `npx yodel plan dev` stops with `sql.profiles.dev.user names DEV_CLICKHOUSE_READER_USER, which is not set` (`DEV_POSTGRES_READER_USER` on Postgres). The reader cannot write, so `yodel apply` runs with `YODEL_CREDENTIALS=writer`, as `just apply` does. Run as the reader, `yodel apply dev` refuses before the approval gate (exit 4) and names the variable to set; the commands yodel prints to run again carry it too (`YODEL_CREDENTIALS=writer:dev npx yodel apply dev`).
## 4. Write the first migration
The output on the rest of this page is from a run of the ClickHouse template; on Postgres the commands are the same. It was recorded before yodel printed `yodel approve` in its messages, so where an approval is asked for, the output shows chant's `chant approve` command, and the run approves with it, having no terminal to ask; you run `npx yodel approve dev`, which records the same approval.
`yodel new` writes the next migration from the change in `src/` since the last one. The first one creates everything. `yodel lint` checks the migrations offline:
```console
$ npx yodel new init
Wrote migrations/20261010T2213-init/ (2 statements; the first migration)
$ npx yodel lint
1 migration: 0 errors, 0 warnings, 0 silenced.
```
Commit it:
```sh
git add -A && git commit -m "feat: the first migration"
```
## 5. Plan, approve, apply
`yodel plan` shows what `yodel apply` would run, and a digest of that plan:
```console
$ npx yodel plan dev
Plan for dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history (single (yodel.config.ts))): 0 applied, 1 pending
20261010T2213-init
0 statement SQLCH200 create events
CREATE DATABASE events
ENGINE = Atomic
COMMENT 'Declared in src/schema.ts [chant managed-by=chant]'
1 statement SQLCH200 create events.events
CREATE TABLE events.events (
id UInt64,
kind LowCardinality(String),
at DateTime
)
ENGINE = MergeTree
ORDER BY (kind, at)
COMMENT 'One row per event [chant managed-by=chant]'
The apply pipeline waits in wave 1 for an approval of jcs1-sha256:753d2acf4bf5873de6c3517ecc47c2b8c2dc5e285d9c54fb972ace5b7465be26 (gate always at 27fbeee6832b).
Approve it for the pipeline with:
chant approve yodel-apply yodel-apply-wave-1 --plan jcs1-sha256:753d2acf4bf5873de6c3517ecc47c2b8c2dc5e285d9c54fb972ace5b7465be26
Plan digest: jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3
Approve it with:
chant approve migrate-dev approve-migrate-dev --plan jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3
or, at a terminal, with npx yodel approve dev, which shows the plan and asks you first.
Not approved yet. Approving binds exactly this digest; if anything it covers changes before the apply runs, the gate asks again and nothing is applied.
```
The plan prints two approvals. The first, of the apply pipeline's wave, is for the CI pipeline's wave job after a merge, and you need it only once the project has a remote and CI. The second, of `migrate-dev`, is the one `yodel apply dev` waits for on your machine.
`yodel apply` runs only a plan someone approved. The first run stops at the approval and prints the command that gives it (exit 3):
```console
$ YODEL_CREDENTIALS=writer npx yodel apply dev
Migrations in dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history, topology single (yodel.config.ts))
Applied: none
Pending (1):
20261010T2213-init
Plan digest: jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3
Running migrate-dev (/ops/migrate-dev.op.ts)
[phase] Plan
✓ shellCmd(cmd=yodel apply dev --digest) 2.4s
[phase] Approve
• gate:approve-migrate-dev() skipped
[phase] Apply
• shellCmd(cmd=yodel apply dev --execute, env={"YODEL_APPROVED_PLAN":{"kind":"step-output-ref","step":"plan","path":"stdout"}}) skipped
Op "migrate-dev" is gated on "approve-migrate-dev" after 2.9s
plan : jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3
approve : chant approve migrate-dev approve-migrate-dev --plan jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3
expires : 2026-10-12T22:13:43.677Z
note : recorded locally (no remote for chant/lifecycle)
migrate-dev is waiting at gate "approve-migrate-dev" for approval of this plan (jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3).
Approve it with:
chant approve migrate-dev approve-migrate-dev --plan jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3
or, at a terminal, with npx yodel approve dev, which shows the plan and asks you first;
then run YODEL_CREDENTIALS=writer:dev npx yodel apply dev again.
[exit 3]
```
The note about a remote is expected: approvals live on a `chant/lifecycle` branch, and this repository has no remote to push it to.
To approve, run `npx yodel approve dev`: it shows the plan and asks you to type `dev`. The command `yodel apply` prints adds the digest, `npx yodel approve dev --plan `, so it approves only the plan you were shown. The run below had no terminal to ask, so it recorded the same approval with chant's command. The approval holds for this exact plan only. Then run `yodel apply` again:
```console
$ npx chant approve migrate-dev approve-migrate-dev --plan "$(npx yodel apply dev --digest)"
Gate "approve-migrate-dev" on "migrate-dev" resolved by yodel at 2026-10-10T22:13:46.126Z (recorded locally (no remote for chant/lifecycle))
This approves the plan jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3, and only that plan. A run whose fresh plan differs refuses rather than applying it.
This records the resolution as a fact; it does not itself re-run anything. Run `chant run migrate-dev` and it walks through gate "approve-migrate-dev".
$ YODEL_CREDENTIALS=writer npx yodel apply dev
Migrations in dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history, topology single (yodel.config.ts))
Applied: none
Pending (1):
20261010T2213-init
Plan digest: jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3
Running migrate-dev (/ops/migrate-dev.op.ts)
lock: no KeeperMap on this server (KeeperMap is disabled because 'keeper_map_path_prefix' config is not defined. (BAD_ARGUMENTS) (version 26.8.15.10 (official build))); the lock is a file on this machine, so cross-runner locking needs Keeper (and keeper_map_path_prefix)
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3, by yodel at 2026-10-10T22:13:46.126Z
20261010T2213-init: applying
statement 0: ok (SQLCH200 create, events)
statement 1: ok (SQLCH200 create, events.events)
20261010T2213-init: applied
[phase] Plan
✓ shellCmd(cmd=yodel apply dev --digest) 899ms
[phase] Approve
✓ gate:approve-migrate-dev() 180ms
[approved] yodel at 2026-10-10T22:13:46.126Z
[phase] Apply
✓ shellCmd(cmd=yodel apply dev --execute, env={"YODEL_APPROVED_PLAN":"jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3"}) 2.0s
Op "migrate-dev" completed in 3.2s
Applied: 20261010T2213-init.
$ npx yodel status dev
Migrations in dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history, topology single (yodel.config.ts))
Applied (1):
20261010T2213-init 2026-10-10 22:13:49.991391 by yodel@example
Pending: none
```
## 6. A second migration
Add a column to the table in `src/schema.ts`:
```ts
at DateTime,
country LowCardinality(String) DEFAULT ''
```
Then write the migration, check it and commit it:
```console
$ npx yodel new add-country
Wrote migrations/20261010T2213-add-country/ (1 statement; follows 20261010T2213-init)
$ npx yodel lint
2 migrations: 0 errors, 0 warnings, 0 silenced.
```
```sh
git add -A && git commit -m "feat: events.country"
```
The plan has the one new statement. The approval you gave for the first migration does not hold for this plan, and the plan says why:
```console
$ npx yodel plan dev
Plan for dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history (single (yodel.config.ts))): 1 applied, 1 pending
20261010T2213-add-country
0 statement SQLCH201 metadata events.events
ALTER TABLE `events`.`events` ADD COLUMN country LowCardinality(String) DEFAULT '' AFTER `at`
The apply pipeline waits in wave 1 for an approval of jcs1-sha256:2cef01bec2fd0e3182fcb24fe645ec39f48697c3573f26338e064c459f656203 (gate always at b3e7a48d268e).
Approve it for the pipeline with:
chant approve yodel-apply yodel-apply-wave-1 --plan jcs1-sha256:2cef01bec2fd0e3182fcb24fe645ec39f48697c3573f26338e064c459f656203
Plan digest: jcs1-sha256:3df448990a3d23ff2213501d47ac943e9acc6267e1c0fa134e012ac6ab97af8a
Approve it with:
chant approve migrate-dev approve-migrate-dev --plan jcs1-sha256:3df448990a3d23ff2213501d47ac943e9acc6267e1c0fa134e012ac6ab97af8a
or, at a terminal, with npx yodel approve dev, which shows the plan and asks you first.
The newest approval of approve-migrate-dev, by yodel at 2026-10-10T22:13:46.126Z, is for jcs1-sha256:96585362be63615a9c5a29c059b2e211f409bcb3e3d5a20a1b226773dfea9aa3, not this plan; it does not hold.
What moved (as far as yodel can tell): history changed: applied since: 20261010T2213-init (by yodel@example at 2026-10-10 22:13:49.991391); pending migrations changed: committed to since approval: 20261010T2213-add-country (1ee6e61 at 2026-10-10T16:13:53-06:00); live schema changed: as expected after the history change above: applying a migration changes the live schema, and yodel cannot rebuild the live schema as it was at approval, so it cannot confirm that nothing else in it changed.
```
Approve and apply as before:
```console
$ npx chant approve migrate-dev approve-migrate-dev --plan "$(npx yodel apply dev --digest)"
Gate "approve-migrate-dev" on "migrate-dev" resolved by yodel at 2026-10-10T22:13:57.553Z (recorded locally (no remote for chant/lifecycle))
This approves the plan jcs1-sha256:3df448990a3d23ff2213501d47ac943e9acc6267e1c0fa134e012ac6ab97af8a, and only that plan. A run whose fresh plan differs refuses rather than applying it.
This records the resolution as a fact; it does not itself re-run anything. Run `chant run migrate-dev` and it walks through gate "approve-migrate-dev".
$ YODEL_CREDENTIALS=writer npx yodel apply dev
Migrations in dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history, topology single (yodel.config.ts))
Applied (1):
20261010T2213-init 2026-10-10 22:13:49.991391 by yodel@example
Pending (1):
20261010T2213-add-country
Plan digest: jcs1-sha256:3df448990a3d23ff2213501d47ac943e9acc6267e1c0fa134e012ac6ab97af8a
Running migrate-dev (/ops/migrate-dev.op.ts)
lock: no KeeperMap on this server (KeeperMap is disabled because 'keeper_map_path_prefix' config is not defined. (BAD_ARGUMENTS) (version 26.8.15.10 (official build))); the lock is a file on this machine, so cross-runner locking needs Keeper (and keeper_map_path_prefix)
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:3df448990a3d23ff2213501d47ac943e9acc6267e1c0fa134e012ac6ab97af8a, by yodel at 2026-10-10T22:13:57.553Z
20261010T2213-add-country: applying
statement 0: ok (SQLCH201 metadata, events.events)
20261010T2213-add-country: applied
[phase] Plan
✓ shellCmd(cmd=yodel apply dev --digest) 947ms
[phase] Approve
✓ gate:approve-migrate-dev() 216ms
[approved] yodel at 2026-10-10T22:13:57.553Z
[phase] Apply
✓ shellCmd(cmd=yodel apply dev --execute, env={"YODEL_APPROVED_PLAN":"jcs1-sha256:3df448990a3d23ff2213501d47ac943e9acc6267e1c0fa134e012ac6ab97af8a"}) 2.0s
Op "migrate-dev" completed in 3.3s
Applied: 20261010T2213-add-country.
```
## 7. Drift
`yodel drift` compares what `src/` declares with the server, and reports anything changed by hand. Change a default on the server, as someone might by hand:
```sql
ALTER TABLE events.events MODIFY COLUMN country LowCardinality(String) DEFAULT 'US'
```
`yodel drift dev` reports it, and exits 2:
```console
$ npx yodel drift dev
Drift in dev (clickhouse 26.8.15.10 at 127.0.0.1:8123)
Compared with the schema 20261010T2213-add-country records, the newest migration the history records as applied.
Changed out of band:
events (ClickHouse::Table)
column country default.expr: declared '', live 'US'
[exit 2]
```
To put it right, change it back on the server, or declare the new default in `src/schema.ts` and write a migration for it.
## The same steps with just
Each template has a `justfile` for these steps. [just](https://github.com/casey/just) is optional: every target runs the command beside it, and the commands work without it.
| just | Runs |
|---|---|
| `just up` | `npx yodel emulator up`, then `npx tsx scripts/local.ts`, which checks that `dev` reaches the emulator, makes the read-only user `reader` there (the statements in step 3), and prints the reader and writer lines for a `.env` file |
| `just down` | `npx yodel emulator down` |
| `just new ` | `npx yodel new ` |
| `just lint` | `npx yodel lint` |
| `just replay` | `npm run replay`: every migration replayed into a throwaway server and compared with its recorded schema |
| `just plan [env]` | `npx yodel plan ` (`dev` when left out) |
| `just approve [env]` | prints the command that approves the environment's plan, for you to run; it never runs it |
| `just apply [env]` | `YODEL_CREDENTIALS=writer npx yodel apply ` |
| `just status [env]` | `npx yodel status ` |
| `just drift [env]` | `npx yodel drift ` |
| `just ci` | `npm run ci`, which renders the CI pipelines again |
The justfile reads a `.env` file in the project when there is one, so the variables from step 3 can go there in place of `export`; `.gitignore` keeps it out of git. Only `just` reads it: the plain `npx yodel ...` commands above see only the shell's environment, so to run them with the variables in `.env`, load it first with `set -a; . ./.env; set +a`.
## Next
- Push the project and set up CI: [Setting up each forge](/sql-yodeler/forges/) has the secrets, tokens and runner each forge needs. From then on each change is a pull request: edit `src/schema.ts`, `npx yodel new `, commit both. The pull request gets a plan comment, and a merge applies what was approved.
- [The two workflows](/sql-yodeler/workflows/), [Migrations](/sql-yodeler/migrations/) and [Approval](/sql-yodeler/approval/) explain what you just ran.
- [Drift](/sql-yodeler/drift/) has the scheduled drift check the pipelines run.
- To take a database you already have into migrations, see [Starting a project](/sql-yodeler/adoption/#adopting-an-existing-database-yodel-init-from).
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `new` | yodel new writes the next migration offline, with no dev database, and it applies; on Postgres, functions, procedures and triggers too, which lint checks | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `lint` | yodel lint --replay replays the migrations into a fresh database and each gives the schema it recorded; offline, yodel lint fails a migrations directory with a fork (exit 3), naming both migrations; a checkpoint replays alone to its recorded schema, and a fresh environment starts from it; yodel revert undoes the newest migration behind the gate, with a hand-written step for its data step, back to its parent's recorded schema, and refuses a checkpoint or a migration before one; yodel test runs a project's tests on databases replayed from the migrations, a seed meeting the backfill after it, fails a case whose assertion does not hold, and refuses an environment with a history; on Postgres, a unique index over rows with duplicates is refused before the gate by its generated pre-check (exit 4, naming the statement and the count), and yodel lint flags it as data-dependent | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `approval` | a pending migration applies only after chant approve of its plan digest, and the history records that digest; a failing pre-migration check refuses it, and so does a policy rule, read at the base commit, unless an override is recorded for that digest, for a yodel revert as for an apply; the audit log derived from the history and the ledger accounts for every approval and apply, and names one removed; a reader and a writer that are one user are refused, and the reader, its password a minted token, cannot write; an approval is refused once the migration changed after it, and nothing is applied | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `drift` | yodel drift reports a declared object changed out of band, naming the property, and one dropped; on the versioned path it compares with the newest applied migration's recorded schema, so a pending migration is not drift | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
---
# Starting a project
Source: https://intentius.io/sql-yodeler/adoption/
## Optional: hand this page to your coding agent
```text
Take the existing database of the environment I name into versioned migrations with SQL Yodeler, following https://intentius.io/sql-yodeler/adoption/.
Make the project from the starter template with the existing database or schema name first, and ask me before running `YODEL_CREDENTIALS=writer npx yodel init --from --force`: it writes one row to that environment's history.
Open a pull request with src/, the baseline migration and the config.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
There are two ways in: a new project from a starter template, whose first migration creates everything, or `yodel init --from `, which adopts a database that already exists.
## A new project from a template
`templates/clickhouse` and `templates/postgres` in this repository are starter projects, and `yodel create` makes a project from one. [Your first migration](/sql-yodeler/getting-started/) takes a project made this way to applied migrations on a local database, step by step. Each gives `chant.config.ts` with a `dev` and a `prod` profile, `yodel.config.ts`, a migrations Op and a drift watch per environment, and CI pipelines for GitHub Actions, GitLab CI and Forgejo Actions. `yodel create --help` lists the options.
Before the project exists, run yodel from npm:
```sh
npx @intentius/sql-yodeler@latest create my-schema --clickhouse --database events
npx @intentius/sql-yodeler@latest create my-schema --postgres --schema app
```
The template is the one released with that yodel (tag `v` of this repository), so the project matches the yodel that made it. `--name` sets the package name, the directory's name by default.
The transcript below predates `yodel create`: it made the project from a clone's template, shown as ``, with chant's CLI, which `yodel create` runs underneath. The project is the same.
```console
$ npx chant init --from /templates/clickhouse --param name=events-schema --param database=events my-schema
Created:
.forgejo/workflows/watch-dev.yml
.forgejo/workflows/watch-prod.yml
.forgejo/workflows/yodel-apply.yml
.forgejo/workflows/yodel-pr.yml
.github/workflows/watch-dev.yml
.github/workflows/watch-prod.yml
.github/workflows/yodel-apply.yml
.github/workflows/yodel-pr.yml
.gitignore
.gitlab-ci.yml
.gitlab/yodel-apply.gitlab-ci.yml
.gitlab/yodel-watch.gitlab-ci.yml
chant.config.ts
justfile
migrations/.gitkeep
ops/migrate-dev.op.ts
ops/migrate-prod.op.ts
ops/watch-dev.op.ts
ops/watch-prod.op.ts
package.json
README.md
scripts/local.ts
src/schema.ts
tsconfig.json
yodel-waves.json
yodel.config.ts
.chant/workspace.lock.json
Lineage: dir:/templates/clickhouse (a directory, recorded by digest only), recorded in .chant/workspace.lock.json
Parameters: database="events", history_database="yodeler", name="events-schema"
```
The template's `package.json` takes `@intentius/sql-yodeler` from npm (`^0.5.2`), so `npm install` in `my-schema` needs no token, and neither do the generated pipelines' `npm ci`; [Installing](/sql-yodeler/configuration/#installing) has the other routes. The run quoted here linked the clone's `node_modules` into `my-schema` in place of `npm install`.
The first migration creates what `src/schema.ts` declares. `yodel new` needs no server:
```console
$ npx yodel new init
Wrote migrations/20261010T1725-init/ (2 statements; the first migration)
$ npx yodel lint
1 migration: 0 errors, 0 warnings, 0 silenced.
```
Make the directory a git repository and commit (`yodel apply` finds the migrations Op through git, and chant keeps approvals on the `chant/lifecycle` branch). Then create the database users the template's README lists, add the forge's secrets as [Setting up each forge](/sql-yodeler/forges/) says, and push. From there each change is a pull request: edit `src/schema.ts`, run `npx yodel new `, commit both. [The two workflows](/sql-yodeler/workflows/) and [Approval](/sql-yodeler/approval/) go on from here.
## Adopting an existing database: yodel init --from
`yodel init --from ` takes a database that already exists into versioned migrations in one command:
1. It reads the live database the profile names (chant's import, as `chant import --from ` runs it) and writes the declarations to the source directory (`sourceDir`, else `src/`).
2. It builds them and plans them against the same database. The plan must show no change; if it does not, everything it wrote is taken back.
3. It writes the baseline migration, `migrations/-baseline/`: the first migration, whose statements create everything.
4. It records the baseline applied in the environment's history, without running any of its statements.
On Postgres, when the environment manages access (`environments..access` in `yodel.config.ts`, or `sql.profiles..access`), step 1 also reads each table's row-level security, the policies and the privileges, and declares them; roles stay the environment's and are named as text ([Access control](/sql-yodeler/access/#adopting-a-database)). On ClickHouse it reads the row policies on the tables, the users and roles that hold privileges there, and every grant each of them holds, and writes them to `src/access.ts`; a user's password stays the environment's ([Access control](/sql-yodeler/access/#adopting)). Dictionaries are adopted with the tables whether access is managed or not, and so are the SQL functions an adopted view or column default calls and those `sql.profiles..importFunctions` names ([Workflows](/sql-yodeler/workflows/)).
The live database is only read. The one write is the history row (and the history's database or schema and table, when they are not there yet).
What it needs first:
- A project: a `chant.config.ts` with `lexicons: ["sql"]`, `sql.dialect` and `sql.profiles.`, with the project's databases (ClickHouse, `databases`) or schemas (Postgres, `schemas`) listed and the history's database or schema not among them, and a `package.json` from which chant and the `sql` lexicon resolve. `init` does not write `chant.config.ts`. The usual way to get one is a [starter template](#a-new-project-from-a-template) made with the existing database's name (`yodel create --clickhouse --database `, or `--postgres --schema `); its `src/schema.ts` is a placeholder, so run `init` with `--force` to write over it.
- The writer's credentials. `init` writes the history row, and creates the history's database or schema and table when they are not there. The templates read the reader's credentials, which their READMEs create read-only, unless `YODEL_CREDENTIALS=writer`, so with a template it is `YODEL_CREDENTIALS=writer npx yodel init --from --force`.
- No subdirectory in `migrations/`. Move another tool's migration files out of it first.
It refuses (exit 4, nothing written) when the source directory already declares a schema (unless `--force`), the project already has migrations, the history already records migrations, or the environment holds nothing.
`init` adopts every object in the profile's databases or schemas, except the history tables of other migration tools (golang-migrate's and dbmate's `schema_migrations`, goose's `goose_db_version`, Flyway's `flyway_schema_history`, and others), which it leaves out with a warning. There is no exclude list: a table the project should not own belongs in a database or schema the profile does not list. [Coming from another migration tool](/sql-yodeler/from-other-tools/) covers adopting a database another tool manages: the old tool's files and table, more than one environment, cutting CI over, and rollback.
Both examples adopt a database this way. The ClickHouse example's run, where the database was first made on the declarative path, so `src/schema.ts` was there and `--force` writes over it:
```console
$ npx yodel init --from dev --force
read 2 object(s) from dev
wrote src/schema.ts
planned against dev: no change
wrote migrations/20261010T1722-baseline (2 statements)
lock: no KeeperMap on this server (KeeperMap is disabled because 'keeper_map_path_prefix' config is not defined. (BAD_ARGUMENTS) (version 26.8.15.10 (official build))); the lock is a file on this machine, so cross-runner locking needs Keeper (and keeper_map_path_prefix)
recorded 20261010T1722-baseline applied in yodeler.history as a baseline; none of its statements was run
Adopted dev (clickhouse): 2 objects
Database shop
Table shop.events
Declarations: src/schema.ts
Baseline: migrations/20261010T1722-baseline/ (2 statements, sha256:571b633c0d6c4e461d76285328cb71600b3c33c1a429f90db1a5f9e174228deb)
Recorded: applied in yodeler.history as a baseline; none of its statements was run
yodel plan dev shows no change, and yodel status dev lists the baseline applied.
```
The declarations it wrote:
```ts
import { database, table } from "@intentius/chant-lexicon-sql/clickhouse";
export const shopDb = database`
CREATE DATABASE shop
ENGINE = Atomic
COMMENT 'The SQL Yodeler ClickHouse example'`;
export const events = table`
CREATE TABLE ${shopDb}.events
(
id UInt64,
kind LowCardinality(String),
at DateTime
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(at)
ORDER BY (kind, at)`;
```
and the history:
```console
$ npx yodel status dev
Migrations in dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history, topology single (default))
Applied (1):
20261010T1722-baseline 2026-10-10 17:22:07.370595 by yodel@example
Pending: none
```
The baseline's statements are the ones that would create the database from nothing, so a new environment (a fresh dev database, the replay check's throwaway server) gets everything by applying it. On the adopted environment it is never run.
Objects made by hand carry no chant ownership marker in their comments, and `init` does not add one: they stay unmarked until a declarative apply stamps them. The plan compares declared definitions without the marker, so they plan clean either way.
After `init`, `yodel apply ` needs a migrations Op for the environment (an Op with a Plan step running `yodel apply --digest`, a gate bound to its output, and an Apply step running `yodel apply --execute`); `yodel apply` prints the declaration to add when there is none. See [Approval](/sql-yodeler/approval/).
The Postgres example does the same on Postgres.
## Another environment that already holds the schema: yodel init --baseline
`init --from` adopts one environment. Another environment that holds the same schema with its data (staging, prod) cannot run the baseline, whose first `CREATE` would fail there. `yodel init --baseline ` records the baseline applied in it instead, without running it:
```sh
YODEL_CREDENTIALS=writer npx yodel init --baseline prod
```
1. It plans the schema the baseline (the project's first migration) records against ``'s live database, from `migration.json` alone. The plan must be empty: every object the baseline declares is there as it declares it, and the profile's databases or schemas hold nothing else. Each difference is named, with what the environment has and what the baseline declares, and the command refuses (exit 4), recording nothing:
```text
yodel init: refused: prod's live schema is not the schema 20261010T1745-baseline records, so the baseline cannot be recorded applied there. 1 difference:
customers (app.customers) columns.extra: extra text in the environment, (none) in the baseline (SQLPG204)
Make prod hold the baseline's schema (or empty it, and let yodel apply run the baseline), then run yodel init --baseline prod again. Nothing was recorded.
```
2. When they match, it runs ``'s migrations Op, as `yodel apply` does. The Plan step compares again and prints the record's digest (the baseline, the empty history and the live schema), the gate binds the approval to that digest, and the run stops there (exit 3) with the command that approves it, `yodel approve --plan `. Approve, and run `yodel init --baseline ` again.
3. The Apply step takes the lock, checks that the history still records nothing, compares again, refuses if the digest moved since the approval, and appends the baseline's history row: a succeeded migration row whose `note` starts `baseline:`, with the approved digest. None of the baseline's statements is sent.
From there `yodel status ` lists the baseline applied, and `yodel plan ` and `yodel apply ` take the migrations after it, as in every other environment. It refuses (exit 4) a project with no migrations and an environment whose history records anything.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `adopt` | yodel init --from adopts a live database without touching it, and yodel plan then shows no change; on Postgres its policies, row-level security and grants too, on ClickHouse its dictionaries, functions, roles, users, row policies and grants; yodel init --baseline records the baseline, behind the gate, in a second environment that holds the same schema, and refuses one that differs | ClickHouse: pass, caught; Postgres: pass, caught | `9329873`, 2026-10-10 |
| `new` | yodel new writes the next migration offline, with no dev database, and it applies; on Postgres, functions, procedures and triggers too, which lint checks | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
---
# Coming from another migration tool
Source: https://intentius.io/sql-yodeler/from-other-tools/
## Optional: hand this page to your coding agent
```text
Move this repository's database migrations from the tool it uses now to SQL Yodeler, following https://intentius.io/sql-yodeler/from-other-tools/.
Make the project from the starter template, move the old migration files out of migrations/, and ask me before running `YODEL_CREDENTIALS=writer npx yodel init --from --force` against the environment I name: it writes one row to that environment's history.
Leave the old tool's history table in the database.
Open a pull request with src/, the baseline migration, the config and the pipelines, with the old tool's migration jobs removed and listed in its description.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
This page is for a database that golang-migrate, goose, Flyway or dbmate manages today. It maps their concepts onto SQL Yodeler's, takes the database into versioned migrations with its current schema as the starting point, and says how rollback works.
## The concepts, side by side
The main difference in day-to-day work: you do not write the migration's SQL. You change the declared schema in `src/` (chant's `sql` lexicon), and `yodel new ` writes the migration from the difference between that and the schema the previous migration recorded. You can still edit `migration.sql` by hand before it is applied anywhere; `yodel lint --update-checksum ` then records the new checksum.
| In golang-migrate, goose, Flyway, dbmate | In SQL Yodeler |
|---|---|
| An up file: `1_add_note.up.sql`, `V2__add_note.sql`, a `-- +goose Up` or `-- migrate:up` section | A directory, `migrations/-/`, with `migration.sql` (the statements) and `migration.json` (its parent, its checksum, its steps and the schema as it stands after it). `yodel new ` writes it. See [Migrations](/sql-yodeler/migrations/#the-directory). |
| A down file, `-- +goose Down`, `-- migrate:down`, `flyway undo` | No down files. `yodel revert ` plans the reverse from the recorded schemas when you need it ([below](#rolling-back)). |
| The order: the version number or timestamp in the file name | Each migration names its parent; the chain of parents is the order. The timestamp in the id is for people and never decides the order. |
| `schema_migrations` (version, dirty), `goose_db_version`, `flyway_schema_history` | `.history`, in a database (ClickHouse) or schema (Postgres) of its own, `yodeler` by default. It is append-only: one row per event (a migration or statement started, succeeded, failed), with who ran it and under which approval. See [The history table](/sql-yodeler/migrations/#the-history-table). |
| `migrate version`, `goose status`, `flyway info`, `dbmate status` | `yodel status `: applied, pending, failed part way, out of order, checksum mismatches. |
| golang-migrate's dirty flag and `migrate force`; a failed row in `flyway_schema_history` | A migration that failed part way. The history records each statement, so the next `yodel apply` resumes at the statement that failed and never sends again one that succeeded. There is nothing to force. See [Resuming](/sql-yodeler/migrations/#resuming-a-migration-that-failed-part-way). |
| `flyway validate` | `yodel status` and `yodel apply` check every applied migration's files against the checksum recorded when it ran, and refuse on a mismatch; `yodel lint` checks each migration's own `checksum` field. |
| `flyway repair` | `yodel repair --reason ""` records a new checksum for an applied migration whose files were edited on purpose, with the reason. Nothing runs on the server. It does not clear failures: a failed migration resumes instead. |
| `baseline`, `baselineOnMigrate` | `yodel init --from `: reads the live database into declarations, writes a baseline migration whose statements create all of it, and records it applied without running it. See [Adopting it](#adopting-the-database). |
| `outOfOrder=true`, goose's `--allow-missing` | `yodel apply --allow-out-of-order`, for one run. Without it, a pending migration the chain puts before an applied one is refused (exit 4), never skipped. See [Out-of-order migrations](/sql-yodeler/migrations/#out-of-order-migrations). |
| Flyway's repeatable migrations (`R__`) for views and functions | Views, functions, procedures and triggers are declared in `src/` like tables. A changed body becomes a `CREATE OR REPLACE` in the next migration ([Postgres objects](/sql-yodeler/workflows/#postgres-objects)). |
| goose's Go migrations, Flyway's Java migrations: code that moves data | Steps inside a migration: a backfill (`yodel new --backfill `), a ClickHouse rebuild, a Postgres expand-and-contract change. They resume from their receipts. See [Data migrations](/sql-yodeler/steps/). |
| Flyway callbacks (`beforeMigrate`, `afterMigrate`) | `environments..steps` in `yodel.config.ts`: read-only checks and commands before and after each apply ([Steps around apply](/sql-yodeler/approval/#steps-around-apply-checks-before-and-after)). |
| dbmate's `schema.sql` dump | Each `migration.json` records the schema after it, and `src/` declares the current one. `yodel docs` writes an HTML reference and an ERD ([Generated schema reference](/sql-yodeler/schema-docs/)). |
| Squashing old migrations into one | `yodel checkpoint `: new environments start from it instead of replaying the whole chain ([Checkpoints](/sql-yodeler/migrations/#checkpoints-yodel-checkpoint)). |
| `migrate up` / `flyway migrate` in a deploy job | `yodel apply ` in the pipelines `yodel ci` renders: the plan is posted on the pull request, and the apply after a merge waits for an approval bound to a digest of that plan ([Approval](/sql-yodeler/approval/)). |
| Tests that run the migrations on a fresh database | `yodel test`: cases in `tests/*.test.ts`, run on a database built from the migrations ([Lint](/sql-yodeler/lint/#tests-on-the-replayed-database-yodel-test)). |
## Adopting the database
Adoption keeps the database as it is. The migrations the old tool ran are not imported one by one: the schema they produced becomes the baseline, the first migration of the new chain, recorded as applied without running.
### Before you start
1. Stop the old tool's migration jobs for the environment, and apply whatever the old tool still has pending, so the database holds the schema you mean to keep. A migration the old tool runs after the baseline is recorded changes the database behind yodel's back: `yodel drift` reports a declared object it changed or dropped, and a table it created stays undeclared.
2. Decide which environment to adopt with `yodel init --from`, usually production. The others that hold the same schema take the baseline afterwards with `yodel init --baseline` (see [More than one environment](#more-than-one-environment)).
3. Have the writer's credentials for it. `init` reads the database and then writes one row to the history (creating the history's database or schema and table first, when they are not there), so the user needs to create those: on ClickHouse, `CREATE DATABASE` (or an existing history database it can create tables in); on Postgres, `CREATE` on the database. A read-only user fails at that write. The plan check before it only reads.
### The project
`yodel init` needs a project around it: `chant.config.ts` with a profile for the environment, `yodel.config.ts`, a migrations Op, and a `package.json` from which chant and the `sql` lexicon resolve. It does not write these. The quickest way to get them is a starter template, made with `yodel create` and the database (ClickHouse) or schema (Postgres) you already have:
```sh
npx @intentius/sql-yodeler@latest create events-schema --clickhouse --database events
npx @intentius/sql-yodeler@latest create app-schema --postgres --schema app
cd app-schema && npm install
```
The history goes in its own database or schema (`--history ` on `yodel create`, `yodeler` by default); it must not be one of the project's. When the project owns more than one database or schema, list each in `databases` (ClickHouse) or `schemas` (Postgres) of every profile in `chant.config.ts`. [Starting a project](/sql-yodeler/adoption/#a-new-project-from-a-template) has the rest of what the template writes.
### Moving the old tool's files
yodel's migrations live in `migrations/` at the project root, and the name is fixed. Move the old tool's files out of it before you run `init` (for example to `legacy-migrations/`, or delete them and let git keep them). `init` refuses while `migrations/` holds any subdirectory, and yodel never reads the old files: flat `.sql` files left there are ignored, which only confuses whoever reads the directory later.
### Running init
Point the environment at the database (in the templates, `_CLICKHOUSE_URL` or `_POSTGRES_URL`, and the writer's user and password variables), then:
```sh
YODEL_CREDENTIALS=writer npx yodel init --from prod --force
```
- `YODEL_CREDENTIALS=writer` makes the template's `chant.config.ts` read the writer's variables. Without it the template reads the reader's, which its README creates read-only.
- `--force` is needed because the template's `src/schema.ts` is a placeholder schema, and `init` refuses to write over declarations without it: `refused: src already declares a schema (schema.ts) ... Pass --force to overwrite`. The import writes `src/schema.ts`; any other file in `src/` is kept and built with it, so remove files that declare objects the database does not have.
What `init` does, step by step, and what it refuses, is in [Adopting an existing database](/sql-yodeler/adoption/#adopting-an-existing-database-yodel-init-from). Afterwards `npx yodel plan prod` shows no change and `npx yodel status prod` lists the baseline applied. Commit `src/`, the baseline and the project.
### The old tool's history table
`init` leaves the old tool's history table out of the declared schema, with a warning such as `app.schema_migrations is kept by a migration runner (Rails, golang-migrate, dbmate); left out, since declaring it would have chant change what that tool owns`. It does this on both dialects for the tables chant knows by name: `schema_migrations`, `goose_db_version`, `flyway_schema_history`, and those of Prisma, Rails, Django, Alembic, drizzle-kit, Knex, Sequelize and node-pg-migrate. (Flyway keeps its table under a configurable name; one under another name is adopted like any other table.)
From then on yodel never reads it or writes to it: migrations are written from the declared schema, and drift reports only declared objects. Keep it while you might go back to the old tool, and drop it by hand once nothing runs the old tool. Its rows changing does not move a plan's digest; creating or dropping the table in one of the project's databases or schemas does, so drop it between applies, not while an approved plan is waiting.
### Tables the project should not own
There is no exclude list. The scope is the databases (ClickHouse) or schemas (Postgres) the profile lists: `init` adopts every object in them except the history tables above, and nothing outside them. To keep a table out of the project (a table another service owns, a scratch table), keep it in a database or schema the profile does not list. Deleting a table's declaration after `init` does not undeclare it: the baseline recorded it, so the next `yodel new` writes a `DROP TABLE`, which `yodel lint` flags as destructive.
An undeclared object in a listed database or schema (one created after `init`) is left alone: migrations are written from the declarations, and drift does not report it. Creating or dropping one does move the plan digest, since the digest covers the catalog of those databases or schemas.
### More than one environment
Once `migrations/` holds the baseline, `init --from` refuses in that project, so it records the baseline in one environment. An environment that does not hold the schema yet, or one you can empty first (a dev or CI database), starts with an empty history, and `yodel apply` runs the baseline there, which creates everything. An environment that already holds the schema and its data takes it with `yodel init --baseline `: it compares that environment's live schema with the schema the baseline records, refuses and names each difference when they differ, and otherwise records the baseline applied there, without running it, behind the environment's gate ([Another environment that already holds the schema](/sql-yodeler/adoption/#another-environment-that-already-holds-the-schema-yodel-init-baseline)). Stop the old tool in that environment first too, and bring it to the same schema as the adopted one.
### Cutting CI over
The template renders pipelines for GitHub Actions, GitLab CI and Forgejo Actions (`npm run ci`, which is `yodel ci`): lint and a plan comment on each pull request with the reader's credentials, and on a merge, an apply per environment with the writer's, behind its approval. Create the users the template's README lists and the secrets [Setting up each forge](/sql-yodeler/forges/) places, remove the old tool's migration jobs in the same pull request that adds these, and from then on make each change by editing `src/` and running `yodel new `. [The two workflows](/sql-yodeler/workflows/#the-pipelines-yodel-ci) has the details.
## Rolling back
There are no down migrations to write or keep in step with the up ones. Each migration records the schema after it, and its parent records the schema before it, so yodel can plan the reverse when you ask for it.
### Reverting the newest migration
```sh
npx yodel revert prod --dry-run # the reverse statements, each with its rule and class, and the digest
YODEL_CREDENTIALS=writer npx yodel revert prod
```
`yodel revert` undoes the newest migration applied in the environment, and only that one, since any later migration was written against it. It goes through the migrations Op like an apply: it stops at the gate with the approve command (exit 3), and once approved, the next `yodel revert` runs it under the lock. The policy in `yodel.config.ts` judges its statements, so a rule that refuses drops refuses a revert that drops what the migration added unless an override is recorded. The history gets a row for each reverse statement and a `reverted` row; the migration is then pending again, and `yodel status` marks it reverted. Delete it, or rewrite it with `yodel new --replace`, before the next `yodel apply`, which would otherwise apply it again.
A revert carries the schema back, not the data. A dropped column comes back empty, and a revert that drops a column the migration added loses what was written to it since. Reverting the baseline plans the drop of everything it created.
### When a hand-written step is needed
- The migration has a data step (a backfill). The revert is refused (exit 2) until `--step ` names SQL that undoes the data step; it runs before the reverse statements and is part of the digest.
- The reverse would need an Op or a manual step (a ClickHouse sort key changed back, a Postgres column renamed back). The revert is refused (exit 2). Write the change back as a new migration: edit `src/`, then `yodel new `.
- The migration is not the newest applied one, or other environments have moved on from it. Roll forward the same way: a new migration that undoes it.
### A migration that failed part way
It is not rolled back. Statements that succeeded stay, and the next `yodel apply` resumes at the one that failed. Fix the cause on the server (the row a constraint rejects, a lock someone holds), or edit that statement in `migration.sql` and record the files' new checksum with `yodel lint --update-checksum `; then approve and apply again. `yodel repair` is for a migration that applied completely and whose files were edited afterwards; it refuses one that failed part way. On Postgres, each statement that can run in a transaction runs in its own, so a failed statement leaves nothing half done.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `adopt` | yodel init --from adopts a live database without touching it, and yodel plan then shows no change; on Postgres its policies, row-level security and grants too, on ClickHouse its dictionaries, functions, roles, users, row policies and grants; yodel init --baseline records the baseline, behind the gate, in a second environment that holds the same schema, and refuses one that differs | ClickHouse: pass, caught; Postgres: pass, caught | `9329873`, 2026-10-10 |
| `resume` | an interrupted apply resumes where it stopped: a backfill step written as yodel's form (table, key, batch size, SQL) from its receipts, running each batch once, and a failed statement at that statement, never resending one that ran | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `out-of-order` | a migration merged late, before one already applied, is refused unless --allow-out-of-order, and never skipped | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
---
# Lint and plan on pull requests only
Source: https://intentius.io/sql-yodeler/lint-and-plan/
## Optional: hand this page to your coding agent
```text
Set up SQL Yodeler's pull request checks without its apply pipeline, following https://intentius.io/sql-yodeler/lint-and-plan/: set `ci: { apply: false }` in yodel.config.ts, run `npm run ci`, delete the apply files it no longer writes, and check that `npx yodel ci --check` passes.
List the readers' secrets the forge needs in the pull request description, and add no writer's secret.
Open a pull request with the result.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
This page is for a team that wants SQL Yodeler to review migrations in pull requests before it lets SQL Yodeler apply anything. Every pull request gets `yodel lint`, the replay check and a plan comment per environment, and the migrations are applied some other way for now: from a terminal with `yodel apply`, or with the process the team already has. CI holds read-only credentials only, and no job in it can write to a database.
## Setting it up
1. Make the project. [Starting a project](/sql-yodeler/adoption/) covers both ways in: a new project from a starter template, or `yodel init --from ` for a database that already exists.
2. Turn the apply pipeline off in `yodel.config.ts`:
```ts
// yodel.config.ts
export default defineConfig({
// ...
ci: { forges: ["github", "gitlab", "forgejo"], apply: false },
});
```
`apply: false` cannot be combined with a wave whose `approval` is `pr-review` or with `ci.resume`, since both run in the apply pipeline. `yodel ci` refuses either combination and names the setting to remove.
3. Render the pipelines with `npm run ci` (which runs `yodel ci`). With `apply: false` it writes `yodel-pr` and a `watch-` per environment on each forge, and no `yodel-waves.json`, no `yodel-apply` or `yodel-apply-plans` workflow, and no `.gitlab/yodel-apply.gitlab-ci.yml`; `.gitlab-ci.yml` has no apply stages and does not include that file. A project made from a template already has those files: `yodel ci` does not delete them, so delete them yourself and commit the result. `yodel ci --check` then passes, because it no longer expects them.
4. Add only the readers' secrets on the forge: for each environment, its URL and its reader (`DEV_CLICKHOUSE_URL`, `DEV_CLICKHOUSE_READER_USER`, `DEV_CLICKHOUSE_READER_PASSWORD` for `dev` on ClickHouse, `DEV_POSTGRES_...` on Postgres), and on GitLab `GITLAB_TOKEN`, a project access token with the `api` scope (Reporter) for the plan comment and the drift watch's issue. Where each one goes on each forge is in [Setting up each forge](/sql-yodeler/forges/). No writer's secret is needed.
5. Open a pull request with a migration and read the plan comment: the pending migrations for each environment, what they would run, and the digest an approval would be bound to. [The pull request comment](/sql-yodeler/approval/#the-pull-request-comment) shows one.
## What runs
On each pull request, every job with readers or with no database credentials at all:
- `lint`: `yodel ci --check`, `yodel lint`, and the replay check, which replays every migration into a throwaway server the job starts. It holds no database credentials.
- `plan-`: `yodel config check --write-probe`, which tries a write and fails the job if the server allows it, then `yodel plan --comment`. It holds the environment's reader, and the reader of the environment before it in the waves (`waves[].requires`), so the plan can show where each migration has run.
On its schedule, `watch-` compares the server with the declared schema and keeps a tracking issue open while it finds drift ([Drift](/sql-yodeler/drift/)). It holds the environment's reader.
Applying is left to you. `YODEL_CREDENTIALS=writer yodel apply ` from a machine that holds the writer applies behind the same approval as the pipeline would ([Approving](/sql-yodeler/approval/#approving)), and records each migration in the history the plan comment and the watch read.
## Turning apply on later
1. Remove `apply: false` from `ci` in `yodel.config.ts`.
2. Run `npm run ci` and commit what it writes: `yodel-waves.json` and the apply pipeline on each forge.
3. Add the writers' secrets where [Setting up each forge](/sql-yodeler/forges/) says, so that only each environment's wave job can read them.
The next push to main runs the apply waves, each waiting for its approval. [Approval](/sql-yodeler/approval/) covers the gates and the waves.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `template` | a project from the starter template, on Forgejo: apply only after approval, lint with replay and the plan comment on a pull request, and the approved change applied on merge; a sealed wave applies only on an approval sealed by a signer listed at the base, and a pr-review wave on the review of a writer other than the author; a pull request job cannot write, a forked migration fails lint and is annotated, a stale or hand-edited pipeline fails yodel ci --check, the CI image pinned by digest runs a pull request's jobs, a command token source mints the reader's password, and the drift watch keeps one tracking issue | ClickHouse: pass, caught; Postgres: pass, caught | `868ff97`, 2026-10-10 |
| `template-github` | a project from the starter template, on GitHub Actions (act and a mock GitHub): apply only after approval, lint with replay and the plan comment on a pull request, and the approved change applied on merge; a sealed wave applies only on an approval sealed by a signer listed at the base, and a pr-review wave on the review of a writer other than the author; a pull request job cannot write, a forked migration fails lint and is annotated, a stale or hand-edited pipeline fails yodel ci --check, the CI image pinned by digest runs a pull request's jobs, a command token source mints the reader's password, and the drift watch keeps one tracking issue | ClickHouse: pass, caught; Postgres: pass, caught | `868ff97`, 2026-10-10 |
| `template-gitlab` | | not recorded | |
---
# Setting up each forge
Source: https://intentius.io/sql-yodeler/forges/
## Optional: hand this page to your coding agent
```text
Prepare this repository's pipelines for the forges I name, following https://intentius.io/sql-yodeler/forges/: set `ci.forges` in yodel.config.ts, run `npm run ci`, and check that `npx yodel ci --check` passes.
In the pull request description, list by name every secret, variable, environment, protection, token and runner the matrix says each forge needs. Put no secret's value in a file or in the description; I add them.
Open a pull request with the result.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
`yodel ci` (`npm run ci` in a project made from a starter template) renders the same jobs for GitHub Actions, GitLab CI and Forgejo Actions: lint and a plan comment per environment on each pull request, an apply wave per environment on a push to main, and a drift watch per environment on its schedule. Which job holds which credentials is the same on every forge ([Least privilege on each forge](/sql-yodeler/configuration/#least-privilege-on-each-forge)). What differs is where you put the secrets, which tokens the jobs use, and what the runner needs:
| | GitHub | GitLab | Forgejo |
|---|---|---|---|
| Pipeline files | `.github/workflows/yodel-pr.yml`, `yodel-apply.yml`, `watch-.yml` | `.gitlab-ci.yml`, `.gitlab/yodel-apply.gitlab-ci.yml`, `.gitlab/yodel-watch.gitlab-ci.yml` | `.forgejo/workflows/yodel-pr.yml`, `yodel-apply.yml`, `watch-.yml` |
| Readers' secrets | repository secrets | CI/CD variables, masked, not protected (merge request pipelines run on unprotected branches), with no environment scope | repository secrets |
| Writer's secrets | environment secrets of the environment named ``, whose deployment branches are limited to `main`; only the wave job names that environment, so a pull request job cannot read them | CI/CD variables, protected, masked and scoped to the environment ``; only a job on a protected branch that declares that environment gets them, and only the wave job declares it | repository secrets: Forgejo has no environments, so a workflow on any branch can name them. Limit who can push branches to those trusted to apply |
| The plan comment | the job's `GITHUB_TOKEN`, with `pull-requests: write` (the workflow grants it) | `GITLAB_TOKEN`: a project access token with the `api` scope (Reporter); `CI_JOB_TOKEN` cannot write merge request notes | the job's own token (`FORGEJO_TOKEN`, else `GITHUB_TOKEN`) |
| The drift watch | `watch-.yml` on the watch's cron; the job's token with `issues: write` for the tracking issue | no cron in the file: one pipeline schedule per watch, with the cron and `CHANT_SCHEDULED_OP` noted at the top of `.gitlab/yodel-watch.gitlab-ci.yml`; `GITLAB_TOKEN` for the tracking issue | `watch-.yml` on the watch's cron; the job's own token writes the tracking issue |
| Resuming after `yodel approve` | re-runs the run's failed jobs at the same commit; a fine-grained token with Actions: read and write | retries the job, and the waves after it run when it passes; a project access token with the `api` scope and the Developer role (`CI_JOB_TOKEN` cannot retry a job) | dispatches `yodel-apply.yml` again on the branch (no re-run API), so the waves before it plan again and apply nothing new; a token with `write:repository` |
| Cloud roles over OIDC | the workflow grants `id-token: write` to a job that mints a cloud password | the job declares an ID token for the cloud's audience (`AWS_ID_TOKEN`, `GCP_ID_TOKEN` or `AZURE_ID_TOKEN`) | none: Forgejo issues no OIDC token to a job, so the runner's own cloud identity mints |
| Runner requirements | `ubuntu-latest`; the lint job runs in a `node:22-bookworm` container with its replay server as a service, so a self-hosted runner needs Docker | a runner that runs `image:` and `services:` (the Docker executor, say); the jobs run in `node:22-bookworm` | an Actions runner with the `docker` label |
| Pull requests from forks | they get no secrets and a read-only token: lint runs, but the plan jobs hold no reader and post no comment | a merge request from a fork runs its pipeline in the fork's project by default, with none of this project's variables | they get no secrets |
`yodel approve` takes its token from `CHANT_FORGE_TOKEN`, else `GITHUB_TOKEN` or `GH_TOKEN` (GitHub and Forgejo), or `GITLAB_TOKEN` (GitLab), on your machine ([Approve and resume](/sql-yodeler/approval/#approve-and-resume)). With `ci: { apply: false }` ([Lint and plan on pull requests only](/sql-yodeler/lint-and-plan/)) there is no apply pipeline and no writer's secret on any forge, and the rows about the writer and resuming do not apply. A role whose password a cloud token source mints has no password variable; [Short-lived tokens](/sql-yodeler/configuration/#short-lived-tokens) has the sources, and `ci.login` for exchanging the forge's OIDC token.
## The steps on each forge
The README a starter template writes into the project has the same steps, for reading offline.
#### GitHub
- Repository secrets: the readers' variables and the URLs (`DEV_CLICKHOUSE_URL`, `DEV_CLICKHOUSE_READER_USER`, `DEV_CLICKHOUSE_READER_PASSWORD` for `dev` on ClickHouse; `DEV_POSTGRES_...` on Postgres).
- Environments `dev` and `prod` (Settings > Environments > ``), each with deployment branches limited to `main`, holding that environment's URL and writer variables. Only the wave job names the environment, so pull request jobs cannot read its secrets. Pull requests from forks get no secrets at all.
- The workflows set their own token permissions: `contents: read` and `pull-requests: write` on `yodel-pr.yml` for the comment, `issues: write` on each `watch-.yml` for the tracking issue, and `contents: write` on `yodel-apply.yml`, which pushes the pending approval to `chant/lifecycle`.
- The lint job runs in a `node:22-bookworm` container and reaches its replay server by the service's name, so it needs no free port on the runner; a self-hosted runner needs Docker.
#### GitLab
- CI/CD variables (Settings > CI/CD > Variables): the readers masked and not protected, with no environment scope, since merge request pipelines run on unprotected branches; the writers protected, masked and scoped to the environment `dev` or `prod`, which only the wave job for that environment declares. `.gitlab-ci.yml` lists the variables in its header.
- `GITLAB_TOKEN`: a project access token with the `api` scope, for the plan comment and the watch's tracking issue (`CI_JOB_TOKEN` cannot write merge request notes or issues). For `yodel approve` to retry a job, the token needs the Developer role.
- GitLab has no cron in the file: create one pipeline schedule per watch (Settings > CI/CD > Schedules) with the cron and the `CHANT_SCHEDULED_OP` value noted at the top of `.gitlab/yodel-watch.gitlab-ci.yml`.
#### Forgejo
- Repository secrets for all of the variables. Forgejo has no environments, so a workflow on any branch of the repository can name the writer's: restrict who can push branches to those trusted to apply, since a branch can change a workflow, or take changes only as pull requests from forks, which get no secrets. A token source whose cloud role trusts only the runner that runs `main` closes the rest.
- The comment and the tracking issue are posted with the job's own token.
- The jobs need an Actions runner with the `docker` label.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `template` | a project from the starter template, on Forgejo: apply only after approval, lint with replay and the plan comment on a pull request, and the approved change applied on merge; a sealed wave applies only on an approval sealed by a signer listed at the base, and a pr-review wave on the review of a writer other than the author; a pull request job cannot write, a forked migration fails lint and is annotated, a stale or hand-edited pipeline fails yodel ci --check, the CI image pinned by digest runs a pull request's jobs, a command token source mints the reader's password, and the drift watch keeps one tracking issue | ClickHouse: pass, caught; Postgres: pass, caught | `868ff97`, 2026-10-10 |
| `template-github` | a project from the starter template, on GitHub Actions (act and a mock GitHub): apply only after approval, lint with replay and the plan comment on a pull request, and the approved change applied on merge; a sealed wave applies only on an approval sealed by a signer listed at the base, and a pr-review wave on the review of a writer other than the author; a pull request job cannot write, a forked migration fails lint and is annotated, a stale or hand-edited pipeline fails yodel ci --check, the CI image pinned by digest runs a pull request's jobs, a command token source mints the reader's password, and the drift watch keeps one tracking issue | ClickHouse: pass, caught; Postgres: pass, caught | `868ff97`, 2026-10-10 |
| `template-gitlab` | | not recorded | |
---
# Schema from an ORM
Source: https://intentius.io/sql-yodeler/orm/
## Optional: hand this page to your coding agent
```text
Bring the tables our ORM defines into SQL Yodeler, following https://intentius.io/sql-yodeler/orm/: add a source under `sources` in yodel.config.ts with the command that prints the ORM's DDL, and remove any declaration in src/ of a table the ORM now owns.
Run `npx yodel new ` and `npx yodel lint`, and open a pull request with the config, src/ and the new migration.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
When an ORM defines some of the tables, yodel can read them from the DDL the ORM prints instead of having them rewritten as declarations. The ORM's tables then go through the same plan, approval and apply as the ones in `src/`.
## Plain .sql files in src/
The simplest way to keep schema as DDL is a `.sql` file in `src/`. chant's build reads every `.sql` file in the source directory beside the TypeScript declarations, so yodel needs no configuration for it:
```sql
-- src/billing.sql
CREATE TABLE app.invoices (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES app.customers (id),
amount numeric(12, 2) NOT NULL
);
CREATE INDEX invoices_customer ON app.invoices (customer_id);
```
A file reads the same statements a DDL source does (below). A name in it that another object declares, in a `.sql` file or in TypeScript, is a dependency, so the plan creates `app.customers` first. An object declared in a file and in a template is a build error naming both. A file whose first line is `-- chant-discovery-skip` is not read, for seed data or queries kept beside the schema.
Use a file when you write or paste the DDL yourself. Use a DDL source when a command prints it, such as an ORM's, so the declarations follow the model each time yodel builds.
## A DDL source
Name each source in `yodel.config.ts` under `sources`, with the command that prints its DDL or a file that holds it:
```ts
import { defineConfig } from "@intentius/sql-yodeler";
export default defineConfig({
sources: {
prisma: {
command: "npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script",
schema: "app",
},
legacy: { file: "db/legacy.sql" },
},
});
```
| Setting | What it is |
|---|---|
| `command` | a shell command, run in the project directory, that prints the DDL on stdout |
| `file` | a file of DDL, relative to the project directory |
| `schema` | the Postgres schema (ClickHouse database) for the names the DDL leaves unqualified, as an ORM does when its connection picks the schema |
Give one of `command` and `file`. A source's name is lowercase letters, digits and `-`.
Commands that print an ORM's DDL:
| ORM | Command |
|---|---|
| Prisma | `npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script` |
| Drizzle | `npx drizzle-kit export` (or `drizzle-kit generate`, then a `file` source on the SQL it wrote) |
| Django | `python manage.py sqlmigrate `, one source per app |
| SQLAlchemy | a script that prints `CreateTable(table).compile(engine)` for each table of the metadata |
## What yodel does with it
Before each build (`yodel plan`, `yodel new`, `yodel apply`, `yodel drift`), yodel runs each source's command or reads its file, parses the DDL with the sql lexicon, and writes it out as declarations in `src/.generated.ts`: the same file `chant import` writes, with a header saying where it came from. The build reads that file like any other declaration, so a model change shows up in `yodel plan` as the change it makes to the table, and the ApplyOp's own build finds it too.
Commit the generated file. A build that yodel does not start, such as `chant build` or an Op run outside yodel, reads the committed copy.
Keep a `file` source's DDL out of the schema build. chant's build reads every `.sql` file in the directory it builds, and an ApplyOp's diff builds the project root unless `chant.config.ts` sets `sourceDir`. A DDL file there would declare each object a second time, beside the generated file. Set `sourceDir: "src"` in `chant.config.ts`, or start the file with a `-- chant-discovery-skip` line.
yodel reads the DDL with the sql lexicon's `sqlFileDeclarations`, the reader chant's build uses for a `.sql` file in `src/`, so a source and a file read the same statements. On Postgres that is each `CREATE`, `GRANT`, `REVOKE` and `ALTER DEFAULT PRIVILEGES`, with `COMMENT ON` and `ALTER TABLE ... ROW LEVEL SECURITY` folded into the object they finish. ORMs print foreign keys as `ALTER TABLE ADD CONSTRAINT ... FOREIGN KEY ...`; each `ALTER TABLE ... ADD` of a table constraint is folded into ``'s `CREATE TABLE`, where a declaration holds it. `BEGIN` and `COMMIT` are skipped. On ClickHouse, it reads `CREATE DATABASE`, `TABLE`, `VIEW`, `MATERIALIZED VIEW`, `DICTIONARY` and `FUNCTION`.
Any other statement is an error that names it, and nothing is built. Leaving it out would leave part of the ORM's schema unmanaged without saying so.
With `schema` set, the unqualified names are qualified with it: on Postgres, each object's own name, an index's table, a foreign key's table, and a column whose type is an enum or domain the same DDL creates; on ClickHouse, each object's own name except a function's, which belongs to no database.
## One owner per object
An object is declared in one place. If a source creates a table that `src/` (or another source) also declares, the build stops and names both:
```text
$ yodel plan dev
yodel plan: each object has one owner, and app.customers is declared twice: by src/ (export customers) and by the source "prisma" (src/prisma.generated.ts). Leave it to one of them.
```
The same holds when `src/` exports the object under another name: yodel reads each built object's name and refuses the build when a source creates it too.
## Proof
The `declarative` claim runs a project whose `yodel.config.ts` has a source whose command prints a Prisma-style DDL file (the claim installs no ORM): the first apply creates the ORM's tables behind the gate, a column added to the model is the one change `yodel plan` shows, the approved apply adds it, and the plan is then empty. Under `BREAK=1` the DDL changes again after the approval, and the column is not applied.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `declarative` | the declarative path: yodel plan shows the change against the live server and yodel apply makes it behind the plan-bound gate, for src/ declarations (on Postgres, functions, procedures and triggers, and access control: a role, a policy and grants, with a grant made by hand revoked; on ClickHouse, a dictionary, a function, and access control: a role, a user, a row policy and grants, with a grant made by hand revoked) and for an ORM's exported DDL | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
---
# Generated schema reference
Source: https://intentius.io/sql-yodeler/schema-docs/
## Optional: hand this page to your coding agent
```text
Following https://intentius.io/sql-yodeler/schema-docs/, run `npx yodel docs` and open a pull request with schema-docs/.
In its description, name the tables and relations the diagram shows that the pull request changes.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
This page is about the reference `yodel docs` generates. Writing the schema itself is [Declaring the schema](/sql-yodeler/schema/).
`yodel docs` writes a reference of the schema for people reading a pull request or onboarding to a project: one HTML page and one Mermaid entity-relationship diagram, from the build of the declared schema. It reads no server and starts nothing; the HTML has its styles inline and no scripts, so it opens from disk or from any static host.
```sh
npx yodel docs # writes schema-docs/index.html and schema-docs/erd.md
npx yodel docs --out site/schema # somewhere else
npx yodel docs --migration 20261010T1723-baseline # the schema that migration recorded
```
`schema-docs/index.html` lists every database or schema, table, view, materialized view and index, with:
- its columns: type, nullability, default or generated expression, codec, and comment
- its keys: the primary key, unique constraints, checks and foreign keys on Postgres; the engine with its arguments, `ORDER BY`, `PRIMARY KEY`, `PARTITION BY`, `SAMPLE BY` and `TTL` on ClickHouse
- its relations both ways: the foreign keys it holds and the ones that reference it, what a view or materialized view reads, the table a materialized view writes to, the local table a `Distributed` table serves
- its history: each step in `migrations/` that changed it, in chain order
- its DDL
`schema-docs/erd.md` is the diagram as a Markdown file with a `mermaid` block, which GitHub, GitLab and Forgejo render in place. A foreign key is drawn as many-to-one (zero-or-one when its columns may be null); the other relations are dashed. Columns in a primary key are marked `PK`: on ClickHouse that is the `PRIMARY KEY`, or the `ORDER BY` when the table sets none, since the sparse index is built on it. Mermaid takes a type as one word, so spaces and commas in a type become `_` in the diagram; the HTML shows the type as declared.
The same schema always gives the same files, so a project can commit them and a pull request that changes the schema shows the change in `erd.md` and `index.html` next to the migration. `--migration ` documents the schema a migration recorded instead of the build, with the history up to that migration.
## The examples
Both example projects commit their output, and `npm run check` fails when it is not what `yodel docs` writes now (`test/schema-docs.test.ts`).
The ClickHouse example declares one database and one table. [Its reference](/sql-yodeler/examples/clickhouse/schema/) shows the table's `MergeTree` engine, its sort and partition keys, and the migrations that changed it, the sort-key rebuild among them. A backfill step names no object, so it is in no history.
erDiagram
shop_events["shop.events"] {
UInt64 id PK
LowCardinality(String) kind PK
DateTime at
LowCardinality(String) country
LowCardinality(String) source
}
The Postgres example declares a schema, a table and three indexes. [Its reference](/sql-yodeler/examples/postgres/schema/) shows the primary key, the check constraint, the indexes, and the history, the column rename as an expand-and-contract step included.
erDiagram
shop_orders["shop.orders"] {
bigint id PK
text email
text status
numeric(12_2) amount
timestamp_with_time_zone placed_at
timestamp_with_time_zone refunded_at
boolean gift
}
Neither example has a foreign key or a view, so neither diagram has a relation line. A project with them gets one per foreign key, per table a view reads and per materialized view target.
---
# The two workflows
Source: https://intentius.io/sql-yodeler/workflows/
## Optional: hand this page to your coding agent
```text
Make the schema change I describe with SQL Yodeler, following https://intentius.io/sql-yodeler/workflows/: edit the declarations in src/, run `npx yodel new ` and `npx yodel lint`, and open a pull request with src/ and the new migration.
Do not edit a migration that is committed on the default branch; write a new one.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
Both workflows start from the schema declared in `src/` ([Declaring the schema](/sql-yodeler/schema/)): it says what the database should be, and yodel works out how to get there. `yodel plan ` and `yodel apply ` take one of two paths, chosen by the project: the versioned path when it has a `migrations/` directory holding at least one migration, the declarative path otherwise. (A template's empty `migrations/`, with only `.gitkeep`, is still declarative.) Both apply behind an approval bound to a digest of what they would do; see [Approval](/sql-yodeler/approval/).
## Declarative: the declared schema against the live database
No migration files. `yodel plan ` shows the classified change from the environment's live database to the declared schema in `src/`, as `chant sql plan` reports it, and `yodel apply ` makes it.
`yodel apply` plans, then runs the project's `ApplyOp` for the environment with `chant run`. From the ClickHouse example:
```ts
import { ApplyOp } from "@intentius/chant/op";
// The declarative path: yodel apply dev plans src/ against dev and runs this
// Op, behind a gate bound to the plan.
export const { op } = ApplyOp({ name: "apply-dev", env: "dev", target: "clickhouse", gate: {} });
```
The Op builds the project first with `npm run build`, so `package.json` needs a `build` script that writes chant's build output:
```json
"scripts": { "build": "chant build src --lexicon sql -o dist/schema.json" }
```
Without it the Op fails at its Build step (`npm error Missing script: "build"`).
The run below is the ClickHouse example's first apply. The Op stops at its gate and `yodel apply` exits 3 with the command that approves this plan. Today that command is `npx yodel approve dev --plan `, which shows the plan, asks you to type `dev`, and records the approval of the digest the gate waits on; the runs below were recorded before yodel printed it, so they show and run chant's `chant approve`, which records the same approval:
```console
$ npx yodel apply dev
Plan for dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, sql.profiles.dev)
shop
[create] object: shop
SQLCH200 Create an object. A new database, table, view, dictionary, function, user, role or row policy is created, or a grantee gets its first grants; nothing existing changes. https://clickhouse.com/docs/sql-reference/statements/create
events (shop.events)
[create] object: shop.events
SQLCH200 Create an object. A new database, table, view, dictionary, function, user, role or row policy is created, or a grantee gets its first grants; nothing existing changes. https://clickhouse.com/docs/sql-reference/statements/create
2 create
Access control: not managed in dev (off by default); policies, grants, roles and users are not planned.
Running apply-dev (/clickhouse/ops/apply-dev.op.ts)
> sql-yodeler-example-clickhouse@0.0.0 build
> chant build src --lexicon sql -o dist/schema.json
fold: 1 file folded, 0 ran
sql — environment: dev
2 missing, 0 orphan, 0 disappeared, 0 newly observed, 0 drifted, 0 unchanged
--------------------------------------------------------------------------------
MISSING (declared, provider reports not in cloud):
- events [queried http://127.0.0.1:8123 shop.events] [definition 9f20bfc4d4ef488a683af5646837a026beea36f8affb953ecfb1e118c3f4a21a]
- shop [queried http://127.0.0.1:8123 shop] [definition aecfb2aa109301003bbc1a7ee057c2bb2610d824e610588317907171ed70c610]
[phase] Build
✓ chantBuild(path=.) 728ms
[phase] Plan
✓ lifecycleDiff(env=dev, live=true) 747ms
[outcome] Drift=true
[phase] Approve
• gate:approve-apply-dev() skipped
[phase] Apply
• nativeApply(target=clickhouse, env=dev, output=dist/schema.json, deleteMode=never) skipped
Op "apply-dev" is gated on "approve-apply-dev" after 2.0s
Approve apply to dev (delete mode: never)
plan : jcs1-sha256:02cfabc13e5ef7d0f1d089b4a5468afc3ed5140b9c2f04afad2203b470c933e8
approve : chant approve apply-dev approve-apply-dev --plan jcs1-sha256:02cfabc13e5ef7d0f1d089b4a5468afc3ed5140b9c2f04afad2203b470c933e8
expires : 2026-10-12T17:21:58.086Z
apply-dev is waiting at gate "approve-apply-dev" for approval of this plan.
Approve it with:
chant approve apply-dev approve-apply-dev --plan jcs1-sha256:02cfabc13e5ef7d0f1d089b4a5468afc3ed5140b9c2f04afad2203b470c933e8
then run npx yodel apply dev again.
[exit 3]
```
```console
$ npx chant approve apply-dev approve-apply-dev --plan jcs1-sha256:02cfabc13e5ef7d0f1d089b4a5468afc3ed5140b9c2f04afad2203b470c933e8
Gate "approve-apply-dev" on "apply-dev" resolved by yodel at 2026-10-10T17:21:59.886Z
This approves the plan jcs1-sha256:02cfabc13e5ef7d0f1d089b4a5468afc3ed5140b9c2f04afad2203b470c933e8, and only that plan. A run whose fresh plan differs refuses rather than applying it.
This records the resolution as a fact; it does not itself re-run anything. Run `chant run apply-dev` and it walks through gate "approve-apply-dev".
```
Run again, the same command applies the plan; the example shows that run, which ends `Applied: dev matches the declared schema.`
What the declarative path does and does not do:
- An `ApplyOp` with no `delete` option runs with delete mode `never` (its output says `delete mode: never`): a drop stays in the plan, and `yodel apply` says so when changes remain after the Op. `delete: "owned-only"` or `"gated"` on the `ApplyOp` lets it drop (chant's `ApplyOp` options).
- A change no statement makes in place (a ClickHouse sort-key change, a Postgres column rename or type change across kinds) makes `yodel plan` exit 2, and `yodel apply` applies nothing. Those changes are steps of a migration on the versioned path ([Data migrations](/sql-yodeler/steps/)).
- It keeps no history. What the database is, is what the live catalog says; `yodel drift` compares the two ([Drift](/sql-yodeler/drift/)).
- The topology comes from `sql.profiles..topology` only ([Topology](/sql-yodeler/topology/)).
- chant marks the objects it creates with an ownership marker in their comment (`[chant managed-by=chant]`) and never touches an object the project does not declare.
## Versioned: migrations written from the declared schema
The declared schema stays the source. `yodel new ` writes the next migration by diffing it against the schema the newest migration recorded, with no database involved. Each change goes through review as a pull request holding the edit to `src/` and the migration it produced:
```sh
$EDITOR src/schema.ts
npx yodel new add-country # writes migrations/-add-country/
npx yodel lint # offline; CI runs it on every pull request
npx yodel plan dev # the pending migrations and the plan digest
npx yodel apply dev # behind the migrations Op's gate
```
`yodel apply` on this path runs the pending migrations in chain order, statement by statement, recording each in the history table under a lock, and resumes a migration that failed part way at the statement that failed. A change that is not one statement (a rebuild, a backfill, an expand-and-contract column change) is a step inside the migration, recorded in the same history. [Migrations](/sql-yodeler/migrations/) has the details, and [Data migrations](/sql-yodeler/steps/) the steps.
## More than one environment
Each environment is a profile in `chant.config.ts`, with its own history. The starter templates' apply pipeline applies them in the order of `yodel.config.ts` `waves` after every push to main, one job per environment, each waiting for the one before; a wave that stops at its gate stops the ones after it. Each wave has its own gate policy (`always`, `on-destructive` or `never`), read from the commit the push merged onto, and applies only migrations the environment before it has applied: prod refuses (exit 4) a migration staging's history does not record. [Approval](/sql-yodeler/approval/#waves-a-gate-per-environment) has how a wave plans, waits and applies.
## The pipelines: yodel ci
`yodel ci` renders a project's pipelines for GitHub Actions, GitLab CI and Forgejo Actions from `chant.config.ts` (the environments), `yodel.config.ts` (`waves`, `ci`) and `ops/` (each environment's migrate and watch Ops), each declared with chant's lexicons: `yodel-pr` (lint, the replay check and the plan comment on a pull request), `yodel-apply` (the waves, after a push to main) and a `watch-` per environment. The templates' `npm run ci` runs it. [Setting up each forge](/sql-yodeler/forges/) has the secrets, tokens and runner each forge needs for them.
`ci: { apply: false }` leaves out `yodel-apply` and `yodel-waves.json` on every forge, for a team that wants lint and the plan comment on pull requests before CI applies anything: [Lint and plan on pull requests only](/sql-yodeler/lint-and-plan/).
The pipelines come from the installed package, so a fix to them reaches a project when it upgrades `@intentius/sql-yodeler` and runs `yodel ci` again. `yodel ci --check` writes nothing and exits 1 naming each file that differs from what it renders; the pull request's lint job runs it, so a pipeline edited by hand, or not rendered again after an upgrade, fails the pull request until the rendered files are committed. `yodel ci --replay` runs the lint job's replay check on your machine, in a throwaway server it starts with Docker.
`ci.forges` in `yodel.config.ts` picks the forges (default all three). A project's own jobs go in the module `ci.jobs` names, declared with chant, never in the rendered files:
```ts
// ci/jobs.ts, with ci: { jobs: "ci/jobs.ts" } in yodel.config.ts
import { Job, Step } from "@intentius/chant-lexicon-github";
import { Image, Job as GitLabJob } from "@intentius/chant-lexicon-gitlab";
export const actions = {
test: new Job({ "runs-on": "ubuntu-latest", steps: [new Step({ uses: "actions/checkout@v7" }), new Step({ run: "npm ci && npm test" })] }),
};
export const gitlab = {
test: new GitLabJob({ stage: "test", image: new Image({ name: "node:22" }), script: ["npm ci", "npm test"] }),
};
```
`yodel ci` adds `actions` to `yodel-pr` on GitHub and Forgejo and `gitlab` to `.gitlab-ci.yml`, after its own jobs, with a GitLab job's stage after `review`, so they are there after every render. A job with the name of one of yodel's (`lint`, `plan-`, `.yodel`) is refused.
By default every job runs in `node:22-bookworm` (GitHub's plan jobs on the runner, with `setup-node`) and installs the project's packages with `npm ci`. `ci.image` runs every job in the CI image instead, `ghcr.io/intentius/sql-yodeler:@sha256:`, which a release publishes with Node, yodel, chant and the lexicons on it: no job runs `npm ci` or `setup-node`, and a checkout resolves chant and the lexicons from the image. It is taken only pinned by its digest, which the release's `image` job writes to its summary, so a job never runs a different image under the same tag. Use the version the project's `package.json` installs, so the image's yodel renders the same pipelines `yodel ci --check` compares; a project whose jobs need packages of their own leaves `image` unset.
## ClickHouse objects
A ClickHouse project declares these with the `sql` lexicon's tags, from `@intentius/chant-lexicon-sql/clickhouse`. Both paths plan and apply them, `yodel drift` watches them, and `yodel init --from` adopts them. The claims column names the scenario claims that run each against a server.
| Object | Tag | Claims |
| --- | --- | --- |
| Database | `database` | declarative, new, adopt |
| Table | `table` | declarative, new, adopt |
| View and materialized view | `view` | adopt |
| Dictionary | `dictionary` | declarative, adopt |
| Function (a SQL lambda) | `func` | declarative, adopt |
| User | `user` | declarative, adopt |
| Role | `role` | declarative, adopt |
| Row policy | `policy` | declarative, adopt |
| Grant | `grant` | declarative, adopt |
Dictionaries and functions:
- A dictionary's changed comment is SQLCH203. Any other change (its source, layout, lifetime, attributes) is SQLCH245, applied as one `CREATE OR REPLACE DICTIONARY`.
- A function belongs to no database. A changed lambda is SQLCH260, applied as `CREATE OR REPLACE FUNCTION`; the server prints `x * k` as `(x * k)`, and the plan asks the server's formatter before calling that a change. A function cannot carry chant's ownership marker, so it is never pruned and a function made by hand is never read. `yodel init --from` adopts the SQL functions `sql.profiles..importFunctions` names (names, or prefixes ending in `*`), and the functions those call; it warns naming each function on the server it leaves out. It also adopts a function an adopted view or column default calls: ClickHouse stores the function's body in place of the call, and chant's import matches that body to the function, so a function in use needs no entry in the list.
- Users, roles, row policies and grants are planned only where the environment manages access ([Access control](/sql-yodeler/access/#clickhouse)).
## Postgres objects
A Postgres project declares these with the `sql` lexicon's tags, and both paths plan, apply, watch for drift and adopt them. The claims column names the scenario claims that run each against a server.
| Object | Tag | Claims |
| --- | --- | --- |
| Schema | `schema` | declarative, new, adopt |
| Table | `table` | declarative, new, adopt |
| Index | `index` | declarative, new, adopt |
| Function | `func` | declarative, new, adopt |
| Procedure | `procedure` | declarative, new |
| Trigger | `trigger` | declarative, new, adopt |
| View and materialized view, sequence, enum, domain, extension | `view`, `sequence`, `type`, `domain`, `extension` | none yet |
Functions, procedures and triggers:
- A function or procedure is its name and its parameter types, so two overloads are two objects. A new body or new attributes are `CREATE OR REPLACE` (SQLPG280, metadata). A change `CREATE OR REPLACE` refuses (the result type, an output parameter, an input parameter's name, a removed default, or other parameter types between two builds) is a drop and a create in one transaction (SQLPG281); while a view, trigger, default or other routine depends on the function it is an expand change (SQLPG282), which `yodel new` writes as a manual step.
- A trigger is its name on its table. Created on a table that already exists, it takes SHARE ROW EXCLUSIVE on that table (SQLPG283, metadata); created with its table, it is part of the create (SQLPG200). A changed trigger is `CREATE OR REPLACE TRIGGER` (SQLPG284), and a dropped one is SQLPG285.
- Statements go in dependency order: a function before the trigger that runs it and after the tables its body names, and a trigger dropped before its function.
- `yodel lint` flags a migration that drops and creates a function or procedure with another signature (`pg-signature`), and one that drops a function a declared trigger still executes (`pg-routine-in-use`); see [Lint](/sql-yodeler/lint/).
- `yodel drift` reports a function replaced by hand and a declared trigger that is gone. `yodel init --from` adopts the functions, procedures and triggers in the profile's schemas with the tables.
- On the declarative path, a function or trigger on the server that the project does not declare shows in `yodel plan` as a drop, the way an undeclared table does. Only a prune makes it, and only for an object carrying chant's ownership marker. A migration never drops one unless an earlier migration declared it.
- A routine with a SQL-standard body (`RETURN ...`, `BEGIN ATOMIC`) cannot be declared: the server stores it rewritten. `init --from` leaves one out with a warning.
## Moving from one to the other
A project on the declarative path moves to the versioned one with `yodel init --from `: it imports the live database into `src/`, writes the baseline migration that creates all of it, and records the baseline applied in the environment's history without running it. With `src/` already declaring the schema, `--force` lets it write over the declarations. Then the `ApplyOp` gives way to a migrations Op. Both examples start declarative and move this way; [Starting a project](/sql-yodeler/adoption/) has the run.
Going back is deleting `migrations/` and adding an `ApplyOp`. The history table stays in the database, unread.
## Proven by
The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). [Claims status](/sql-yodeler/claims/) lists every claim.
| Claim | What it says | Plain, broken | Last run |
|---|---|---|---|
| `declarative` | the declarative path: yodel plan shows the change against the live server and yodel apply makes it behind the plan-bound gate, for src/ declarations (on Postgres, functions, procedures and triggers, and access control: a role, a policy and grants, with a grant made by hand revoked; on ClickHouse, a dictionary, a function, and access control: a role, a user, a row policy and grants, with a grant made by hand revoked) and for an ORM's exported DDL | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `new` | yodel new writes the next migration offline, with no dev database, and it applies; on Postgres, functions, procedures and triggers too, which lint checks | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `approval` | a pending migration applies only after chant approve of its plan digest, and the history records that digest; a failing pre-migration check refuses it, and so does a policy rule, read at the base commit, unless an override is recorded for that digest, for a yodel revert as for an apply; the audit log derived from the history and the ledger accounts for every approval and apply, and names one removed; a reader and a writer that are one user are refused, and the reader, its password a minted token, cannot write; an approval is refused once the migration changed after it, and nothing is applied | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `waves` | the apply pipeline runs one wave per environment, in order, each behind its gate policy read from the base commit, applies only what the wave before applied, and applies a tenant set's migrations to every tenant behind one gate; a sealed wave counts only an approval sealed by a signer the base commit lists | ClickHouse: pass, caught; Postgres: pass, caught | `c6f58a4`, 2026-10-10 |
| `adopt` | yodel init --from adopts a live database without touching it, and yodel plan then shows no change; on Postgres its policies, row-level security and grants too, on ClickHouse its dictionaries, functions, roles, users, row policies and grants; yodel init --baseline records the baseline, behind the gate, in a second environment that holds the same schema, and refuses one that differs | ClickHouse: pass, caught; Postgres: pass, caught | `9329873`, 2026-10-10 |
---
# Migrations
Source: https://intentius.io/sql-yodeler/migrations/
## Optional: hand this page to your coding agent
```text
`yodel lint` reports a fork on this branch. Following https://intentius.io/sql-yodeler/migrations/ (Forks and yodel rebase), run `npx yodel rebase` with `--env` for each environment I name, so a migration that ran is never moved, then `npx yodel lint`, and push the branch.
Never run `yodel apply` against a shared environment, never run `chant approve`, `yodel approve` or `yodel override`, never edit the `chant/lifecycle` branch or `.chant/allowed_signers`, never merge; approvals and applies belong to people.
```
On the versioned path, `migrations/` holds the history of the schema as reviewed steps, and a table in each environment's database records what ran there. This page covers both, how `yodel apply` uses them, and what to do when they disagree. The output quoted here is from the two examples' runs; `...` marks lines of a command's output left out.
## The directory
Each migration is a directory, `migrations/-/`, named by `yodel new ` with the UTC time it was written. The name is lowercase letters and digits, words joined by `-` or `_`. It holds:
- `migration.sql`: the statements, each after a comment naming its object, the classifier's rule and the change's class. A step that is not SQL (a rebuild, a backfill, an expand-and-contract change, a manual step) appears as a comment only.
- `migration.json`: the migration's id, its parent's id, its checksum, when it was written, its dialect, its steps (each statement with its SQL, rule and class, and each Op step with the Op's declaration and options), and the recorded schema: chant's build of the declared schema as it stood after this migration.
- any file a step names: a backfill written as a chant Op, `backfill.ts`. A backfill written as a form has no file of its own; the form is in the step in `migration.json`.
From the ClickHouse example:
```sql
-- yodel migration 20261010T1722-add-country
-- parent: 20261010T1722-baseline
-- events (shop.events): SQLCH201 metadata
ALTER TABLE `shop`.`events` ADD COLUMN country LowCardinality(String) DEFAULT '' AFTER `at`;
```
The order migrations run in is the chain of parent ids. The timestamp in an id is for people reading a listing; it never decides the order. The first migration has no parent (`"parent": null`).
### Checksums
A migration's checksum covers its own files and nothing else: `migration.sql` with its line endings normalized, `migration.json` as parsed JSON without its `checksum` field (so whitespace and key order do not count, but every value does, the parent and the recorded schema included), and every other file in its directory. Adding, editing or removing another migration never changes it. Two branches that each add a migration therefore never conflict over a shared file; they fork the chain instead, which `yodel lint` reports ([below](#forks-and-yodel-rebase)).
`yodel lint` checks each migration's files against its `checksum` field (rule `checksum`). `yodel status` and `yodel apply` check each applied migration's files against the checksum the history recorded when it ran, and refuse on a mismatch.
## Writing one: yodel new
`yodel new ` diffs the newest migration's recorded schema (an empty schema for the first) against a fresh build of `src/`, and writes the next migration. It reads no database. When nothing changed it writes nothing and exits 0:
```console
$ npx yodel new check
No changes: the declared schema matches the newest migration's recorded schema (20261010T1724-rename-email). Nothing written.
```
(From the Postgres example's run, just after it writes `rename-email`; `run.ts postgres --write` rewrites this quote with the README. The committed-example check in `examples/walkthrough/run.ts` runs `yodel new check` in each example, so a declared schema that drifts from its migrations fails the e2e test.)
It refuses (exit 1) when the directory is not one chain (a fork, a missing parent) or a migration of that id is already there. `yodel new --backfill` ends the migration with a backfill step written from `backfills/.json` ([Data migrations](/sql-yodeler/steps/#backfills)), and `--retain ` keeps the old column of a Postgres rename, or the old table of a rebuild, that long after the switch ([Data migrations](/sql-yodeler/steps/#postgres-postgresmigrationop)).
## The history table
Each environment's history is a table in its own database: `.history` on ClickHouse, `.history` on Postgres, where the history database (schema) is `yodel.config.ts`'s `environments..history.database`, else `YODEL_HISTORY_DATABASE`, else `yodeler`. `yodel apply` (or `yodel init`, or `yodel repair`) creates it when it is not there. It is never part of the declared schema. The plan digest covers what it records (the applied migrations and their checksums) but not the table itself, so recording a run does not move the digest.
It is append-only: one row per event, never updated or deleted.
| Column | What it holds |
|---|---|
| `seq` | the event's order: taken under the apply lock, one more than the highest, so it orders rows across runs without trusting a clock |
| `run_id` | the `yodel apply` run that wrote the row |
| `kind` | `migration` (a migration started, succeeded or failed; `statement_index` -1), `statement`, or `step` (an Op step) |
| `migration_id`, `parent`, `checksum` | the migration, its parent, and the checksum its files had |
| `statement_index`, `statement_sha` | which step of the migration, and the hash of the statement as written (before topology rendering) |
| `status` | `started`, `succeeded` or `failed` |
| `error` | why it failed |
| `applied_by` | who ran it (`yodel@example` in the examples) |
| `plan_digest` | the digest the run was approved under |
| `silences` | the migration's `yodel:allow` silences, as JSON ([Lint](/sql-yodeler/lint/#silencing-a-finding)) |
| `note` | a step's summary as JSON; a statement's topology (`topology=single`); `baseline: ...` on a baseline `yodel init` recorded; a repair's reason |
| `recorded_at` | the server's time, for people |
The state of a migration or a statement is its row with the highest `seq`. The ClickHouse example's rows for its rebuild and its backfill:
```sql
SELECT migration_id, kind, statement_index, status, note, silences
FROM yodeler.history
WHERE migration_id LIKE '%events-by-id' OR migration_id LIKE '%fill-country'
ORDER BY seq FORMAT Vertical
```
```
Row 1:
──────
migration_id: 20261010T1722-events-by-id
kind: migration
statement_index: -1
status: started
note:
silences: [{"step":0,"rule":"ch-rebuild","reason":"600 rows; the copy takes seconds"}]
Row 2:
──────
migration_id: 20261010T1722-events-by-id
kind: step
statement_index: 0
status: started
note:
silences: [{"step":0,"rule":"ch-rebuild","reason":"600 rows; the copy takes seconds"}]
Row 3:
──────
migration_id: 20261010T1722-events-by-id
kind: step
statement_index: 0
status: succeeded
note: {"op":"ClickHouseRebuildOp","table":"shop.events","state":"rebuild","partitions":6,"copied":6,"skipped":0,"cleared":0,"repaired":0,"verifiedRows":600,"swapped":true,"oldTable":"shop.events__chant_old","retainUntil":"2026-10-17T17:22:47.304Z"}
silences: [{"step":0,"rule":"ch-rebuild","reason":"600 rows; the copy takes seconds"}]
Row 4:
──────
migration_id: 20261010T1722-events-by-id
kind: migration
statement_index: -1
status: succeeded
note:
silences: [{"step":0,"rule":"ch-rebuild","reason":"600 rows; the copy takes seconds"}]
Row 5:
──────
migration_id: 20261010T1722-fill-country
kind: migration
statement_index: -1
status: started
note:
silences: []
Row 6:
──────
migration_id: 20261010T1722-fill-country
kind: step
statement_index: 0
status: started
note:
silences: []
Row 7:
──────
migration_id: 20261010T1722-fill-country
kind: step
statement_index: 0
status: succeeded
note: {"op":"backfill","file":"backfill.ts","name":"backfill-fill-country","effects":6,"skipped":0,"ran":6}
silences: []
Row 8:
──────
migration_id: 20261010T1722-fill-country
kind: migration
statement_index: -1
status: succeeded
note:
silences: []
```
## The apply lock
One apply runs at a time per environment.
- ClickHouse: a row in `.lock`, a `KeeperMap` table, so it lives in Keeper and every runner on any host sees it. It needs Keeper and the server setting `keeper_map_path_prefix`. The holder renews it while it works; a lock left by a dead runner expires after its TTL (`lockTtl`, `YODEL_LOCK_TTL`, default 900 seconds). A single node without Keeper gets a lock file on the runner's machine instead, which serializes applies from that machine only, and the run says so (`lock: no KeeperMap on this server ...; the lock is a file on this machine, so cross-runner locking needs Keeper`). A cluster, a `Replicated` database and Cloud refuse rather than fall back.
- Postgres: a session advisory lock on a connection of its own, keyed by the history schema (`lock: held in Postgres advisory lock (...) for history schema yodeler`). Postgres releases it when the session ends, however it ends, so it needs no expiry.
A run that finds the lock taken exits 5 and names the holder, unless it was told to wait: `yodel apply --lock-wait 10m` (or `lockWait` in seconds in `yodel.config.ts`, or `YODEL_LOCK_WAIT`) tries again every second for up to that long, saying who holds the lock when the wait starts and whenever the holder changes, and exits 5 only if the lock is still held at the end. The wait is in the Apply step's JSON (`lock.waited`: how long, and who held it). An apply that gets the lock after another one applied everything finds nothing pending under it and sends nothing.
`yodel apply --stand-down` (or `YODEL_STAND_DOWN=1`) is for CI on the main branch, where two merges in quick succession start two apply jobs: before it takes the lock, and while it waits for it, the run fetches its branch from `origin` (`GITHUB_REF_NAME` on GitHub and Forgejo Actions, `CI_COMMIT_BRANCH` on GitLab CI, else the checked-out branch), and when the branch's tip is a newer commit that contains this one, it applies nothing and exits 0 (`standing down: main on origin is at , newer than this run's , and its own apply applies `; `"status": "stood-down"` in the JSON). The newer commit's apply job applies the same environment. A fetch that fails, or a tip that does not contain the commit (a force-push), is not a newer commit, and the run goes ahead. The starter templates' apply jobs set both: `YODEL_LOCK_WAIT=10m` and `YODEL_STAND_DOWN=1`.
To clear a lock by hand, once you know nothing is applying: on ClickHouse `ALTER TABLE .lock DELETE WHERE name = 'apply'` (or remove the lock file the refusal names); on Postgres end the holding session, `SELECT pg_terminate_backend()`. `yodel status` and `yodel plan` take no lock.
## Applying
`yodel apply ` runs the project's migrations Op ([Approval](/sql-yodeler/approval/)). Under the lock, its Apply step checks the plan digest again, checks every applied migration's checksum, then runs the pending migrations in chain order, statement by statement, writing a `started` row before each and a `succeeded` or `failed` row after. From the ClickHouse example:
```console
$ npx yodel apply dev
Migrations in dev (clickhouse 26.8.15.10 at 127.0.0.1:8123, history yodeler.history, topology single (default))
Applied (1):
20261010T1722-baseline 2026-10-10 17:22:07.370595 by yodel@example
Pending (1):
20261010T1722-add-country
Plan digest: jcs1-sha256:c6a49076c4a2b58c984b7a73d59e86c5788e82cb89728c5ec24307869e72bd03
Running migrate-dev (/clickhouse/ops/migrate-dev.op.ts)
lock: no KeeperMap on this server (KeeperMap is disabled because 'keeper_map_path_prefix' config is not defined. (BAD_ARGUMENTS) (version 26.8.15.10 (official build))); the lock is a file on this machine, so cross-runner locking needs Keeper (and keeper_map_path_prefix)
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:c6a49076c4a2b58c984b7a73d59e86c5788e82cb89728c5ec24307869e72bd03, by yodel at 2026-10-10T17:22:18.586Z
20261010T1722-add-country: applying
statement 0: ok (SQLCH201 metadata, shop.events)
20261010T1722-add-country: applied
[phase] Plan
✓ shellCmd(cmd=yodel apply dev --digest) 1.0s
[phase] Approve
✓ gate:approve-migrate-dev() 227ms
[approved] yodel at 2026-10-10T17:22:18.586Z
[phase] Apply
✓ shellCmd(cmd=yodel apply dev --execute, env={"YODEL_APPROVED_PLAN":"jcs1-sha256:c6a49076c4a2b58c984b7a73d59e86c5788e82cb89728c5ec24307869e72bd03"}) 1.8s
Op "migrate-dev" completed in 3.2s
Applied: 20261010T1722-add-country.
```
On ClickHouse each statement is rendered for the environment's topology as it is sent ([Topology](/sql-yodeler/topology/)). On Postgres:
- A statement that can run in a transaction runs in one of its own, with its `succeeded` row, so the two commit together or not at all.
- `CREATE INDEX CONCURRENTLY` and the other statements that cannot run in a transaction run alone, with their row written right after. Before each attempt at a `CONCURRENTLY` build, an INVALID index of that name left by an earlier failed attempt is dropped; a valid one is never touched.
- Every statement runs under `lock_timeout` (the profile's `lockTimeoutMs`, default 5 s) and a `statement_timeout` (`statementTimeoutMs`, default 60 s, for a catalog change; `scanTimeoutMs`, default none, for one that reads or rewrites rows). A statement that times out waiting for a lock is tried again after a pause that doubles from 250 ms, up to `YODEL_LOCK_RETRIES` more times (default 5); any other error fails it at once.
## Pre-migration checks
Some migrations are only safe when the data looks a certain way: a table is empty before it is dropped, a column has no NULLs before it becomes NOT NULL. A migration says so with `checks` in its `migration.json`, read-only queries that run against the environment before its first statement. Write one with `yodel new`:
```sh
npx yodel new drop-legacy --check "legacy-empty: SELECT 1 FROM shop.legacy LIMIT 1"
```
`--check` is repeatable. Written `: ` the check takes that name; otherwise it is `check-1`, `check-2` and so on. In `migration.json` it looks like this:
```json
"checks": [
{ "name": "legacy-empty", "sql": "SELECT 1 FROM shop.legacy LIMIT 1" }
]
```
What a check must return is its `expect`:
| `expect` | passes when the query returns |
|---|---|
| `"empty"` (the default, and what `--check` writes) | no rows |
| `"true"` | one row whose first column is true or 1 |
| `{ "min": 1, "max": 1000000 }` (either bound may be left out) | one row whose first column is a number within the bounds, inclusive |
A bare table name means the migration's default database (ClickHouse) or schema (Postgres). A check runs read-only: on ClickHouse with `readonly = 2`, on Postgres in a `READ ONLY` transaction that is rolled back, under the profile's `lockTimeoutMs` and `scanTimeoutMs`.
`yodel apply ` runs the first pending migration's checks before it runs the migrations Op, so a run that would be refused stops before anyone is asked to approve it. The Op's Apply step runs every pending migration's checks again under the lock, just before that migration's first statement, when earlier migrations in the same run have already applied. A check that fails, or whose query the server refuses, refuses the apply with exit 4 and names the check; nothing of that migration is sent. Under the lock the history records the outcome: the migration's `started` row carries the checks' results in `note`, and a refusal is a `failed` migration row whose `error` names the check. A migration resuming part way does not run its checks again, since they held before its first statement.
In the Postgres example a CHECK constraint's validation has a pre-check ([Statements that can fail on the data](/sql-yodeler/lint/#statements-that-can-fail-on-the-data)), and one order has an amount of 0:
```console
$ npx yodel apply dev
yodel apply: refused: a pre-migration check of 20261010T1723-coupons failed before the gate. Nothing of 20261010T1723-coupons was applied:
step 1, ALTER TABLE shop.orders ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID, would fail: 1 rows that fail the check orders_amount_positive (amount > 0) (pre-check precheck-1: SELECT count(*) AS n FROM shop.orders WHERE NOT (amount > 0))
Fix the data, then run yodel apply again; if the check is wrong, edit it in migration.json and run yodel lint --update-checksum 20261010T1723-coupons (the plan digest moves, so the plan is approved again). A statement's pre-check is chant's count of the rows the statement fails on: fix those rows, or change the declaration.
[exit 4]
```
The checks are part of `migration.json`, so of the checksum and the plan digest: adding, editing or removing one moves the digest, and the plan is approved again. To change a check of a migration not yet applied anywhere, edit it and run `yodel lint --update-checksum `. `yodel plan` lists each pending migration's checks, and the pull request comment shows them in a table under the migration's steps.
## Resuming a migration that failed part way
A statement whose latest row is `succeeded` is never sent again. So when a statement fails, the next `yodel apply` resumes the migration at that statement. (A statement whose latest row is `started`, because the runner stopped while it ran, is sent again: the history cannot tell whether the server ran it.) An Op step that failed resumes from its receipts ([Data migrations](/sql-yodeler/steps/)).
In the Postgres example a migration adds a column, then builds an index on it, and someone has made an index of the same name on dev by hand. The part of the run after the gate:
```console
$ npx yodel apply dev
...
lock: held in Postgres advisory lock (1498367052, 748638931) for history schema yodeler
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:b5f00cfef13f9274abb0a096b95bf02bd40883ba91189a20245a3b85e46dedcd, by yodel at 2026-10-10T17:24:17.565Z
20261010T1724-refunds: applying
statement 0: ok (SQLPG201 metadata, shop.orders)
statement 1: failed: relation "orders_refunded_at_idx" already exists
yodel apply: 20261010T1724-refunds failed at statement 1: relation "orders_refunded_at_idx" already exists
Fix the cause (on the server, or that statement in the migration's files), then run yodel apply again: it resumes at statement 1 and never sends again the statements that succeeded.
...
[exit 1]
```
`yodel status` shows where it stopped, and exits 3 (pending):
```console
$ npx yodel status dev
Migrations in dev (postgres 18.6 at 127.0.0.1:5432/postgres, history yodeler.history)
Applied (2):
20261010T1723-baseline 2026-10-10 17:23:54.742760 by yodel@example
20261010T1723-coupons 2026-10-10 17:24:09.819313 by yodel@example
Pending (1):
20261010T1724-refunds (failed part way at statement 1; resumes at statement 1)
error: statement 1: relation "orders_refunded_at_idx" already exists
Plan digest: jcs1-sha256:f4538cde31ddae8c82df1d6e19ff46886b6e7ebe9ab0d3c22e24451ed8a52c30
[exit 3]
```
The history changed since the approval (statement 0 ran), so the plan digest moved and the gate asks for a new approval. After dropping the hand-made index and approving again, the next run sends only statement 1:
```console
$ npx yodel apply dev
...
Running migrate-dev (/postgres/ops/migrate-dev.op.ts)
lock: held in Postgres advisory lock (1498367052, 748638931) for history schema yodeler
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:9527e04f668b47281769059b63977f8fafdbbd3bf06b377854aa05273ad5db09, by yodel at 2026-10-10T17:24:27.531Z
20261010T1724-refunds: resuming at statement 1
statement 0: succeeded in an earlier run; not sent
statement 1: ok (SQLPG240 concurrently, shop.orders_refunded_at_idx)
statement 2: ok (SQLPG240 concurrently, shop.orders_refunded_at_idx)
20261010T1724-refunds: applied
...
```
## yodel status
`yodel status ` lists the environment's migrations against its history: applied, pending, out of order, failed part way (with the statement it resumes at), and checksum mismatches, and prints the plan digest `yodel apply` would ask approval for. It reads only and takes no lock. Exit codes: 0 every migration applied, 3 migrations pending, 4 a checksum mismatch or an out-of-order migration, 2 a pending migration has a step `yodel apply` does not run, 1 the status could not be read. `--json` prints it as JSON.
```console
$ npx yodel status dev
Migrations in dev (postgres 18.6 at 127.0.0.1:5432/postgres, history yodeler.history)
Applied (7):
20261010T1723-baseline 2026-10-10 17:23:54.742760 by yodel@example
20261010T1723-coupons 2026-10-10 17:24:09.819313 by yodel@example
20261010T1724-refunds 2026-10-10 17:24:31.521713 by yodel@example
20261010T1724-rename-email 2026-10-10 17:24:50.116164 by yodel@example
20261010T1724-drop-coupon 2026-10-10 17:25:09.412058 by yodel@example
20261010T1725-add-gift 2026-10-10 17:25:40.602850 by yodel@example
20261010T1725-index-placed-at 2026-10-10 17:25:24.896277 by yodel@example
repaired 2026-10-10 17:25:30.589306 by yodel@example: rebased onto add-gift after it ran on dev
Pending: none
```
## Out-of-order migrations
A pending migration that the chain puts before one already applied is out of order. It can happen when a migration that ran in an environment is moved in the chain afterwards, as below. `yodel apply` refuses it (exit 4) and never skips it:
```console
$ npx yodel apply dev
yodel apply: refused: 20261010T1725-add-gift is out of order: the chain puts it before 20261010T1725-index-placed-at, which is already applied. Nothing was applied. Pass --allow-out-of-order to apply it now (in chain order); it is never skipped.
[exit 4]
```
`--allow-out-of-order` applies it, in chain order, behind the same approval as any other run:
```console
$ npx yodel apply dev --allow-out-of-order
...
Running migrate-dev (/postgres/ops/migrate-dev.op.ts)
lock: held in Postgres advisory lock (1498367052, 748638931) for history schema yodeler
approval: migrate-dev / approve-migrate-dev for jcs1-sha256:a6b43ee382e0a02028ff147d6d6c1a5e0932f1351c226c64555200835edd5cf9, by yodel at 2026-10-10T17:25:36.714Z
20261010T1725-add-gift: applying (out of order, allowed)
statement 0: ok (SQLPG201 metadata, shop.orders)
20261010T1725-add-gift: applied
...
```
## Forks and yodel rebase
Two branches that each add a migration give one parent two children. Merged together, `yodel lint` fails on the fork (an error that cannot be lowered), naming both:
```console
$ npx yodel lint
migrations/
error fork: 20261010T1725-add-gift and 20261010T1725-index-placed-at both follow 20261010T1724-drop-coupon; rebase one onto the other
20261010T1723-baseline
warning SQLPG112: step 2: orders.customer_email looks like a secret or personal data and neither it nor shop.orders has a COMMENT; say what it holds and how it is protected
20261010T1723-coupons
warning data-dependent: step 1: SQLPG217 Add a constraint NOT VALID on shop.orders: can fail on the rows already there (rows that fail the check orders_amount_positive (amount > 0)); yodel apply runs this pre-check before the migration's first statement and refuses when it is not 0: SELECT count(*) AS n FROM shop.orders WHERE NOT (amount > 0)
20261010T1724-drop-coupon
silenced destructive: step 0: SQLPG204 Drop a column on shop.orders: removes data that cannot be recovered
reason: no order ever had a coupon; checked on dev
20261010T1724-rename-email
warning SQLPG112: step 0: orders.email looks like a secret or personal data and neither it nor shop.orders has a COMMENT; say what it holds and how it is protected
7 migrations: 1 error, 3 warnings, 1 silenced.
[exit 3]
```
`yodel rebase [