Skip to content

CONCEPT Cited by 2 sources

Versioned schema migration

Definition

A versioned schema migration is a paradigm for managing relational-database schema evolution in which the schema is expressed as an ordered sequence of immutable, incremental DDL scripts — one per change — rather than as a single desired-state file. Each script captures a delta (add this column, drop this index, rename this table) with a conventional ordering (by timestamp or monotonic number in the filename), the tool applies the scripts in order against the live database, and a dedicated tracking table inside the database itself records which scripts have already run.

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

"Schema versioning tools have existed long before their declarative counterparts. Instead of having a single file describing the state of the database schema, versioned schema migrations consist of multiple files or scripts that iterate on each other to describe the database as it moves through time. As changes are made to the schema, new files are added to describe those changes … It works very similarly to a system you may already be familiar with: git."

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

Why the name

The schema's version at any moment is identified by the cumulative set of migration files that have been applied. New migrations bump that version monotonically — adding a script = creating a new version of the schema; replaying from version N to version M requires applying every intervening script. The tool reads the tracking table to determine the current version and then applies the pending suffix of the ordered chain.

The paradigm's two structural primitives:

  1. Sequence ordering at the filesystem / filename level. See patterns/sequential-numbered-migration-files.
  2. State tracking inside the database via a migration-tracking table that records which scripts have been applied.

Without both, the paradigm doesn't work: without (1), the tool can't determine what to apply next; without (2), the tool can't determine what's already been applied and will either skip pending work or re-apply everything.

Contrast with declarative schema management

Axis Versioned Declarative
Representation Ordered chain of delta scripts Single desired-state file
Source of truth The chain's cumulative effect The file
Change expression New file appended File edited in place
Apply mechanism Replay pending scripts Diff file vs live DB → emit DDL
Current-state readable from Live DB (or full replay) The file directly
Drift-blindness Yes by default — tool trusts tracking table No — tool always diffs against live
Rollback Often built-in (if down() written) Requires inverse-tool machinery
Adoption age Pre-2010s ubiquity via Rails, Django, Laravel 2020s-ish (Atlas, Prisma migrate with dev-shadow)

Canonical wiki framing: both paradigms are valid compositional substrates; the right choice depends on team discipline (versioned requires correct down() authoring) + tool compatibility (declarative requires a diff engine that handles your dialect) + platform constraints (PlanetScale's safe-migrations mode reshapes both paradigms — see composition note below).

The tracking table at the heart of the paradigm

Every versioned tool creates a table inside the target database to record applied scripts. Laravel's Eloquent uses migrations (id, migration, batch) where migration is the script's filename and batch is a monotonic counter incremented each artisan migrate invocation. Rails' ActiveRecord uses schema_migrations (version) (no batch — Rails lacks Laravel's batch concept). Django uses django_migrations (id, app, name, applied). Flyway uses flyway_schema_history with version, description, and install_rank columns.

Canonicalised on the wiki as concepts/migration-tracking-table.

The reversibility discipline

Many versioned tools support forward and reverse scripts — in Laravel's Blueprint idiom, up() and down() methods on the migration class. Running artisan migrate invokes up(); artisan migrate:rollback --step=1 invokes the down() methods of the most recent batch in reverse order.

Load-bearing caveat verbatim: "assuming the developers or database administrators include those details in the migration scripts." The inverse isn't inferred by the tool — the developer must author a correct down(), and the tool's rollback is only as good as the discipline behind the down() author. Canonicalised as concepts/up-down-migration-pair.

Benefits

  • Familiarity / low switching cost. "developers are likely more familiar with how they work and may be more comfortable working in this environment" (Morrison II, 2023). The paradigm has existed since the mid-2000s (Rails Migrations shipped 2006); it's the default scaffolding in every major Active-Record- style framework.
  • Incremental-diff audit trail. Each change is one file; code review sees "what changed" directly without diffing against a monster desired-state file. "diagnosing migration issues may be a bit more straightforward when compared to the declarative approach" (Morrison II, 2023).
  • Built-in rollback as a first-class primitive via the down() + batch column — if discipline is maintained, "reverting changes simpler since a single script will have instructions on performing a downgrade."

Drawbacks

  • Current-state-not-materialised. Load-bearing verbatim: "it may be hard to get a full picture of what the database schema looks like at any given point in time. You'd essentially have to replay all of the previous scripts against a live system to see the schema in full." The desired-state file in a declarative tool is browseable / greppable / diff-against-anything; the migration chain is not. Consequence: tooling that needs the current schema (ORM scaffolding, codegen, docs) must read from the live DB or run a shadow replay.
  • Drift-blindness. "Depending on the tool, it may not validate the current state of the schema before attempting to apply changes. This can cause major issues if the schema was modified outside of the tool and DDL was issued directly to the database." If someone ran ALTER TABLE manually, the next migration applies as if the schema still matches the chain's cumulative state — producing silent drift or duplicate-column / missing-column errors. Declarative tools reconcile against the live DB on every apply and cannot silently drift.
  • Inverse-authoring burden. Every destructive migration needs a matching down() that restores the prior state — often harder than the forward path (restoring a dropped column loses the data unless backups exist; restoring a renamed column requires remembering the old name). In practice many teams ship migrations with empty down() methods.
  • Conflict-on-same-timestamp. Two developers branching from the same HEAD and authoring new migrations simultaneously can produce two files with adjacent timestamps; on merge the tool may apply them out of authoring order, with subtle semantic effects if they touch the same table.

Composition with PlanetScale-native primitives

When PlanetScale's safe-migrations feature is off, versioned tools work unchanged — same apply command, same tracking table. When on, direct DDL is restricted on production branches; the versioned tool must run against a development branch and the resulting schema diff is promoted via a deploy request. The deploy request is schema-only by default, so the migrations table (which is data, not schema) would otherwise be lost on merge; PlanetScale offers an "automatically copy migration data between branches" setting with presets for Laravel / Rails / Django / custom ORM tracking tables.

Canonical wiki framing: the versioned paradigm and PlanetScale's native branching don't conflict — they compose by splitting the ceremony into authoring (via the versioned tool, against a dev branch) and applying (via the deploy request, against production).

See patterns/branch-based-schema-change-workflow.

Seen in

  • Laravel (artisan migrate + Eloquent Blueprint) — canonical worked example in this post. Files live in database/migrations/, named YYYY_MM_DD_HHMMSS_description.php, each exporting up() + down() closures that build DDL via the Blueprint DSL. Tracking table is migrations (id, migration, batch). Rollback via artisan migrate:rollback --step=N. (Source: sources/2026-04-21-planetscale-versioned-schema-migrations)

  • MySQL as execution target — Morrison II's worked example uses Laravel to emit MySQL DDL. The paradigm is engine-agnostic: the tool is responsible for translating its DSL / script syntax into the target dialect.

  • PlanetScale as deployment substrate — both paradigms compose with PlanetScale; versioned tools run against development branches when safe-migrations is on.

Last updated · 550 distilled / 1,221 read