Migrations ​
Migrations change the database's structure in versioned, ordered steps. Each one is a TypeScript file in a migrations directory, written by hand with a small set of operations, and the runner applies the pending ones and records what it applied.
// migrations/20261012093000_create_users.ts
export const createUsers = defineMigration({
up(m) {
m.createTable("users", (table) => {
table.id(); // "id" bigserial primary key
table.text("email").notNull();
table.text("name"); // columns allow null unless notNull()
table.integer("age");
table.enumText("role", ["admin", "member"]).notNull().default("member"); // text + check
table.jsonb("settings");
table.references("org_id", "orgs", { type: "uuid", onDelete: "cascade" });
table.timestamps(); // created_at, updated_at
});
m.createIndex("users", ["email"], { unique: true }); // users_email_key
},
down(m) {
m.dropTable("users"); // its indexes go with it
},
});The file name holds the version and the name: 20261012093000_create_users.ts is version 20261012093000 (a UTC timestamp, YYYYMMDDHHMMSS) named create_users. egon migrate create create_users writes such a file, with an empty up and down for you to fill in (CLI reference).
If you come from Prisma Migrate or drizzle-kit: there is no schema diffing. You write the change you want, as with Sequelize's or Knex's migrations; the schema classes and the migrations are kept in step by you, not inferred from each other.
Running migrations ​
From the command line, with @egon-orm/cli installed (npm install -D @egon-orm/cli, then npx egon …) and the configuration in egon.config.ts:
egon migrate create add_age # write migrations/<version>_add_age.ts
egon migrate # apply every pending migration
egon migrate --dry-run # print the SQL, run nothing
egon rollback # roll back the latest one
egon migrations # list them: up, down or missing (claimed: see SQLite below)From code, with createMigrator from egon-orm/migration:
const adapter = postgres({ connectionString: process.env.DATABASE_URL });
const migrator = createMigrator({
adapter,
directory: "./migrations",
log: (event: MigrationEvent) => {
if (event.kind === "migrated")
process.stdout.write(`${event.version} ${event.name}: ${event.durationMs} ms\n`);
},
});
await migrator.up(); // every pending migration
await migrator.up({ to: 20261013080000 }); // up to and including a version
await migrator.down(); // the latest one
await migrator.down({ step: 2 });
const plan = await migrator.up({ dryRun: true }); // the SQL, nothing run
const rows = await migrator.status(); // [{ version, name, state: "up" | "down" | "missing" }]
await adapter.close();The migrator takes the adapter, the same one you give createRepo (or one of its own).
Up and down ​
Every migration has two functions, and both are required:
up(m)makes the change;down(m)undoes it. Rolling back always runsdown.
When there is nothing to undo, as for a backfill, down is an empty function. Write a // noop comment in it, so a reader knows it is empty on purpose:
// migrations/20261013080000_backfill_roles.ts
export const backfillRoles = defineMigration({
up(m) {
m.execute(fragment`update users set role = ${"member"} where role is null`);
},
down() {
// noop: the old rows had no role, so there is nothing to put back
},
});A migration without up or without down is a type error. Loading a file that lacks one (for example a JavaScript file, or a cast) fails with a MigrationError that names the file and the missing function. Nothing runs then: not egon migrate, egon rollback or egon migrations.
down lists the undoing operations in the reverse order of up: drop what up created, and recreate what it dropped. m.dropTable, table.remove and m.dropConstraint take only names. So to bring back what up removed, down defines it again: the column's type, the table's columns or the constraint's check.
Operations ​
The createTable and alterTable callbacks receive a table builder. These pages call it table rather than t, so it does not shadow the t imported from egon-orm.
| Operation | Options |
|---|---|
m.createTable(name, (table) => …, options?) | schema, ifNotExists, primaryKey: false |
m.alterTable(name, (table) => …, options?) | schema |
m.dropTable(name, options?) | schema, ifExists, cascade |
m.renameTable(from, to, options?) | schema |
m.createIndex(table, columns, options?) | unique, name, where, using, include, concurrently, schema, ifNotExists |
m.dropIndex(table, columns, options?) | unique, name (these and the columns name the index, as in createIndex), concurrently, schema, ifExists |
m.createConstraint(table, name, options) | check or exclude (a fragment), using, schema |
m.dropConstraint(table, name, options?) | schema, ifExists |
m.execute(sql) | a fragment; its interpolations are bound parameters |
m.createSchema(name) / m.dropSchema(name) | ifNotExists / ifExists, cascade (a database schema) |
m.createExtension(name) / m.dropExtension(name) | ifNotExists, schema / ifExists |
A table must have a primary key: table.id(), or .primaryKey() on its key columns (several make a composite key). Pass { primaryKey: false } for a table that has none on purpose.
Inside alterTable, the column methods add columns, and modify(name, type, { notNull, default }), remove(name) and rename(from, to) change existing ones:
export const profileFields = defineMigration({
up(m) {
m.alterTable("users", (table) => {
table.text("nickname"); // add
table.modify("age", "bigint"); // change the type
table.remove("legacy_flag"); // drop
table.rename("name", "full_name");
});
},
down(m) {
m.alterTable("users", (table) => {
table.rename("full_name", "name");
table.boolean("legacy_flag"); // down says what the dropped column was
table.modify("age", "integer");
table.remove("nickname");
});
},
});Columns ​
The column methods are named after the column types (DbType): integer, bigint, float, decimal, boolean, text (also string), uuid, binary, date, time, timestamp, timestamptz, json, jsonb, array(name, of) and custom(name, sql). table.add(name, type) takes any type, such as { kind: "decimal", precision: 10, scale: 2 }.
The helpers:
| Helper | Column |
|---|---|
table.id() | "id" bigserial primary key; table.id("id", { type: "integer" }) for serial (the first argument is the column name) |
table.references(column, target, options?) | a bigint (or type) with a foreign key to the table target (column, onDelete, onUpdate, schema, name) |
table.enumText(column, values) | text with a check constraint allowing only values |
table.timestamps() | created_at and updated_at, timestamptz not null default now() |
Each returns a column builder: .notNull(), .nullable(), .default(value), .primaryKey(), .unique().
A column default is a constant (a string, number, bigint, boolean, null, or for json and jsonb an object or array) or an expression as a fragment, .default(fragment`now()`). DDL takes no bound parameters, so a constant is written into the statement as a SQL literal: strings with their quotes doubled (on Postgres as an E'…' string with doubled backslashes when they hold a backslash), numbers only when finite. Fragments in DDL (a default, an index predicate, a constraint) may interpolate identifiers with fragment.ident(), but not values.
Index and constraint names ​
Migrations name what they create the way the changeset constraint functions expect, so a database error becomes a changeset error without either side naming anything:
| Created by | Default name | Matched by default by |
|---|---|---|
m.createIndex(table, cols, { unique: true }), .unique() | <table>_<columns>_key | uniqueConstraint(fields) |
table.references(column, …) | <table>_<column>_fkey | foreignKeyConstraint, assocConstraint, noAssocConstraint |
table.enumText(column, …) | <table>_<column>_check | checkConstraint(field, { name }) |
m.createIndex(table, cols) | <table>_<columns>_index | (not a constraint) |
class User extends Schema("users", {
id: t.bigint({ mode: "number" }).primaryKey().dbGenerated(),
email: t.string(),
}) {
static changeset(user: User | typeof User, params: unknown) {
// Looks for users_email_key: the name createIndex("users", ["email"], { unique: true }) gave.
return cast(user, params, ["email"]).uniqueConstraint("email");
}
}
export async function signUp(repo: ReturnType<typeof createRepo>, email: string) {
const result = await repo.insert(User.changeset(User, { email }));
if (!result.success) return result.error.errorMessages(); // { email: ["has already been taken"] }
return result.data;
}A name longer than the database keeps (63 bytes on Postgres) is shortened the way Postgres shortens the names it makes itself, and the changeset defaults match the shortened name. Pass name to choose your own; then give the same name to the changeset function.
export const searchIndexes = defineMigration({
transaction: false, // CONCURRENTLY cannot run inside a transaction
up(m) {
m.createIndex("posts", ["author_id", "created_at"]); // posts_author_id_created_at_index
m.createIndex("posts", ["tags"], { using: "gin", concurrently: true });
m.createIndex("users", ["email"], {
unique: true,
where: fragment`deleted_at is null`,
name: "users_live_email_key",
});
m.createIndex("users", [fragment`lower(${fragment.ident("email")})`], {
name: "users_lower_email_index",
});
},
down(m) {
// dropIndex takes the table, the columns and unique or name, which name the index.
m.dropIndex("users", [fragment`lower(${fragment.ident("email")})`], { name: "users_lower_email_index" });
m.dropIndex("users", ["email"], { name: "users_live_email_key" });
m.dropIndex("posts", ["tags"], { concurrently: true });
m.dropIndex("posts", ["author_id", "created_at"]);
},
});export const bookingRules = defineMigration({
up(m) {
m.createExtension("btree_gist");
m.createConstraint("bookings", "bookings_nights_check", { check: fragment`nights > 0` });
m.createConstraint("bookings", "bookings_room_during_excl", {
exclude: fragment`room with =, during with &&`,
using: "gist",
});
},
down(m) {
m.dropConstraint("bookings", "bookings_room_during_excl"); // by name
m.dropConstraint("bookings", "bookings_nights_check");
m.dropExtension("btree_gist");
},
});How the runner works ​
Order. Pending migrations run oldest version first. A pending migration older than the latest applied one (merged from another branch) is applied too, with a warning.
Transactions. Each migration runs in its own transaction where the database has transactional DDL (Postgres and SQLite do); if it fails, it is rolled back and the run stops. With
transaction: falseit runs without one, and a failure can leave it partly applied. MySQL commits after every DDL statement, so there a migration that fails midway is always left partly applied and is not recorded: fix the database by hand (or write migrations of one DDL statement each) before running again.The lock. The runner holds a lock for the whole run (on Postgres a session-level advisory lock keyed by the tracking table, on MySQL
GET_LOCKwith a name derived from it), so two deploys that start at once take turns: the second waits, then finds nothing left to do.lockTimeoutMsmakes it give up instead.The lock on SQLite. SQLite has no lock outside a transaction, so the runner holds a write transaction (
begin immediate) for the whole run, and each migration is a savepoint inside it. While it runs, every other writer to the file waits until the run ends: another runner, in this process or another, tries again every 25 ms untillockTimeoutMs, and other writes to the file fail with a retryableSQLITE_BUSYonce theirbusy_timeoutruns out. Keep migration runs out of busy periods.transaction: falseon SQLite. Some statements cannot run in a transaction at all (vacuum), andpragma foreign_keys = offis ignored inside one, which the table rebuild needs (copy, drop, rename: the way to change a column on SQLite). Such a migration runs outside the lock:- the runner records a claim (a row with version
-1in the tracking table) and commits; - it runs the migration with no transaction;
- it takes the lock again, removes the claim and records the version.
Another runner that gets the lock while the claim is there gives it back and waits, so two runners never both run it.
If the process dies in step 2, the claim stays behind:
egon migrations(status()) lists it asclaimed, version-1;- a later run warns once (a
warningevent, whichegon migrateprints), then waits for the claim to go; - with
lockTimeoutMs, it gives up instead.
Check what the migration did, then delete the row with version
-1; a waiting run then carries on.After such a migration, whether it succeeded or failed, the runner:
- rolls back a transaction the migration left open (a
beginwithout acommitis a failure); - puts
foreign_keysback as it was before, since the adapter's one handle is shared with the application.
A failure is reported as "failed inside a transaction of its own, which was rolled back" when the migration's own transaction was undone, and otherwise as "ran without a transaction": statements before the failing one stay applied.
A table rebuild follows SQLite's own order:
- foreign keys off, outside any transaction;
- the rebuild and its check inside
begin … commit; - foreign keys on.
A row left without its parent fails the check, and the whole rebuild is rolled back:
ts// migrations/20261101090000_require_post_title.ts (SQLite) export const requirePostTitle = defineMigration({ // SQLite's own order: foreign keys off outside a transaction (the pragma does nothing // inside one), then the rebuild and its check in a transaction, so that a row left // without its parent rolls the whole rebuild back. transaction: false, up(m) { m.execute(fragment`pragma foreign_keys = off`); m.execute(fragment`begin`); m.execute(fragment`create table posts_new (id integer primary key, title text not null default '')`); m.execute(fragment`insert into posts_new (id, title) select id, coalesce(title, '') from posts`); m.execute(fragment`drop table posts`); m.execute(fragment`alter table posts_new rename to posts`); // pragma foreign_key_check lists the rows that break a foreign key: counting them into a // column that must be 0 fails the statement, and the runner rolls the transaction back. m.execute(fragment`create temp table fk_violations (n integer check (n = 0))`); m.execute(fragment`insert into fk_violations select count(*) from pragma_foreign_key_check`); m.execute(fragment`drop table fk_violations`); m.execute(fragment`commit`); // The adapter's one handle keeps the pragma: the runner also puts it back, even on failure. m.execute(fragment`pragma foreign_keys = on`); }, down() { // noop: the stricter column can stay when this migration is rolled back }, });- the runner records a claim (a row with version
Tracking. Applied versions are rows of
schema_migrations (version bigint primary key, created_at timestamp(0)), created by the firstupordownthat is not a dry run (statusand dry runs never create it), withcreated_atin UTC.Missing files. A recorded version with no migration file shows as
missing. Rolling back past it is refused until the file is restored.Dry run.
up({ dryRun: true })anddown({ dryRun: true })return the SQL of each migration and change nothing, not even the tracking table.
Zero-downtime changes ​
- Build indexes on large tables with
concurrently: true. It cannot run inside a transaction, so the migration needstransaction: false(the runner refuses otherwise). Keep such a migration to that one operation: without a transaction, a failure leaves the earlier statements applied. - Add a column as nullable (or with a default), backfill it in a migration of its own (its
downa// noop), and addnotNullin a later one. - Rename in steps: add the new column, write to both, then drop the old one.
Running in CI and deploys ​
Run egon migrate before the new code starts. It exits with 0 on success and 1 on a failure, prints a line per migration with its duration, and with --json prints one JSON object instead. Several instances may run it at once; the lock serialises them.
Loading migration files ​
Migration files are loaded with import(). TypeScript files need a process that can load TypeScript: Node 22.18 or later strips types by itself; on 22.12 to 22.17 turn it on with NODE_OPTIONS=--experimental-strip-types npx egon … (or node --experimental-strip-types for a script of your own). Node's type stripping does not map ./db.js to ./db.ts, so a migration that imports your own files the way TypeScript writes them needs tsx instead (NODE_OPTIONS="--import tsx" npx egon …, or node --import tsx), or Bun. Without a loader, the error says what to install. A file exports its migration as migration (export const migration = defineMigration(…), as the file that egon migrate create writes does); the default export works too, and a file that exports both runs migration.
For bundlers and deployments without a file system, pass the migrations as a list:
const migrator = createMigrator({
adapter: postgres({ connectionString: process.env.DATABASE_URL }),
migrations: [
{ version: 20261012093000, name: "create_users", migration: createUsers },
{ version: 20261013080000, name: "backfill_roles", migration: backfillRoles },
],
});Adopting an existing database ​
To start using migrations on a database that already has its tables, write a first migration that describes them (egon migrate create initial gives you the empty file), and record it as applied without running it. On a database that has never been migrated the tracking table does not exist yet, so create it first, as the runner would:
// Once, by hand or in a script: record 20261001000000_initial as applied without running it.
await adapter.execute(
"create table if not exists schema_migrations (version bigint primary key, created_at timestamp(0))",
[],
);
await adapter.execute(
"insert into schema_migrations (version, created_at) values ($1, now() at time zone 'utc')",
["20261001000000"],
);If the database already has a schema_migrations table from another tool, with a version bigint column, the runner reads it as it is. Its versions count as applied; name its timestamp column if it is not created_at:
const migrator = createMigrator({
adapter: postgres({ connectionString: process.env.DATABASE_URL }),
directory: "./migrations",
table: "schema_migrations", // the existing tracking table
timestampColumn: "inserted_at", // its timestamp column, if not created_at
});Good to know
- Columns allow
NULLunless you call.notNull(), as in SQL. (Schema fields are the other way round: non-null unless.nullable().) - The column method
timestampis a timestamp without a time zone andtimestamptzone with it; the schema fieldt.timestamp()is stored astimestamptz, andt.json()asjsonb. - Nothing is inferred from your schema classes when migrations run: a migration describes the database, and the schema classes describe your code.
- Rolling back runs
downas you wrote it: adownthat drops a table drops its rows too.

