Skip to content

Deploying migrations

Getting schema changes into production safely is a different problem from expressing them. The migrations guide covers how LiteORM produces DDL — AutoMigrate for additive sync, GenerateMigration for reviewable SQL, and the liteorm.org/migrate runner that applies it. This guide covers the operational half: which tool to trust in production, how to sequence a change so it doesn’t break a running app, and how to recover when a migration fails part-way.

orm.AutoMigrate is convenient because it’s implicit — it reads your model and brings the table into agreement, additively. That’s exactly right in development and for low-risk additive rollouts. In production it has two limits worth naming: it applies whatever the current binary’s models imply, with no artifact to review before it runs, and it only ever adds — it will never drop, retype, or rename, because those are the changes that can lose data. The reviewable migrate runner is the opposite: every change is a versioned SQL file you read, edit, and check into source control, applied in order and tracked in a ledger.

I want… Use
add a table or column in dev, fast orm.AutoMigrate / AutoMigrateAll
a reviewed, versioned, ordered change in prod the migrate runner
to drop / retype / rename a column orm.GenerateMigration → review → runner
a change I can roll back the runner (with a non-empty Down)

A pragmatic split many teams settle on: let AutoMigrate own the purely additive rollout (a new nullable column, a new table) where reviewing a one-line ADD COLUMN buys little, and route everything destructive or ambiguous through the runner. The two connect cleanly — orm.GenerateMigration[T] diffs a model against the live database and hands you the up/down SQL, and migrate.WritePair drops that straight into the runner’s on-disk format. See migrations for both.

The question that decides how you sequence a migration: can it run while the old code is still serving traffic? An online change is backward-compatible — the running application keeps working across it. An offline change is not, and needs the app stopped, or a multi-step expand/contract dance, to avoid errors during the window when schema and code disagree.

Change Online? Note
add a nullable column yes old code ignores it
add a table or index yes (usually) index builds can lock — check your backend
add a NOT NULL column with a default usually fine if the backend backfills; else two steps
drop a column no old code may still SELECT it
rename a column no nothing sees both names at once
retype / narrow a column no can fail on existing rows

The safe pattern for an offline change is expand then contract, spread across deploys: first add the new shape and make the app write to both old and new (an online change), deploy that, backfill, then in a later migration remove the old shape once no running code depends on it. Each individual migration stays online even though the net change is not. GenerateMigration leans the same way on purpose: it emits additive parts live and comments out the destructive DROP / ALTER ... TYPE, so you opt into the dangerous step deliberately, on its own schedule, after the expand phase has shipped.

Schema and code are deployed by different mechanisms, so their order matters. The rule follows directly from the online/offline split:

  • Additive, online change: migrate before the code that uses it deploys. The new column exists and sits unused until the new binary rolls out — harmless.
  • Destructive, offline change: deploy the code that stops using the old shape first, let it fully roll out, then migrate to remove it. Migrating first would break the still-running old code.

So an additive change migrates ahead of the deploy; a destructive change migrates behind it. Fold the runner into your deploy pipeline accordingly — a pre-deploy step for the additive case, a post-deploy step (after the rollout is confirmed) for the contract half of an expand/contract. The runner is safe to invoke every deploy: Up skips already-applied versions and only runs what’s pending, so a no-op deploy costs one ledger read.

m := migrate.New(sess) // ledger table: schema_migrations
n, err := m.Up(ctx, migs) // apply all pending in version order; n = how many ran
if err != nil {
return err
}
log.Printf("applied %d migration(s)", n)

UpTo(ctx, migs, version) pins a deploy to a specific version when you want to advance in controlled increments rather than to head.

The runner tracks state in a single-row (version, dirty) ledger. Before it runs a step it marks the ledger dirty at that version; after the step’s statements all succeed it clears the flag. If a migration fails part-way — a statement errors, the process is killed mid-run — the ledger is left dirty, and the next Up, UpTo, or Down refuses with a *migrate.DirtyError rather than layering more changes onto an unknown state.

Recovery is deliberate and manual, because only you know what the half-applied migration actually did to the database:

n, err := m.Up(ctx, migs)
var de *migrate.DirtyError
if errors.As(err, &de) {
// The database is in an unknown state at de.Version.
// 1. Inspect it. Decide whether that migration is effectively applied or not.
// 2. Fix the schema by hand so it matches one clean version.
// 3. Declare that version — this clears the dirty flag:
if err := m.Force(ctx, de.Version); err != nil { // or the previous version
return err
}
// Now Up can proceed again.
}

Force sets the version and clears dirty without running any SQL — it records “trust me, the database is at version N.” Point it at de.Version if you finished the migration’s work by hand, or at the previous version if you undid it. Getting this right is why destructive steps belong in reviewed migrations you understand, not implicit sync. To check the ledger without changing anything, m.Version(ctx) returns the current version and dirty flag, and m.Status(ctx, migs) reports which migrations are applied.

Down rolls back the most recent step (or DownTo(ctx, migs, target) unwinds down to a version), running the migration’s Down SQL. A rollback is only as safe as that Down script — and some forward changes have no honest inverse. A migration with an empty Down is marked irreversible: Down refuses it with an error rather than silently doing nothing, so an irreversible step can’t be rolled back by accident.

Two cautions worth internalizing: a Down that drops a column added by its Up will discard any data written to that column since — reversibility of schema is not reversibility of data. And in production, rolling forward to a fix is usually safer than rolling back, because Down runs against data the old schema may not have anticipated. Treat Down as a development and staging convenience and a genuine-emergency escape hatch, and prefer a new forward migration for routine corrections. Where a Down genuinely can’t restore prior state, leave it empty on purpose — an explicit irreversible step is more honest than a lossy one that looks reversible.