Skip to content

CONCEPT Cited by 1 source

Up/down migration pair

Definition

An up/down migration pair is the authoring discipline, native to most versioned schema migration tools, where every migration file exports two symmetric closures: an up() that advances the schema one version forward, and a down() that inverts up() to restore the prior state. The tool's migrate command runs up()s in order; the tool's rollback command runs the most recently applied down()s in reverse.

Canonical framing (Morrison II, 2023-04-05, PlanetScale):

"Many tools that support versioned migrations support going both directions, upgrading and/or downgrading the schema. This makes reverting changes simpler since a single script will have instructions on performing a downgrade, assuming the developers or database administrators include those details in the migration scripts."

(Source: sources/2026-04-21-planetscale-versioned-schema-migrations)

The emphasis on "assuming" is load-bearing: the reversibility is aspirational, not mechanical. The tool cannot infer the inverse of an arbitrary DDL statement; the inverse is whatever the developer writes. Correctness of rollback depends entirely on author discipline.

Canonical worked example — Laravel Blueprint

Verbatim from the post:

return new class extends Migration
{
    public function up() {
        Schema::table('users', function (Blueprint $table) {
            $table->string('nickname');
        });
    }

    public function down() {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('nickname');
        });
    }
};

up() adds a nickname column; down() drops it. Running artisan migrate invokes the up() — new column appears, row written to migrations table with the next batch number. Running artisan migrate:rollback --step=1 invokes down() — column dropped, row removed from migrations.

The reversibility discipline

Authoring a correct down() requires:

  1. Knowing the DDL inverseDROP COLUMN for ADD COLUMN, DROP INDEX for ADD INDEX, ALTER … TYPE old_type for ALTER … TYPE new_type.
  2. Handling data-loss asymmetries — dropping a column loses its data. The inverse cannot restore it. up() adds column, populates via backfill; down() drops column, data is gone. The reversibility is structural, not semantic.
  3. Preserving order-dependence — if up() adds column A then backfills from column B then drops column B, down() must add column B back, but the B→A backfill is unrecoverable.
  4. Handling rename ambiguity — a rename is syntactically indistinguishable from a drop-plus-add. If up() does ALTER … RENAME COLUMN old TO new, down() must know the old name to emit RENAME new TO old.

Per-tool variants of the pair

  • Laravel (Eloquent Blueprint) — explicit up() and down() methods on every migration class. Canonical instance.
  • Rails (ActiveRecord) — either explicit def up; end / def down; end, or a single def change; end block that ActiveRecord can reverse automatically for a subset of "reversible" primitives (add_column, create_table, etc.). For irreversible operations (raw SQL, remove_column with lossy type coercion), the developer must fall back to explicit up/down.
  • Djangooperations = [...] list on each migration class. Django generates reverse operations automatically for its built-in operation types; for RunSQL / RunPython, the developer must supply a reverse_sql / reverse_code argument or the operation is marked irreversible.
  • Alembic — explicit upgrade() and downgrade() functions; Alembic's autogenerate tool produces both but the developer is expected to review and edit, especially for data-migration operations.
  • Flywayno built-in downgrade support. Flyway's philosophy is forward-only; teams that want rollback must author a new forward migration that inverses the bad one. The paired-reversibility discipline is explicitly rejected.

When down() is commonly wrong or empty

Real-world failure modes of the discipline:

  • Empty down() for production migrations. Teams that never plan to rollback simply commit empty bodies. Works until a production incident requires one.
  • Incorrect down() that compiles but leaves the database in a different state than pre-up() (forgotten default value, forgotten index, forgotten FK constraint).
  • down() that lose data silently. Dropping a populated column in down() works but loses all values; the schema rolls back, the data does not.
  • Non-commutative up() sequences that break when batch-rolled-back in reverse.
  • Environment-dependent down() that works in dev (empty table) and fails in prod (FK constraints on rows the drop would orphan).

Contrast with schema-revert

PlanetScale's schema-revert primitive is a different mechanism for the same goal (undoing a schema change). Revert works by:

  1. Capturing the pre-migration snapshot of the shadow table at cutover time.
  2. On revert request, replaying the inverse of the shadow-table diff against a new shadow table and cutting over back to the original.

Revert does not require a dev-authored down(). The inverse is computed from the forward diff + the captured snapshot. Caveat: revert has a time bound (the snapshot retention window) and a data semantics — rows written after the migration are lost on revert unless specifically handled.

The up/down pair is author-authored reversibility; revert is snapshot-computed reversibility. They target the same user need from opposite ends.

See patterns/instant-schema-revert-via-inverse-replication.

Canonical wiki caveat — reversibility-as-aspiration

The load-bearing wiki framing is Morrison II's "assuming" — the tool gives you the scaffolding, but correctness is a human discipline. This is the asymmetric failure mode versioned-paradigm shops discover at their first production incident: the down() was never tested, was wrong, or didn't exist.

The declarative-paradigm sibling post's "DDL-as-crutch" drawback is the symmetric human failure on the other side — engineers who don't know DDL can't read the emitted diff. Both paradigms push a cognitive burden onto humans that tooling cannot absorb; they're just different burdens.

Seen in

Last updated · 550 distilled / 1,221 read