Skip to content

PLANETSCALE 2023-04-05 Tier 3

Read original ↗

PlanetScale — Versioned schema migrations

Summary

Short PlanetScale tutorial post by Brian Morrison II (originally 2023-04-05, re-surfaced via the 2026-04-21 feed snapshot). The sibling-companion to the same author's 2023-04-05 declarative-schema-migrations explainer (sources/2026-04-21-planetscale-declarative-schema-migrations) — the two posts bracket the schema-migration design space. Where the declarative post covers the desired-state / reconcile ceremony (Atlas HCL-style), this post covers the imperative / versioned / replay- ordered ceremony (Laravel artisan migrate-style). The post opens with the load-bearing framing: "versioned schema migrations consist of multiple files or scripts that iterate on each other to describe the database as it moves through time … It works very similarly to a system you may already be familiar with: git." Canonicalises the migrations tracking table + monotonic file naming + batch-number ledger + reversible up()/down() mechanism, then composes it with PlanetScale's native branching + deploy-request workflow — including a previously-undisclosed automatic migration-data copy between branches feature that keeps the ledger in sync when deploy requests only merge schema, not data.

Key takeaways

  • Versioned schema management is git by direct analogy. Verbatim opening: "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." The database is the checked-out working tree; the ordered migration files are the commit history; applying unapplied migrations is fast-forwarding. Canonicalised as patterns/versioned-schema-migration. (Source: sources/2026-04-21-planetscale-versioned-schema-migrations)

  • The migrations tracking table is the mechanism. Canonical verbatim: "The system will use a dedicated table within your database to track which scripts have been applied, and which ones still need to be applied." The worked Laravel example shows the table shape: id (surrogate PK), migration (the filename slug, e.g. 2014_10_12_000000_create_users_table), batch (monotonically-increasing integer marking which migrate invocation applied the row). On migrate, the tool lists the migration files on disk, diffs against the migrations table, and runs the unrun files in file-name order. Canonicalised as concepts/migration-tracking-table.

  • Monotonic file-name ordering. "Those files are usually numbered in the order they need to be applied." Laravel uses timestamp-prefix filenames (2014_10_12_000000_create_users_table.php, 2019_08_19_000000_create_failed_jobs_table.php, 2023_01_13_000001_add_new_column.php) so filesystem sort order equals application order. Other ecosystems use integer prefixes (Flyway V1__, V2__ …) or content-hash prefixes. The property that matters is total ordering by filename — the migration runner makes no attempt to understand content dependencies; it relies on operators naming files in a monotonically-increasing scheme.

  • Reversible migrations via paired up() / down(). Every Laravel migration file defines two methods: up() does the change, down() undoes it. The worked example canonicalises both directions: up() = Schema::table('users', fn($t) => $t->string('nickname')); down() = Schema::table('users', fn($t) => $t->dropColumn('nickname')). Running ./vendor/bin/sail artisan migrate:rollback --step=1 executes the last batch's down() methods in reverse. This gives versioned migrations a mechanism-level rollback story that purely declarative tools lack — but it is operator-authored, not tool-inferred: the operator must remember to write the down() method, and many destructive operations (dropping a column with production data in it) are not genuinely reversible even with a down() method that re-creates the column shape.

  • Benefits framed by the post (three): (1) Familiarity"versioned schema migrations have been around for much longer than declarative migrations. This means developers are likely more familiar with how they work and may be more comfortable working in this environment." (2) Bidirectional support"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." The "assuming" clause is load-bearing — the reversibility is a convention, not a guarantee. (3) Incremental diagnosis without Git"it's easier to track incremental changes without using a version control system. Since all of the migration scripts are stored alongside each other, diagnosing migration issues may be a bit more straightforward when compared to the declarative approach."

  • Drawbacks canonicalised by the post (two): (1) No snapshot of the current schema"Since the schema is managed incrementally via scripts, 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." Declarative tools give you the whole-schema file; versioned tools give you a delta history. (2) Drift blindness"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." The migrations table tracks what files ran, not what the resulting schema looks like — if an operator manually altered a table between migrations, the versioned tool has no mechanism to detect or reconcile the drift.

  • PlanetScale safe-migrations as gate — the post frames versioned migrations as perfectly fine on PlanetScale when safe-migrations is not enabled: "If safe migrations is not enabled for your production branch, versioned migrations would work with PlanetScale branches just as they would with any other MySQL environment." When safe-migrations is enabled on a production branch, direct DDL is blocked — the operator must run migrations against a development branch first, then merge via PlanetScale's native deploy request workflow. This mirrors the declarative-schema-migrations post's framing: the two approaches are rivals of PlanetScale's vendor-managed ceremony, not substrates underneath it.

  • Automatic copy migration data between branches — canonical new mechanism disclosure. Because PlanetScale deploy requests only merge schema, not data, the migrations tracking table (a data table holding ledger rows) would get lost on each merge — breaking the versioned-migration tool's ability to know what has been applied in production. The post discloses PlanetScale's mitigation: "PlanetScale offers a setting in every Vitess database to automatically copy migration data between branches. This can be set to several preconfigured ORMs, or you can provide a custom table name to sync between database branches." First canonical wiki disclosure of this feature. Canonicalised as concepts/migration-data-copy-between-branches. The feature is structurally necessary for versioned-migration tools to compose with PlanetScale's branching workflow — without it, the ledger is dropped on every production merge and the tool mis-re-runs already-applied migrations.

