Migrations
Optional: hand this page to your coding agentThe steps work by hand too.Show the whole prompt
`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
Section titled “The directory”Each migration is a directory, migrations/<YYYYMMDDTHHMM>-<name>/, named by yodel new <name> 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 inmigration.json.
From the ClickHouse example:
-- yodel migration 20261010T1722-add-country-- parent: 20261010T1722-baseline
-- events (shop.events): SQLCH201 metadataALTER 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
Section titled “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).
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
Section titled “Writing one: yodel new”yodel new <name> 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:
$ npx yodel new checkNo 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 <name> --backfill ends the migration with a backfill step written from backfills/<name>.json (Data migrations), and --retain <duration> keeps the old column of a Postgres rename, or the old table of a rebuild, that long after the switch (Data migrations).
The history table
Section titled “The history table”Each environment’s history is a table in its own database: <history database>.history on ClickHouse, <history schema>.history on Postgres, where the history database (schema) is yodel.config.ts’s environments.<env>.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) |
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:
SELECT migration_id, kind, statement_index, status, note, silencesFROM yodeler.historyWHERE migration_id LIKE '%events-by-id' OR migration_id LIKE '%fill-country'ORDER BY seq FORMAT VerticalRow 1:──────migration_id: 20261010T1722-events-by-idkind: migrationstatement_index: -1status: startednote:silences: [{"step":0,"rule":"ch-rebuild","reason":"600 rows; the copy takes seconds"}]
Row 2:──────migration_id: 20261010T1722-events-by-idkind: stepstatement_index: 0status: startednote:silences: [{"step":0,"rule":"ch-rebuild","reason":"600 rows; the copy takes seconds"}]
Row 3:──────migration_id: 20261010T1722-events-by-idkind: stepstatement_index: 0status: succeedednote: {"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-idkind: migrationstatement_index: -1status: succeedednote:silences: [{"step":0,"rule":"ch-rebuild","reason":"600 rows; the copy takes seconds"}]
Row 5:──────migration_id: 20261010T1722-fill-countrykind: migrationstatement_index: -1status: startednote:silences: []
Row 6:──────migration_id: 20261010T1722-fill-countrykind: stepstatement_index: 0status: startednote:silences: []
Row 7:──────migration_id: 20261010T1722-fill-countrykind: stepstatement_index: 0status: succeedednote: {"op":"backfill","file":"backfill.ts","name":"backfill-fill-country","effects":6,"skipped":0,"ran":6}silences: []
Row 8:──────migration_id: 20261010T1722-fill-countrykind: migrationstatement_index: -1status: succeedednote:silences: []The apply lock
Section titled “The apply lock”One apply runs at a time per environment.
- ClickHouse: a row in
<history database>.lock, aKeeperMaptable, so it lives in Keeper and every runner on any host sees it. It needs Keeper and the server settingkeeper_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, aReplicateddatabase 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 <env> --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 <env> --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 <sha>, newer than this run's <sha>, and its own apply applies <env>; "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 <history database>.lock DELETE WHERE name = 'apply' (or remove the lock file the refusal names); on Postgres end the holding session, SELECT pg_terminate_backend(<pid>). yodel status and yodel plan take no lock.
Applying
Section titled “Applying”yodel apply <env> runs the project’s migrations Op (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:
$ npx yodel apply devMigrations 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 (<tmp>/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.586Z20261010T1722-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.8sOp "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). On Postgres:
- A statement that can run in a transaction runs in one of its own, with its
succeededrow, so the two commit together or not at all. CREATE INDEX CONCURRENTLYand the other statements that cannot run in a transaction run alone, with their row written right after. Before each attempt at aCONCURRENTLYbuild, 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’slockTimeoutMs, default 5 s) and astatement_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 toYODEL_LOCK_RETRIESmore times (default 5); any other error fails it at once.
Pre-migration checks
Section titled “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:
npx yodel new drop-legacy --check "legacy-empty: SELECT 1 FROM shop.legacy LIMIT 1"--check is repeatable. Written <name>: <sql> the check takes that name; otherwise it is check-1, check-2 and so on. In migration.json it looks like this:
"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 <env> 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), and one order has an amount of 0:
$ npx yodel apply devyodel 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 <id>. 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
Section titled “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).
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:
$ npx yodel apply dev...lock: held in Postgres advisory lock (1498367052, 748638931) for history schema yodelerapproval: migrate-dev / approve-migrate-dev for jcs1-sha256:b5f00cfef13f9274abb0a096b95bf02bd40883ba91189a20245a3b85e46dedcd, by yodel at 2026-10-10T17:24:17.565Z20261010T1724-refunds: applying statement 0: ok (SQLPG201 metadata, shop.orders) statement 1: failed: relation "orders_refunded_at_idx" already existsyodel apply: 20261010T1724-refunds failed at statement 1: relation "orders_refunded_at_idx" already existsFix 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):
$ npx yodel status devMigrations 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:
$ npx yodel apply dev...Running migrate-dev (<tmp>/postgres/ops/migrate-dev.op.ts)lock: held in Postgres advisory lock (1498367052, 748638931) for history schema yodelerapproval: migrate-dev / approve-migrate-dev for jcs1-sha256:9527e04f668b47281769059b63977f8fafdbbd3bf06b377854aa05273ad5db09, by yodel at 2026-10-10T17:24:27.531Z20261010T1724-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
Section titled “yodel status”yodel status <env> 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.
$ npx yodel status devMigrations 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: noneOut-of-order migrations
Section titled “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:
$ npx yodel apply devyodel 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:
$ npx yodel apply dev --allow-out-of-order...Running migrate-dev (<tmp>/postgres/ops/migrate-dev.op.ts)lock: held in Postgres advisory lock (1498367052, 748638931) for history schema yodelerapproval: migrate-dev / approve-migrate-dev for jcs1-sha256:a6b43ee382e0a02028ff147d6d6c1a5e0932f1351c226c64555200835edd5cf9, by yodel at 2026-10-10T17:25:36.714Z20261010T1725-add-gift: applying (out of order, allowed) statement 0: ok (SQLPG201 metadata, shop.orders)20261010T1725-add-gift: applied...Forks and yodel rebase
Section titled “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:
$ npx yodel lintmigrations/ 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 [<id>] [--onto <parent>] moves a migration onto a new parent: its change (its recorded schema against its old parent’s) is applied to the new parent’s recorded schema, its statements are diffed again, and its files and checksum are written again. Migrations after it move with it. Ids do not change. With no <id> it resolves the directory’s one fork by moving the newer of the two (by the timestamp in its id) onto the end of the other’s branch.
A migration that ran somewhere must not be rebased: its statements already ran in that order. --env <env> (repeatable) reads each environment’s history first and refuses to move a migration it records:
$ npx yodel rebase 20261010T1725-index-placed-at --onto 20261010T1725-add-gift --env devyodel rebase: refused: dev's history records 20261010T1725-index-placed-at (applied). Nothing was written. A migration that ran is never rebased: its statements already ran in that order. Rebase the other branch onto it instead (yodel rebase <the other> --onto <the end of this one>).[exit 4]The fix it names is to move the other migration instead: here yodel rebase <add-gift> --onto <index-placed-at> --env dev. Without --env, no history is checked, and the rebase says so:
$ npx yodel rebase 20261010T1725-index-placed-at --onto 20261010T1725-add-giftRebased 20261010T1725-index-placed-at onto 20261010T1725-add-gift (it followed 20261010T1724-drop-coupon). 20261010T1725-index-placed-at: follows 20261010T1725-add-gift; 2 statements; checksum sha256:8b0a50449f82a2bc1c63fb8c868eb6968dba13b9ab5b02ccc43c8fd231659117 -> sha256:6be6d45f55c4fa14ed67b2b22471f217f92ba3115bcc5c10fa154c99ef1a281fNo history was checked (--env): make sure none of these ran anywhere before merging.yodel rebase also refuses (exit 4, nothing written) when both branches change the same object differently (resolve it by hand: delete the migration and run yodel new again), when a migration it would rewrite was edited by hand (unless --force, which drops the edits), or when the moved migration adds nothing on its new parent. A -- yodel:allow line (Lint) does not count as an edit by hand: the rebase writes it again above the same statement, and says how many it carried over. When the statement a line silenced is gone from the rebased migration or changed, the rebase refuses (exit 4, nothing written) and names the line; remove it, record the checksum with yodel lint --update-checksum <id>, rebase, and silence what the new statements need.
Checkpoints: yodel checkpoint
Section titled “Checkpoints: yodel checkpoint”A long chain makes every new environment, and every replay, run every migration from the first. yodel checkpoint <name> writes a migration after the newest one whose statements create the newest migration’s recorded schema from nothing (what yodel new writes for a first migration). Its recorded schema is its parent’s, and its migration.json marks it a checkpoint with the SHA-256 of that schema: "checkpoint": { "schema": "sha256:..." }. It reads no database and does not build src/. Its migration.sql starts -- yodel checkpoint <id>.
How yodel apply and yodel status treat one:
- An environment whose history is empty starts at the newest checkpoint. The migrations before it are skipped there, never run; after the checkpoint, the chain goes on as usual.
- An environment whose history records a checkpoint started there, and goes on skipping the migrations before it.
- Any other environment, one that ran the migrations a checkpoint stands for, skips the checkpoint.
A skipped migration is neither applied nor pending, so it is never out of order. yodel status lists them under “Skipped” (skipped in --json, each covered by the checkpoint it names, or a checkpoint the environment does not start from).
The checkpoint is a migration like any other, so its checksum covers its statements, its mark and its recorded schema. yodel lint adds two checks:
- rule
checkpoint(an error, offline): its recorded schema still hashes to its mark, and so does its parent’s. An edit to either, even with the checksum recorded again, fails here. --replay <env>replays the chain without the checkpoint, then the checkpoint alone into the emptied database, and compares the result with its recorded schema. Statements that do not create that schema fail the replay (rulereplay), naming the checkpoint.
With both, a fresh environment that starts from the checkpoint ends at the schema an environment that ran the chain has. Write another checkpoint later the same way; a fresh environment starts at the newest. yodel checkpoint refuses (exit 4) when the newest migration is a checkpoint already.
Reverting: yodel revert
Section titled “Reverting: yodel revert”yodel revert <env> <id> undoes migration <id> in <env>. Each migration records the schema after it, and its parent records the schema before it, so the reverse is planned offline: the statements that take <id>’s recorded schema back to its parent’s (an empty schema for the first migration), diffed by chant as yodel new diffs. Only the newest migration applied in the environment can be reverted, since a later one was written against it; anything else refuses (exit 4). So does a checkpoint the environment started from (it created the whole schema, and nothing before it ran there), and a migration a checkpoint left out there (Checkpoints); both refusals name the checkpoint. --dry-run prints the statements, each with its rule and class (a drop is marked destructive), and the digest, and runs nothing.
It goes through the environment’s migrations Op, as yodel apply does. The Op’s Plan step prints the revert’s digest (its statements, the history and the live schema), the gate binds the approval to that digest, and the Apply step plans the revert again under the lock, refuses if the digest moved, checks the approval in chant’s gate ledger, and runs it. A run that stops at the gate prints the command that approves the revert, yodel approve <env> --plan <digest> (exit 3); approve, then run yodel revert again. The policy in yodel.config.ts, read at the base commit, is judged over the revert’s statements before the gate and again under the lock, as it is over an apply: a rule that refuses drops refuses a revert that drops what the migration added (exit 4), unless yodel override <env> --revert <id> recorded an override for the revert’s digest (Policy and overrides).
Statements carry the schema back, never data. A migration with a data step (a backfill) is refused (exit 2) unless --step <file> names a hand-written revert step: SQL statements, each ending with ; at the end of a line, that undo what the data step did. They run before the reverse statements, and their text is in the digest. A reverse that would need an Op or a manual step (a ClickHouse sort key changed back, a Postgres column renamed back) is refused (exit 2): write it as a new migration with yodel new.
The history gets a revert row for each statement as it starts, succeeds or fails, then a migration row whose status is reverted. A revert that fails part way resumes at the statement that failed, and the migration stays applied until the reverted row is written. After it, the migration is pending again, and yodel status marks it reverted <when> by <who> (reverted in --json); the audit log of yodel report lists the revert. The next yodel apply would apply it again, every statement of it, so delete it or rewrite it (yodel new <name> --replace) first. On Postgres, a history table made before yodel revert existed has its kind and status checks widened the first time a revert writes to it.
yodel repair
Section titled “yodel repair”An applied migration whose files changed no longer matches the checksum its history recorded, and yodel status and yodel apply refuse:
$ npx yodel apply devyodel apply: refused: the history does not match the migrations directory; nothing was applied: 20261010T1725-index-placed-at was applied with checksum sha256:8b0a50449f82a2bc1c63fb8c868eb6968dba13b9ab5b02ccc43c8fd231659117, but its files now hash to sha256:6be6d45f55c4fa14ed67b2b22471f217f92ba3115bcc5c10fa154c99ef1a281f; if the edit is intended, record it with yodel repair 20261010T1725-index-placed-at --reason ... <env>[exit 4]When the edit is intended (here, the rebase above; or a statement edited after a server upgrade made it invalid), yodel repair <id> --reason "<text>" <env> appends a row with the files’ new checksum and the reason, under the apply lock. Nothing runs on the server: the edit is recorded, not applied. Repair each environment the migration ran in.
$ npx yodel repair 20261010T1725-index-placed-at --reason 'rebased onto add-gift after it ran on dev' devRepaired 20261010T1725-index-placed-at in dev's history (yodeler). old checksum: sha256:8b0a50449f82a2bc1c63fb8c868eb6968dba13b9ab5b02ccc43c8fd231659117 new checksum: sha256:6be6d45f55c4fa14ed67b2b22471f217f92ba3115bcc5c10fa154c99ef1a281f reason: rebased onto add-gift after it ran on devyodel status then lists the migration with the time it was applied and who applied it, and the repair (when, by whom, and why) under it, as in yodel status below. It refuses (exit 4) when the migration is not applied in that environment, when its files already match, or when it is gone from the directory. When the edit is to a migration applied nowhere yet, there is no history to repair: yodel lint --update-checksum <id> writes the new checksum into its migration.json (Lint).
Proven by
Section titled “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 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 |
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 |
lock-retry |
Postgres: a statement blocked behind another session’s lock times out at lock_timeout and is retried until it succeeds; Postgres and ClickHouse with Keeper: two applies at once never send a statement twice, the second waits naming the holder, and –stand-down steps aside for a newer commit | 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 |
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 |
