Ratchet applies SQL migrations in a known order and records what succeeded. It does not parse your SQL, and it does not roll back. It supports PostgreSQL, SQL Server, and SQLite.
Ratchet runs as a one-shot container: point it at a directory of SQL migrations, give it a connection string, and run a command. A typical CI/CD step pulls the image, mounts your migrations to /app/Migrations, and runs update:
docker run --rm \
-v ./migrations:/app/Migrations \
-e Ratchet__ConnectionString="Host=db;Database=app;Username=postgres;Password=..." \
abeseler/ratchet updatePut a ratchet.json starting file in that mounted directory (see Starting File). Override the name with --startingFile / Ratchet__StartingFile if you need to.
Locally, the defaults are a Migrations working directory and ratchet.json inside it. See Configuration for all options.
Ratchet grew out of experience with Flyway and Liquibase, keeping the parts that worked and dropping the parts that added friction. A few principles shape it:
-
It applies whatever the starting file lists. It was designed for one file per object. Folder names and layout are yours — the tool does not require
01_tablesprefixes or a particular tree. I recommend one folder per object type, included in dependency order, but you can just as well point it at a single Flyway-style dump. The cost of that freedom is that you list the folders in apply order. The include array is the dependency gate, not the filesystem. -
Your object folders can be your migrations. A common pattern is to keep a folder of object definitions and a separate folder of migration/rollback scripts that duplicate those changes — two representations of the same change kept in sync by hand. Ratchet makes the second folder optional: point it at your object folders and the files in them are the migrations. One source of truth.
-
One file per object, accumulating its history. Because a file can hold multiple migration blocks, an object's whole change history can live in one place. This makes the model a hybrid:
- Tables are typically delta-based — the file accumulates blocks (
create, thenaddColumn, thenaddColumn), so the file is the object's history, not a snapshot of its current shape. - Views, procedures and functions are typically state-based — a single block with
"run": "onChange"that is re-applied whenever its SQL changes.
- Tables are typically delta-based — the file accumulates blocks (
-
Ratchet does not parse or check your SQL. It only parses the two things it must: the JSON migration header and the statement separators. Everything else is handed to the database, which is the authority on whether the SQL is valid. The tool's job is to try to apply your SQL in a known order and record what succeeded — not to understand it.
-
Roll-forward only — on purpose. Rollback scripts are often sold as a safety net, but that safety is partly an illusion. A rollback restores structure, not data: drop a column and the "undo" can add the column back, but the values are gone. Rollback SQL can also fail exactly like forward SQL — a bug, a lock, a timeout — and, because Ratchet doesn't parse SQL, a "down" script is no more verifiable than any other migration. An automated rollback would therefore imply a guarantee that doesn't actually exist. When a deployment goes sideways, the right response is human judgment, not a canned reverse script: sometimes it's a syntax error you fix in the migration and re-apply; sometimes an expert has to look at the failure and decide — hand-edit the database into a good state, or write new migrations that correct forward. There is no single guaranteed answer, so Ratchet doesn't pretend to offer one.
-
Ordering is by convention first, with explicit overrides. Because the tool doesn't understand your SQL, it can't infer dependencies. The default order is the include list, then alphabetical within a single folder — not a walk of child folders. If
dbocontainsTablesandForeignKeys, include those two paths (tables first). Recursingdbowould apply foreign keys in some implicit name order, often before the tables exist. Numbered prefixes (01_tables,02_views) have the same hole: the folder you need between them has nowhere to go. An explicit list does not. When folder order is not enough, a migration can declaredependsOn(see Dependencies) — ordering metadata you declare, not something inferred from the SQL.
Ratchet runs a single command per invocation. Prefer a subcommand (ratchet update); --command and Ratchet__Command are the same thing for flags and environment variables. Do not pass a subcommand and --command together. --help / -h prints usage and exits without connecting to the database. --version prints the assembly version (the <Version> in the project) and exits.
update— Apply pending migrations in resolved order. This is the normal deployment command. It runs SQL and records only what it applied (oronError: Mark). Checksum drift fails the run — seerepair.status— Print pending apply, pending baseline, needs repair, up to date, and filtered-out counts, with each identity listed. Read-only; no lock. Drift is listed under needs repair here and is a failure onupdate. New files count as pending apply only — pending baseline is leftover history with no hash.validate— Fail if the files cannot be parsed or ordered, or (when a database is configured) if any applied migration needsrepair. Pending apply and pending baseline do not fail. See Validate.dryrun— Likestatus, plus the SQL thatupdatewould run, written to a plan file. See Dry Run.baseline— Record migrations that have no history row as already applied, without running SQL. For adopting Ratchet on an existing schema. Does not overwrite an existing hash — that isrepair.repair— Update the stored hash of migrations that already have a history row whose SQL no longer matches. For after you fixed the database by hand. Does not insert missing rows — that isbaseline.
- Roll-forward only. There is no down script. Recovery is a human decision — see Design & Philosophy.
- Each migration is its own unit of work. Migrations are applied one at a time, each in its own transaction (unless
runInTransactionisfalse). There is no single transaction spanning the whole deployment. If migration 5 of 10 fails, migrations 1–4 remain applied and the process exits with a non-zero code. Re-running after fixing the problem will resume from the first unapplied migration. A failure to connect to the database after the configured retries also exits non-zero. onError: Skipis not a success. A skipped migration is logged, is not recorded as applied, does not consume an apply sequence number, and will be retried on the next run.onError: Markrecords the migration as applied (and sequences it) so it will not be retried. Theupdatesummary lists Applied, Skipped, and Marked separately, with each identity.- Idempotency matters only for migrations that can re-run mid-change. An already-applied
run: oncemigration is never re-run, so defensive guards likeCREATE TABLE IF NOT EXISTSare unnecessary for the common case — thatCREATE TABLEmigration runs exactly once. A migration in a transaction (the default) that fails simply rolls back and re-runs cleanly next time. Guards matter in two situations: (1) a migration withrunInTransaction: falsethat fails partway, since its earlier statements have already committed and the whole migration re-runs on the next attempt; and (2)run: onChange/run: alwaysmigrations, which re-execute by design. Write those so re-running is safe. - Migrations are identified by
fileName [title]and a hash of their SQL. Once arun: oncemigration has been applied, editing its SQL changes the hash and causesupdateto fail. This is intentional — it prevents silently altering history. The hash is sensitive to the SQL text, so even reformatting an already-applied migration will trip this check. If the database already matches the edited file, runrepairto accept the new hash.run: onChangeis the switch for objects that should re-apply when you edit them. - Writing history without SQL is never implicit.
updateonly records migrations it actually applied (oronError: Mark). Stamping existing schema isbaseline. Accepting a new hash for something already recorded isrepair. - Apply order is recorded globally. Each first-time apply (or
onError: Mark) gets the nextexecuted_sequenceacross all deployments — not a counter that restarts at 1 every run. Re-applying arun: onChange/run: alwaysmigration keeps its original sequence.
update, baseline, and repair hold a session-scoped lock so only one of them runs at a time. The lock is tied to the database connection, so it is released automatically if the process dies (crash, OOM, cancelled pipeline job) — no manual cleanup. Each provider uses its native mechanism:
- PostgreSQL: a session-level advisory lock (
pg_try_advisory_lock), scoped to the target database. - SQL Server: a session-scoped application lock (
sp_getapplock). - SQLite: an exclusive lock on the database file.
The __migration_lock table is an audit trail and allocates a deployment_id. Mutual exclusion is the session lock, so an abandoned finished_on IS NULL row from a killed run is harmless.
Migration files are parsed before the lock is taken, so a large set does not block other work while Ratchet reads SQL. After the lock, history is loaded once and used to resolve dependsOn and to build the apply / baseline / repair plan.
--maxLockWait is how long to wait for a contended lock (default 120 seconds). PostgreSQL and SQL Server honor that bound. SQLite is best-effort: the file lock can also block table setup, so a competing run may wait for the other to finish rather than time out exactly.
dryrun prints the same report as status (counts and identities) and writes the SQL that update would run to a plan file (--outputFile, default ratchet-plan.sql). A relative --outputFile is resolved from the process working directory, same as --migrations. The plan file header also lists pending-apply, pending-baseline, and needs-repair identities. It does not take the lock and applies nothing.
Use it as a review gate: generate the plan, publish it as an artifact, run update once someone has looked at it.
The plan is the same order and context filter as a real update. Each block is annotated with its identity and its run, runInTransaction, timeout, and onError settings. Pending baseline and needs-repair are listed in the header comments; their SQL is not written.
validate is a PR gate, not a dress rehearsal for update. It fails the process if the starting file or SQL headers cannot be parsed, includes are missing, titles collide, or dependsOn is invalid or cyclic. Pending apply and pending baseline do not fail — new migrations are expected. When a database is configured, a passing run prints the same identity listing as status.
Without --connectionString / --provider, only files are checked and no database is opened. With a connection string, checksums are compared to that database's __migration_history, and drift fails the run (the same condition that fails update). You do not need validate in front of update in a deploy job; update already stops before applying if those problems exist.
Both commands write __migration_history and run no SQL. They refuse to do each other's job.
Adopt Ratchet on an existing database — the schema is already there, history is empty (or some folders were never recorded):
baseline— insert history for in-context migrations that have no row (or a leftover null hash).executed_sequencestays null; Ratchet did not apply these.status— pending apply should be 0 (unless you also haverun: always/ pendingrun: onChangefiles).- Later, add a new migration block and
update— only the new block runs.
repair on an empty journal is a no-op. If you skip baseline, the next update will try to run CREATE TABLE against objects that already exist.
Unstick after a hand-fix — a deploy failed, someone fixed the database (and maybe the file), and you want the journal to match:
repair— overwrite hashes of already-recorded migrations whose SQL changed. Existingexecuted_sequenceis kept. Each id is logged with previous and current hash.- If a hotfix file was applied by hand and never recorded,
baselinethat file (it still has no row). update— continues from the first unapplied migration.
baseline will not clear checksum drift. update still fails until you repair.
Both commands take the deployment lock, honor --contexts, and can be re-run safely (a second run is a no-op once history matches).
Flags and environment variables set the same options. Env vars use the Ratchet__ prefix (and __ for nesting). The image is on Docker Hub (linux/amd64 and linux/arm64; docker pull selects the host arch, or pass --platform linux/arm64 / linux/amd64). Mount your SQL at /app/Migrations.
| Flag | Environment | Default |
|---|---|---|
--command |
Ratchet__Command |
(required) same as the subcommand: update, status, validate, dryrun, baseline, repair |
--migrations |
Ratchet__WorkingDirectory |
Migrations (in the container: /app/Migrations) |
--startingFile |
Ratchet__StartingFile |
ratchet.json in the working directory |
--provider |
Ratchet__DatabaseProvider |
postgres (postgres, mssql, or sqlite) |
--connectionString |
Ratchet__ConnectionString |
(required except file-only validate) |
--contexts |
Ratchet__Contexts |
comma-separated; empty means no extra context |
--maxLockWait |
Ratchet__LockWaitMaxSeconds |
120 |
--connectionAttempts |
Ratchet__ConnectionAttempts |
10 |
--connectionRetryDelay |
Ratchet__ConnectionRetryDelaySeconds |
5 |
--outputFile |
Ratchet__OutputFile |
ratchet-plan.sql |
--help, -h |
print usage and exit | |
--version |
print the version and exit | |
--logLevel |
Serilog__MinimumLevel__Default |
Information (Verbose, Debug, Information, Warning, Error, Fatal). Debug is the useful extra level: include walk, contexts, parse count, overlapping includes, lock wait. |
Relative --migrations and --outputFile paths are resolved from the process working directory.
Releases are the <Version> in src/Ratchet/Ratchet.csproj. A push to main tags that number when it is newer than the latest git tag; Docker and GitHub Release binaries are built from the tag. A commit that does not bump <Version> is not a release.
Each release image is tagged abeseler/ratchet:<version> (and latest). That tag is a multi-arch manifest: docker pull selects amd64 or arm64 for the host, or pass --platform linux/arm64 / linux/amd64. GitHub Release assets are self-contained single-file binaries — pick the RID for your OS and CPU: ratchet-linux-x64, ratchet-linux-arm64, ratchet-win-x64.exe, ratchet-win-arm64.exe, ratchet-osx-x64, ratchet-osx-arm64.
If you omit --startingFile, Ratchet looks for ratchet.json in the working directory.
The starting file is a JSON array of includes. Files and directories in include are classified by what exists on disk — a folder named v2.0 is a directory, not a file.
[
{
"include": [
"PreDeploy",
"ensure_exists.sql",
"Tables",
"Views",
"PostDeploy"
],
"contextFilter": [],
"contextRequired": false,
"errorIfMissingOrEmpty": true
},
{
"include": [
"SeedScripts"
],
"contextFilter": ["seed"],
"contextRequired": true,
"errorIfMissingOrEmpty": false
}
]include: Files or directories to include. Directories contribute only*.sqlfiles (case-insensitive); other files in the folder are skipped.contextFilter: Contexts that must be active for this include. If none of them are provided, the include is skipped.contextRequired: Iftrue, the include is skipped when no contexts are passed at all. Default isfalse.errorIfMissingOrEmpty: Iftrue(the default), a missing file or directory fails the run. An empty directory is not an error; an included SQL file with no migration blocks is.
Migrations run in include order. SQL files in a directory run in alphabetical order. Non-.sql files in that directory are skipped, so a README.md next to the scripts is harmless. Directories are not walked recursively: list each folder you want applied, in the order it should run (dbo/Tables, then dbo/ForeignKeys, not dbo). That is how you stay in charge of names and of “in between” stages — you insert a line in the array instead of renaming everything.
The same file listed more than once — twice in include, the same folder twice, or a file and the folder that contains it — is fine. The first parse wins; later copies are dropped. Context and errorIfMissingOrEmpty from that first listing are what count.
The layout I recommend is one folder per object type and one file per object, with those folders listed in dependency order. A copy-paste starter is in samples/object-folders (PreDeploy, Extensions, Schemas, Types, Sequences, Tables, ForeignKeys, Functions, Views, Procedures, Triggers, Grants, PostDeploy, plus a Seed include). A single all_migrations folder of versioned files is fine too. The tool does not care what you call them.
Migrations are SQL files under the working directory (Migrations by default). A file can hold one or more migrations, and a migration can hold one or more statements. Statements are separated by a dedicated line -- NewStatement (the space after -- is optional). A trailing note after the token is allowed and is not sent to the database.
A migration is a block of SQL preceded by a comment that starts with /* Migration followed by { or a newline, and ends with */. The body of that comment is JSON. A title-only header can be one line.
/* Migration { "title": "widget:createTable" } */
CREATE TABLE widget (
widget_id INT GENERATED ALWAYS AS IDENTITY,
description TEXT NOT NULL,
created_on_utc TIMESTAMP NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'),
CONSTRAINT pk_widget PRIMARY KEY (widget_id)
);
/* Migration { "title": "widget.last_modified_on:addColumn" } */
ALTER TABLE widget
ADD COLUMN last_modified_on_utc TIMESTAMP NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc');title: Required. Any string, unique within the file (case-insensitive:createandCREATEcollide).dependsOn: Migrations this one must run after. Each entry is a file path (after every block in that file) orfile#title(after that one block). See Dependencies.run: When this block will be executed. Defaultonce. See run.once: Run this SQL one time. After it is recorded, later deploys skip it. Editing the SQL is drift —repair, not a silent second run.onChange: Run again when the SQL hash changes. Views, procedures, functions.always: Run on everyupdate, even if the SQL has not changed. Cheap idempotent seeds. Status will list it as pending apply every run.never: Do not run this block. Hash drift is ignored.update,baseline, andrepairtreat it as absent. Status lists it under Ignored.
runInTransaction: Wrap the migration in a transaction. Defaulttrue.contextFilter: Contexts that must be active or this migration is skipped.contextRequired: Iftrue, skip when no contexts are passed. Defaultfalse.timeout: Statement timeout in seconds. Default30.onError: What to do if this migration fails. DefaultFail.Fail: Stop and exit non-zero.Skip: Log the error, do not record the migration as applied, continue. It will be retried next run.Mark: Log the error but record the migration as applied so it will not be retried. Use with caution — this hides a real failure and can lead to schema drift.
Because a file can contain multiple migrations, one file per object means that object's full change history lives in one place.
run says how many times, and on what trigger, this block’s SQL will execute. once is the default — that is the whole point of a journal: run this SQL, record it, never run it again.
once — this block runs one time. The first successful update (or Mark) executes it and writes history. Every later update skips it. If you edit the SQL, the hash no longer matches and update fails. That is intentional. repair accepts the new hash without running the SQL again. It does not mean “run once more after the first apply.”
onChange — re-apply when you change the file. Ratchet compares the current SQL hash to __migration_history. If it matches, the block is skipped. If you edit the view (or procedure, or function), the hash changes and the next update runs it again. That is how object folders stay the source of truth without paying the cost on every deploy. Write the SQL so a second run is safe (CREATE OR REPLACE, CREATE OR ALTER VIEW, …).
always — re-apply on every deploy, hash or not. You are telling Ratchet: this SQL is part of the deploy, every time. A lookup-table upsert that must overwrite in-database edits is the usual case. Status will list it under pending apply on every run. Each such block is more statements on every environment, every pipeline, even when nothing changed. Keep the SQL cheap and idempotent.
never — do not run this block. The SQL stays in the file. update does not execute it, baseline does not stamp it, and a changed hash is not drift. dependsOn treats it like a missing file unless that block was already applied. Use this to park a migration without deleting it.
| What you want | What to set |
|---|---|
| A table change that should happen once | once (or omit run) |
| View / proc / function that should match the file after you edit it | onChange |
| Reference data that may have been changed in the database and the file must win every deploy | always |
| Keep the SQL in the file but do not run it | never |
An already-applied once block whose SQL you reformatted |
repair, not a different run |
Do not set always to get past a checksum failure. That failure means history and the file disagree; repair accepts the new hash. always would also re-run the SQL on every later deploy.
The old runAlways / runOnChange booleans are rejected. Use run.
By default, migrations run in include order, then alphabetically within a directory. For most cases, grouping folders by dependency in the starting file is enough (Tables before Views).
When that is not enough — or when you want the requirement explicit and reorder-safe — declare dependsOn.
File is the default. A path with no # means "after all in-context migrations in that file":
/* Migration
{
"title": "fk_orders_customer",
"dependsOn": ["Tables/orders.sql", "Tables/customers.sql"]
}
*/
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (customer_id);Pin a single block with file#title when you only need one migration in that file (for example the CREATE TABLE, not later ALTERs):
/* Migration
{
"title": "vw_orders:create",
"run": "onChange",
"dependsOn": ["Tables/orders.sql#orders:createTable"]
}
*/
CREATE OR REPLACE VIEW vw_orders AS
SELECT id, amount FROM orders;- File references are the default. Depending on a file means after every in-context block in that file. You never have to edit the file you depend on.
file#titleis the escape hatch. It orders after that one block only. Title matching is case-insensitive. The file part is required — a bare#titleis rejected.- Ordering stays convention-first.
dependsOnonly adds constraints on top of include order; a stable topological sort keeps everything else where it was. Declaring nothing behaves exactly as before. - Paths are normalized.
\or/and a leading slash do not matter. Matching is case-insensitive. - Invalid references fail before any SQL runs. A bad ref or a cycle stops the deployment; the cycle path is printed.
Because Ratchet does not parse your SQL, it cannot infer dependencies — dependsOn is ordering metadata you opt into where it matters.
Ratchet does not track deleted migrations, and dependsOn is designed to stay consistent with that:
- The referenced file or block exists → it is ordered before the dependent. Removing a file that nothing references simply changes the order of what remains; because already-applied migrations are skipped regardless of position, this is safe and produces no error.
- A file reference is missing but that file was already applied → the reference is treated as satisfied. You can delete an old migration file even if something still declares
dependsOnon it; since it already ran, the ordering constraint is already met. Any applied block from that file counts. - A
file#titlereference is missing but that specific block was already applied → satisfied. A different title from the same file being applied is not enough — title references are exact (case-insensitive). - The referenced file or block is missing and was never applied → hard error. Almost always a typo or a genuinely missing dependency. An empty
#fragment (orders.sql#) is also a hard error. - The reference matches more than one file, or more than one title in that file (case-insensitively) → hard error.
- The referenced migrations are all excluded by the active context → hard error. Depending on something that will not run in this context is a misconfiguration.
- The dependencies form a cycle → hard error, with the cycle path printed.
dryrun writes the fully resolved order to its plan file. Folder order in the starting file is the dependency gate; dependsOn is only for the cases that list cannot express.
Deliberate tradeoffs, so they are not surprises:
- No rollback. Recovery is a human decision. See Design & Philosophy.
- Atomicity is per migration, not per deployment. A failure part-way through leaves earlier migrations applied; re-running resumes from the first unapplied one. See Deployment Semantics.
- Statement splitting is textual. Statements are separated by a dedicated line
-- NewStatement(space after--optional; a trailing note is allowed). A line that is only that token, inside a string literal or a real comment, would split incorrectly. Keep the separator on its own dedicated line. - Hashing is for change detection, not security. Applied migrations are fingerprinted with MD5; it is not a cryptographic guarantee.
- Three databases. PostgreSQL, SQL Server, and SQLite.