Worked example — Laravel artisan migrate on PlanetScale

The post's running example is the default Laravel scaffolding in a Sail container:

./vendor/bin/sail artisan migrate

# Output:
   INFO  Preparing database.

  Creating migration table .............................. 45ms DONE

   INFO  Running migrations.

  2014_10_12_000000_create_users_table .................. 45ms DONE
  2014_10_12_100000_create_password_resets_table ........ 64ms DONE
  2019_08_19_000000_create_failed_jobs_table ............ 38ms DONE
  2019_12_14_000001_create_personal_access_tokens_table . 44ms DONE

After which the migrations table contains four rows all marked batch = 1 — four files applied in one invocation. Subsequent invocations with new files (e.g. 2023_01_13_000001_add_new_column.php adding a nickname column to users) get the next batch number (batch = 2), and migrate:rollback --step=1 rolls back the most recent batch by executing its files' down() methods in reverse order.

Systems / concepts / patterns extracted

Operational numbers / diagrams

  • Per-migration latencies in the migrate output: 38–64 ms for trivial CREATE TABLE migrations on a fresh MySQL; 32 ms for an ADD COLUMN; 41 ms for the paired DROP COLUMN rollback. These are not production numbers — they are Laravel Sail container + local MySQL latencies — but they canonicalise the sub-100-ms per-trivial-migration range for online-DDL-eligible changes on a small table.
  • Two screenshots (not captured in the raw markdown): the Laravel default database/migrations/ folder listing, and PlanetScale's auto-migration-data-copy settings screen.

Caveats

  • Tutorial voice, not retrospective. Brian Morrison II is writing conceptual developer- education content, not a PlanetScale production war story. No data on how PlanetScale customers actually use versioned tools in practice; no adoption numbers; no breakdown of Laravel vs Rails vs Django vs Flyway vs Liquibase market share.
  • Laravel-only worked example. The taxonomy (tracking table + monotonic filename + up/down) is ecosystem-agnostic but the worked example shows exclusively Laravel. Rails rake db:migrate (ActiveRecord), Django python manage.py migrate, Flyway V1__Create_Users.sql, Liquibase XML/YAML changelogs, Alembic (SQLAlchemy), golang-migrate — all follow the same ledger pattern with minor variations, but none are named.
  • Reversibility-as-convention understated. The post says reversibility is "easier" via down() methods but does not canonicalise the two load- bearing caveats: (a) down() is operator-authored — some operators skip it; (b) truly destructive operations (column drop with production data, irreversible type narrowing, constraint enforcement that filtered rows) cannot be reversed by a down() method even if one exists. The concepts/non-revertible-schema-change concept page on the wiki canonicalises this at depth; this post elides it.
  • Drift-blindness understated. The post flags drift blindness as a drawback but does not explain the mechanism — the migrations table tracks which files ran, not what schema resulted, so out-of-band DDL silently de-syncs the ledger from reality without any tooling signal.
  • Concurrent-migration race elided. Unlike the declarative-migrations sibling post, which flags "multiple developers may be making changes to the schema definition files at the same time on separate machines, you may run into a scenario where one developer's changes will overwrite another's", this post does not discuss the versioned-migration equivalent: timestamp collision (two engineers branching from the same commit and authoring a migration with timestamp 2023_01_13_000001_… produces a merge conflict at the filename level), and out-of-order apply (if engineer A's 2023_01_13_… migration is applied to production before engineer B's 2023_01_12_… migration lands, the ordering on disk no longer matches the ordering in the ledger — most versioned tools simply don't detect or handle this).
  • No declarative/versioned hybrid coverage. The post frames the two approaches as a binary choice. In practice, tools like Atlas support both modes (declarative schema apply + a migrate sub-command for versioned files) and migrations-as-versioned-files-generated-from-diff workflows (Rails generate migration inferring the delta) blur the distinction. This post stays on the pedagogical binary.
  • Automatic-copy-migration-data mechanism unexplained. The feature is disclosed and its purpose is clear, but the mechanism is hand-waved — the post does not explain how the Vitess substrate replicates this specific data table during a deploy-request cutover (whether it uses VReplication with a custom-table allowlist, or some other mechanism). First-party disclosure of the feature without first-party disclosure of its mechanism.
  • Published 2023-04-05 with PlanetScale's pre- discontinuation branching + deploy-request workflow intact — post references live PlanetScale docs links for safe-migrations, branching, deploy-requests, and the automatic-copy-migration-data setting, all load-bearing product features at publication time.
  • Raw filename carries truncated-slug suffix (…-migrations-e1764932.md) — the URL field in the raw frontmatter is authoritative: verbatim https://planetscale.com/blog/versioned-schema-migrations.

Source

Related

Last updated · 470 distilled / 1,213 read