Migrations
Turbine migrations are plain SQL files with -- UP and -- DOWN sections, applied in order and tracked in a _turbine_migrations table.
The migration CLI drives PostgreSQL only. On other engines, define the schema with defineSchema and manage DDL yourself; see Database Engines.
Commands#
npx turbine migrate create add_users_table # blank file
npx turbine migrate create add_email_index --auto # SQL from the schema diff
npx turbine migrate up # apply pending (interactive)
npx turbine migrate deploy # apply pending (CI, no prompts)
npx turbine migrate down # roll back the last one
npx turbine migrate status # applied, pending and drifted| Command | What it does |
|---|---|
migrate create <name> | Writes <UTC timestamp>_<name>.sql to migrationsDir. |
migrate create <name> --auto | Fills the file from the diff between your defineSchema file and the live database. Review it before committing. |
migrate create <name> --from-diff | Same SQL as --auto, with destructive statements and irreversible DOWN steps flagged in comments. |
migrate create <name> --recipe backfill | Scaffolds the expand/contract pattern. |
migrate up | Applies pending migrations in timestamp order. Asks before anything destructive. |
migrate deploy | Applies pending migrations without prompts. For CI and deploy hooks. |
migrate down | Runs the -- DOWN section of the last applied migration. |
migrate status | Lists applied and pending migrations, and flags modified files. |
| Flag | Applies to | Effect |
|---|---|---|
--step <n> | up, down | Apply or roll back at most n migrations. |
--dry-run | up, deploy, down | Print what would run without running it. |
--allow-destructive | up, down | Skip the destructive confirmation. |
--allow-drift | up, deploy, down | Run even though an applied file has changed. |
--wait[=<seconds>] | deploy | Wait for a concurrent run's lock, 300 seconds by default. |
--allow-pooler | up, deploy, down | Allow a pooler connection string (see below). |
--allow-pending | create --auto, --from-diff | Diff even though unapplied migration files exist. |
--exit-code | status | Exit 2 on drift. See Checking for drift in CI. |
--auto and --from-diff refuse while unapplied migration files exist, because the diff would repeat them. Run migrate up first, or pass --allow-pending when the pending files do not touch the schema.
Migration files#
-- 20260409143022_add_users_table.sql
-- UP
CREATE TABLE "users" (
"id" SERIAL PRIMARY KEY,
"email" TEXT UNIQUE NOT NULL
);
-- DOWN
DROP TABLE "users";The runner reads the 14-digit prefix, so files stamped in local time still sort correctly.
What the runner guarantees#
- One transaction per migration. If a migration fails, it rolls back and is not recorded.
- Stop on first error. Earlier migrations in the batch stay applied. Fix the failing file and run again.
- Checksums. Each applied file's SHA-256 is stored. If an applied file changes,
statusreports drift anduprefuses to run until you restore it or pass--allow-drift. - One runner at a time. An advisory lock makes a second concurrent
upfail withMigrationError(E006).deploy --waitwaits for the lock instead, so two deploys started together both succeed. - Direct connections only. A transaction pooler can release the advisory lock on a different backend, so
up,deployanddownrefuse apoolerorpgbouncerhost name, or port 6543 or 6432. Use the direct endpoint, or--allow-poolerfor a session-mode pooler. - Your configured schema. With
schemaset inturbine.config.ts, migrations run with that schema first on thesearch_path, so unqualified DDL and_turbine_migrationsland there. A schema that does not exist fails withMigrationError(E006).
Do not write BEGIN, COMMIT or ROLLBACK in a migration. The runner refuses a file containing them, because an embedded COMMIT would end its wrapping transaction partway through. ROLLBACK TO SAVEPOINT is allowed.
No-transaction migrations#
CREATE INDEX CONCURRENTLY cannot run inside a transaction. Add -- turbine:no-transaction to the file header, and the runner applies the file one statement at a time with no wrapping transaction:
-- Migration: add_comments_post_id_index
-- turbine:no-transaction
-- UP
DROP INDEX CONCURRENTLY IF EXISTS "idx_comments_post_id";
CREATE INDEX CONCURRENTLY "idx_comments_post_id" ON "comments" ("post_id");
-- DOWN
DROP INDEX CONCURRENTLY IF EXISTS "idx_comments_post_id";A failure partway leaves earlier statements applied and the migration unrecorded, so a rerun executes the whole file again. Write every statement to be safe to rerun.
For a concurrent index build, drop first instead of using IF NOT EXISTS. A failed build leaves an invalid index under that name, which IF NOT EXISTS would skip. turbine doctor --fix writes this shape.
Out-of-order migrations#
Any pending migration applies, even if its timestamp is older than one already applied, as when two branches each add a migration. The runner prints a warning and applies it. Enforce linear history in review if you need it.
Rolling back#
migrate down runs the -- DOWN section. Undoing an additive change is destructive (the DOWN for an added column drops it), so it asks for confirmation. In an unattended script, pass --allow-destructive:
npx turbine migrate down --allow-destructiveWhat --auto can and cannot do#
--auto compares your schema file with the live database and writes the obvious DDL. It knows nothing about intent:
- Type changes use a plain cast (
ALTER COLUMN ... TYPE <new> USING col::<new>), which fails on rows the cast cannot convert. Use the two-phase recipe instead. - Lossless widenings are not destructive.
inttobigint, a longervarchar, or morenumericprecision apply without--allow-destructive. The file marks each with a-- turbine:wideningcomment. - Renames look like a drop plus an add. The data does not move. Write
ALTER TABLE ... RENAME COLUMNby hand. SET NOT NULLhas no backfill, so it fails on a column holdingNULL. Backfill first.- A new
NOT NULLcolumn without a default gets a warning pointing at--recipe backfill. - Enum values can be added but not reordered. A reordered or removed label produces a warning, not a statement.
- A column missing from the schema becomes a
DROP COLUMN, flagged as destructive. A table missing from the schema is listed and never dropped; write that by hand.
When the diff skips something it does not compare (sequence-backed defaults, some indexes and constraints), push and --auto say so instead of reporting "in sync".
Changing a column type with existing data#
When a type change needs a real conversion, add a new column, backfill it, then swap. Every step is reversible until the final drop.
Expand. Add the column as nullable, then backfill in batches, repeating the UPDATE until it changes zero rows:
ALTER TABLE "orders" ADD COLUMN "total_cents" BIGINT;
UPDATE "orders"
SET "total_cents" = ("total" * 100)::BIGINT
WHERE "id" IN (
SELECT "id" FROM "orders" WHERE "total_cents" IS NULL LIMIT 5000
);Verify before tightening:
SELECT count(*) FROM "orders" WHERE "total_cents" IS NULL; -- expect 0Contract. In one migration, enforce NOT NULL and swap the names. The runner's transaction makes both renames atomic:
ALTER TABLE "orders" ALTER COLUMN "total_cents" SET NOT NULL;
ALTER TABLE "orders" RENAME COLUMN "total" TO "total_old";
ALTER TABLE "orders" RENAME COLUMN "total_cents" TO "total";Renames count as destructive, so migrate up asks before running this one.
Drop the old column later, in a separate migration, once nothing reads it:
ALTER TABLE "orders" DROP COLUMN "total_old";migrate create <name> --recipe backfill writes this skeleton with placeholders for the table, columns and conversion. Fill in every placeholder before applying.
Checking for drift in CI#
# The database differs from the schema file
npx turbine push --dry-run --exit-code
# A migration is pending, modified, missing or misnamed
npx turbine migrate status --exit-codeBoth exit 2 on drift and 0 when everything matches. push also fails on a warning it cannot apply, such as a removed enum label.
A named CHECK that differs only because PostgreSQL rewrote the expression is printed but does not fail the gate.
The destructive gate#
Every command scans for statements that destroy data: drops, TRUNCATE, DELETE, an UPDATE without a restricting WHERE, column type changes, renames and similar. Matches inside comments and string literals are ignored.
| Command | On a destructive statement |
|---|---|
migrate up, migrate down | Stops and asks you to type destroy my data, then yes. --allow-destructive skips the prompt. |
push | Same prompt. --allow-destructive skips it. |
migrate deploy | Prints a notice and proceeds. Review happens when the migration is written. |
Declining the prompt runs nothing.
See also#
- Schema:
defineSchema,pushandschemaDiff(). - CLI: every command and flag.
- Typed Errors: including
MigrationError(E006).