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.
AutoMigrate or the runner?
Section titled “AutoMigrate or the runner?”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.
Online vs offline changes
Section titled “Online vs offline changes”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.
Ordering migrations with app deploys
Section titled “Ordering migrations with app deploys”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_migrationsn, err := m.Up(ctx, migs) // apply all pending in version order; n = how many ranif 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 dirty flag and recovery
Section titled “The dirty flag and recovery”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.DirtyErrorif 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.
Rollback safety
Section titled “Rollback safety”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.
See also
Section titled “See also”- Migrations —
AutoMigrate,GenerateMigration,Diff, and the runner API in full. - Errors — normalized constraint errors you’ll hit while migrating.
- Backends reference — how to open each backend for the runner’s session.
- Full API:
liteorm.org/migrateandliteorm.org/orm.