Skip to content

PLANETSCALE 2023-03-20 Tier 3

Read original ↗

PlanetScale — Zero downtime Rails migrations with the PlanetScale Rails gem

Mike Coutermarsh, originally 2023-03-20, re-fetched 2026-04-21.

Summary

Framework-specific instantiation of PlanetScale's schema-change doctrine for Rails applications — the canonical companion to the earlier Laravel version of the same playbook. Names the planetscale_rails gem (an internal-tool spun out from PlanetScale's own Rails monolith) as the ActiveRecord→pscale bridge that translates Rails's rake db:migrate mental model into PlanetScale's branch-plus- deploy-request workflow. Canonicalises the default Rails "run migrations at deploy time" setup as the direct-DDL anti-pattern that couples two independently risky operations (code deploy + schema deploy) into one, amplifies their combined failure domain, and blocks unrelated code changes whenever a migration stalls or fails. The prescription: separate the two deploys entirely — ship the schema change through a deploy request first, then merge the code that uses it. Also canonicalises Rails-specific expand-migrate-contract recipes for add-column / drop-column / rename-column / add-index / remove-index / data migration scenarios, each grounded in the schema-change deploy order question ("when do I run my migrations?"): schema-before-code for additions, code-before-schema for drops (with ignored_columns as the bridge), and a multi-step add-column → dual-write → backfill → switch-reads → drop-old- column sequence for renames. Worked production patterns throughout, including the ActiveRecord::Base.ignored_columns += %w(...) construct, the sys.schema_unused_indexes query for pre-drop verification, a canonical Project.all.find_each { ... } backfill script template, and a reusable MigrateProjectData class skeleton for complex data migrations. Closes with the dogfooding disclosure that this is the workflow PlanetScale's own engineering team uses in production to manage the app.planetscale.com Rails application.

Key takeaways

  • Direct DDL at deploy time is the Rails anti-pattern being rejected. "Many Rails applications are set up to run their production schema migrations during their deployment process. While this setup feels easy, 'you just merge!', we do not recommend it." The failure shape is well-canonicalised: migrations are "slow on large tables, or worse, fail completely. Both of these cases block the flow of code getting into production." Schema ops are a different risk profile from code ops"Deploying code to production and migrating the database are two of the highest risk actions engineers do in their work. For these tasks, we prefer to focus and take care of each important step one at a time." Equivalent of Laravel's Forge php artisan migrate anti-pattern; the Rails instance is rails db:migrate in a post-deploy hook. (Source: same post)

  • Atomic-deploy impossibility is structural, not avoidable. "It's also impossible to atomically deploy both code and a schema change at the same time. If either is dependent on the other, users will experience errors or downtime." Canonicalises [[concepts/coupled-vs-decoupled-database-schema- app-deploy|the two-system-atomicity gap]] — app hosts and database hosts deploy on independent clocks; any release where correctness requires in-sync state has a bug window equal to the slower system's catch-up time. Decoupling forces best practices: "Having engineers focus on how to get both their code and schema changes out separately forces best practices to be implemented."

  • The six-step Rails workflow. Author: "Here are the details of how an engineer on our team gets a schema change into production." Concretely: (1) install pscale CLI + planetscale_rails gem; (2) pscale branch switch my-feature --database my-db-name --create (the switch subcommand writes a .pscale.yml to the local directory so Rails knows which branch to target); (3) bundle exec rails psdb:migrate (the gem's analog to db:migrate); (4) pscale deploy-request create database-name my-feature (creates the deploy request); (5) open a GitHub PR with the migration file

  • schema.rb changes + deploy-request link in the description; (6) reviewers approve both; deploy request ships first, then code merges. The ordering is load-bearing — the database must be ready before the code that uses it.

  • Rake task surface is narrow and named. The planetscale_rails gem exposes four commands: rake psdb:migrate, rake psdb:rollback, rake psdb:schema:load, rake psdb:setup_pscale (the last sets up a proxy connection to PlanetScale). Multi-database Rails apps (Rails 6+) are supported via :primary-style suffixes — rails psdb:migrate:primary, rails psdb:migrate:rollback. Canonicalises the gem's scope: it is a migration runner, not a full replacement for ActiveRecord — the psdb: prefix is a deliberate namespace separation from db: so both can coexist for local development (against a dev MySQL) vs PlanetScale targets. (Source: same post)

  • schema_migrations table is handled transparently. "You may be wondering what happens with the schema_migrations table when using PlanetScale deploy requests. The answer is, nothing changes! Deploy requests will keep it up to date just like db:migrate does for you. When branching, the schema_migrations table gets copied across branches." Required setup: dashboard toggle "Automatically copy migration data" on the database's Settings page. Canonicalises an important continuity property — Rails's migration-tracking machinery is not disturbed by the branch-based workflow; every branch carries the same schema_migrations state as its parent, and deploy requests advance both the schema and the tracking table atomically. (Source: same post)

  • Add-column is the simplest case: schema-before-code. "To add a new column safely, you must always deploy the schema change before any code using the column is deployed to production." Two steps: (1) deploy request adds the column; (2) code that uses the column merges afterward. The default value concern familiar from classic MySQL ALTER TABLE is explicitly retired here — "Since we are using Deploy Requests, we do not need to worry about default values. PlanetScale's schema change tools will add the column and the new default value without any table locking." The shadow-table migration handles it. Canonicalises the additive-change rule of deploy order: for additions, schema-first is sufficient. (Source: same post)

  • Drop-column requires code-first, not schema-first. "Removing a column is one case where we do want to deploy some code prior to running the migration." Three-step recipe: (1) set self.ignored_columns += %w(category) in the model and deploy → app stops reading/writing the column; (2) verify via Insights that no queries reference the column anymore; (3) deploy request drops the column; (4) remove the ignored_columns line and deploy. Canonicalises the ignored_columns idiom as the Rails bridge primitive for column removal — tells ActiveRecord to behave as if the column doesn't exist so the app can be shipped ahead of the schema change. Also names the query-telemetry verification step as load-bearing — don't trust the code reading; verify production is actually quiet on the column before destructive DDL.

  • Rename-column requires five steps — full expand-migrate-contract. "Renaming a column may seem like a simple change, but doing so without any interruption to production takes some extra care. We cannot rename a column directly without downtime. Instead, we must add a new column and then transfer all the data to it." The canonical five-step Rails recipe: (1) migration adds new column alongside the old one; (2) app update starts double-writing to both columns (ActiveRecord callbacks suggested); (3) backfill script — Project.all.find_each { |project| project.update( :new_column, project.old_column); puts "updated #{project.id}" } — copies historical data; (4) app update switches reads to new column, removes the double-writes; (5) migration drops the old column. Each step is a separate production deploy — five deploys total. "It is a bit of extra work, but taking these steps ensures a safe transition to the new column and no interruptions to production traffic." Canonicalises the Rails instantiation of the classic parallel-change pattern.

  • Index add is trivial; index remove requires usage verification. "When using Deploy Requests, there is no risk of table locking while adding or removing a database index. This scenario is quite simple." Add: one deploy request + merge code. Remove: check usage first. The canonical verification query: SELECT * FROM sys.schema_unused_indexes; — the MySQL sys-schema view that returns indexes unused since the last MySQL restart. Caveat recorded in the post: "If your application runs infrequent scheduled jobs, it may be possible for an index used by that job to show up as unused when running the query." Complements [[sources/2026-04-21-planetscale-what-are-the-disadvantages-of-database- indexes|JD Lien's 2023-02-17 disadvantages-of-indexes post]] which canonicalises the two-step detect-via-cardinality-zero + validate-via-invisibility workflow as a more cautious variant of the same diagnostic loop.

  • Data migrations run on a production Rails console, not as migration files. "Occasionally, we also need to backfill or migrate data as part of our schema changes. We handle this by running the data migration in production after the schema has been changed." Two-altitude recipe: (1) simple case → one-liner in a rails console session using find_each for memory safety; (2) complex case → a reusable class MigrateProjectData; def self.run; initialize.run; end; def run; # migration logic; end; end class that gets code-reviewed, deployed, then invoked from a console via MigrateProjectData.run. Canonicalises the schema-change-first / data- migration-second ordering: get the schema in production, then reshape the data. The find_each choice (Rails's batched-iteration primitive) is load bearing — "use find_each to limit data in memory" — prevents the naive Project.all.each from loading every row into memory at once.

  • Dogfooded workflow, not marketed-only. "We've recently released the planetscale_rails gem. It contains a collection of Rake tasks that we have been using internally to manage the schema of our own Rails application." This is the workflow PlanetScale's engineering team uses on app.planetscale.com itself, spun out as an open-source gem. The developer-owned schema change pattern is not aspirational here — it's the production state of the world for the PlanetScale monolith. Later Coutermarsh posts ( How PlanetScale makes schema changes) elaborate this into a PR-bot auto-deploy-request automation layer on top.

Systems

planetscale_rails gem

Canonical first-party-disclosure post. The gem is named in the post's opening paragraph: "We've recently released the planetscale_rails gem. It contains a collection of Rake tasks that we have been using internally to manage the schema of our own Rails application." Four Rake tasks:

  • rake psdb:migrate — ActiveRecord analog of db:migrate, but runs against a PlanetScale branch (not production) via the .pscale.yml configuration file.
  • rake psdb:rollback — rollback the last migration on the current branch.
  • rake psdb:schema:load — load the current schema.rb into the target database (typically for fresh-database initialisation).
  • rake psdb:setup_pscale — sets up a proxy connection to PlanetScale (wraps pscale connect under the hood).

Multi-database Rails app support via :primary-style suffixes, e.g. rails psdb:migrate:primary or rails psdb:migrate:rollback. The psdb: prefix deliberately separates PlanetScale-targeted runs from db:-prefixed local runs so both coexist.

The gem does not execute DDL against production — production schema changes route through deploy requests. The gem's psdb:migrate runs against a development branch only; the deploy-request system handles the production rollout via shadow-table migration once the PR + deploy request are both approved.

Ruby on Rails

The framework this workflow instantiates. Canonicalises several Rails-specific primitives as load-bearing in the workflow:

  • db/migrate/*.rb — ActiveRecord migration files with up / down or change methods. These files are authored once and run against a PlanetScale branch via psdb:migrate.
  • schema.rb — the canonical serialised schema snapshot. Gets updated by psdb:migrate and ships in the same PR as the migration file.
  • ActiveRecord::Base.ignored_columns — the bridge primitive for column drops. "Set the column as ignored in the model and deploy this change to production. This ensures your application is not using the column in production and removes the risk of any errors when you do run the migration."
  • .find_each — batched iteration (default batch size 1,000) for memory-safe bulk data migrations. Load-bearing in the backfill template.
  • Rails console — production-shell entry point for data migrations (not schema migrations). Canonicalised as the correct altitude for data reshape after schema is in place.
  • Multi-database support (Rails 6+) — psdb:migrate:primary style suffixes compose with Rails's multi-db config.

PlanetScale

The managed-MySQL substrate. Load-bearing features named in the post:

  • Branches — first-class pscale branch switch my-feature --create primitive. The dev branch is where psdb:migrate runs; branches inherit the production schema and the schema_migrations table state.
  • Deploy requestspscale deploy-request create database-name my-feature wraps the schema diff as a first-class review primitive, shipping via online DDL when approved.
  • Safe migrations — feature that must be enabled on the production branch before this workflow works; prevents direct DDL execution against the production branch.
  • Schema revert — follow-on capability the post names as the safety net ("you can also revert it without data loss") but defers to the dedicated internals post for mechanism.
  • "Automatically copy migration data" — database-settings toggle that keeps schema_migrations in sync across branches. Required for the gem's workflow to be sound.

MySQL

The underlying engine. The sys.schema_unused_indexes query is the pre-drop verification primitive named in the index-removal recipe — a view in MySQL's sys schema that reports indexes with zero usage since the last mysqld restart.

Vitess

Implicit substrate — shadow-table online migrations, schema reverts, and deploy requests are all Vitess primitives surfaced through PlanetScale's product layer. Not named in the post directly but named across the canonical reference corpus.

Git + GitHub Actions

Surrounding integration. The workflow is: author migration in a Git branch → psdb:migrate against PlanetScale branch → deploy-request create → GitHub PR with both the migration file and a deploy-request link → reviewers approve both → deploy request ships first, then PR merges. This is the workflow PlanetScale's PR-bot automates in the later Coutermarsh post; this 2023-03-20 post is the manual-workflow substrate beneath it.

Concepts

Deploy request

Named explicitly in the post: "We love using rails db:migrate for development. But when it comes to production, we prefer the safety that deploy requests provide. … Think of it as a very advanced version of rails db:migrate." Canonicalises the deploy-request as the review primitive for schema changes that replaces the implicit-at-deploy-time db:migrate execution. The six-step workflow canonicalised here puts the deploy request front and centre as the first artifact produced from a schema change intent — it's not a consequence of the PR merging, it's a separate artifact that gets linked from the PR description.

Schema revert

Named implicitly: "if something does go wrong, you can also revert it without data loss. Sounds impossible, but you can read through our How schema reverts work blog post if you want the details on how it all works." The post positions revert as the safety net under the workflow, not the focus of this particular essay. Rails-specific wrinkle noted: even ignored_columns-plus-drop workflows are revertible if something surprises you between steps.

Schema-change deploy order

Canonicalised as the load-bearing question of the entire post. "When do I run my migrations?" gets three distinct answers depending on change class: schema-first (additions — add-column, add-index), code-first (drops — drop-column, drop-index, any destructive change), and multi-step (renames — the parallel- change pattern). The post doesn't name the concept this way, but this is the operational rule governing which deploy unit ships first for each change class. Complements Coutermarsh's later 2024 post How PlanetScale makes schema changes which canonicalises the same three-way split at the PR-bot altitude.

Coupled vs decoupled deploy

Canonical structural argument against coupled deploys. "Deploying code to production and migrating the database are two of the highest risk actions engineers do in their work. For these tasks, we prefer to focus and take care of each important step one at a time. It's also impossible to atomically deploy both code and a schema change at the same time." The coupled-deploy anti-pattern is the Rails default that this post specifically recommends engineers abandon.

Online DDL

Underlying substrate that makes schema-first additions safe. The post defers to the mechanism without unpacking it, but names the key operational property: "PlanetScale's schema change tools will add the column and the new default value without any table locking." Rails-native ALTER TABLE on a large production table would hold a table-level lock; the PlanetScale substrate routes through shadow-table migration via Vitess, so lock-free additions are the default.

Non-revertible schema change

Implicit in the drop-column and rename-column recipes — a dropped column is destructive with data loss (and, per MySQL semantics, also metadata loss). The recipes' careful staging (ignored_columns-first for drops, double-write-and-backfill for renames) is precisely because drop operations are the asymmetric-revertibility cliff that the asymmetry concept canonicalises.

Patterns

Expand-migrate-contract

The canonical pattern behind the rename-column recipe. The post walks the full five-step Rails instantiation verbatim — add new column (expand), dual-write (migrate), backfill (migrate), switch reads (migrate), drop old column (contract). Each step is a separate production deploy. The post is Rails's canonical expand-migrate- contract tutorial on the PlanetScale blog, paralleling the earlier Laravel version.

Shadow-table online schema change

The PlanetScale-side mechanism that lets schema-first additions run without locking. Not named explicitly ("PlanetScale's schema change tools" is the post's gloss) but the operational property is canonical.

Branch-based schema-change workflow

The overarching pattern this post instantiates for Rails. The branch-switch-migrate-deploy-request-merge loop is the UX realization of the branch-based workflow, made Rails-native via the planetscale_rails gem. Laravel got its own instantiation in the earlier 2022 post; Python/Django/etc. have no equivalent gem- named instantiation in the PlanetScale corpus.

Instant schema revert via inverse replication

The safety-net mechanism under the workflow. Named but deferred to the mechanism post.

Developer-owned schema change

The meta-pattern. "This leads to faster releases and an all-around more confident team." The gem + workflow put schema changes in the developer's hands end to end — authored by the dev, reviewed by the dev, deployed by the dev, rollback-able by the dev. No DBA gate, no ops handoff. This is the canonical pattern from Noach's 2021 and 2022 principles posts, instantiated at the Rails application-tier altitude.

Workflow diagram

Developer                      GitHub                       PlanetScale
─────────                      ──────                       ───────────
author migration file                                       (production branch)
pscale branch switch my-feature ─────────────────────────▶  (dev branch created)
bundle exec rails psdb:migrate ──────────────────────────▶  (migration runs on dev branch)
pscale deploy-request create ────────────────────────────▶  (deploy request opened)
git push → open PR ─────────▶ PR (with DR link) ───────▶  reviewers
                              review both code + schema
                              deploy request ships first ▶  (shadow-table migration → cutover)
                              PR merges → code deploys ──▶  (code using new schema live)

Operational numbers and quotes

  • Rake task list (verbatim from post body):
    rake psdb:migrate              # Migrate the database for current environment
    rake psdb:rollback             # Rollback primary database for current environment
    rake psdb:schema:load          # Load the current schema into the database
    rake psdb:setup_pscale         # Setup a proxy to connect to PlanetScale
    
  • ignored_columns idiom (verbatim):
    class Project < ActiveRecord::Base
      self.ignored_columns += %w(category)
    end
    
  • Rename-column backfill template (verbatim):
    # Example backfill script
    Project.all.find_each do |project|
      project.update(:new_column, project.old_column)
      puts "updated #{project.id}"
    end
    
  • Reusable data-migration class skeleton (verbatim):
    class MigrateProjectData
      def self.run
        initialize.run
      end
    
      def run
        # migration logic here
      end
    end
    
  • Unused-index verification query (verbatim):
    select * from sys.schema_unused_indexes;
    
  • Branch creation command (verbatim): pscale branch switch my-feature --database my-db-name --create
  • Deploy request creation command (verbatim): pscale deploy-request create database-name my-feature
  • Step count for column rename (load-bearing): 5 separate production deploys.

Caveats

  • Rails monolith-centric framing. The post assumes a single Rails app with a single primary database. Multi-database Rails apps are named (Rails 6+ multi-db config) but the six-step workflow is walked for the single-db case. Microservices with multiple Rails apps hitting the same PlanetScale database aren't engaged with — which app's CI runs psdb:migrate? Who owns the deploy request?
  • planetscale_rails gem internals are not disclosed. The post names the gem and its Rake tasks but doesn't explain how psdb:migrate actually talks to PlanetScale — is it a wrapper around pscale shell + db:migrate? Does it use the PlanetScale API directly? What Rails versions are supported? What happens if the .pscale.yml file is missing or points at a deleted branch? These questions are deferred to the gem's GitHub README and source.
  • No production numbers. Zero benchmarks, zero migration-duration measurements, zero team-velocity claims. The "this leads to faster releases" framing is marketing assertion, not measurement.
  • Pedagogy voice, not incident retrospective. No war stories, no migrations that went wrong despite following this recipe, no disclosure of edge cases the PlanetScale team has hit. The rename- column five-step is presented as a clean recipe without acknowledging the callback-based double-write failure modes (what if the callback throws? what if the new column has a NOT NULL constraint that the partially-migrated state violates?).
  • ignored_columns has footguns. Setting it in the model file doesn't protect against raw SQL, activerecord-import bulk inserts, or other paths that bypass the model layer. The post doesn't mention these. Also doesn't mention that if a migration runs against a model while ignored_columns is set, ActiveRecord may silently skip the column in the migration.
  • sys.schema_unused_indexes has a known blind spot the post names — indexes used only by rare scheduled jobs (daily cron, weekly report) can appear unused if MySQL has restarted since the last job run. Post flags this but doesn't propose a better diagnostic; in practice the [[sources/2026-04-21-planetscale- tracking-index-usage-with-insights|Hazen 2024-08-14 Tracking index usage with Insights post]] canonicalises the production-telemetry answer (per-query index-usage histograms over weeks).
  • No coverage of schema reverts for Rails specifically. The post names schema revert as the safety net but doesn't walk the Rails ergonomics — what does the schema_migrations table look like after a revert? Does Rails's db:rollback still work? What about the schema.rb file — does it need to be hand-edited back? These are deferred to the dedicated schema-revert internals post.
  • 30-minute revert window not named. The canonical "30 minutes after deploy" revert-window datum from the 2022 Barnett launch post is absent here — the post says "without data loss" but doesn't tell the Rails developer how long they have to hit the button.
  • No integration with Rails's built-in strong_migrations gem or similar linters. The Ruby ecosystem has parallel-evolved safety nets (strong_migrations, active_record_doctor, etc.) that catch many of the same anti-patterns; the post treats the gem as the one solution without engaging with what's already standard in the Rails community.
  • .pscale.yml is a local-only artifact. The post notes it's written by pscale branch switch but doesn't discuss CI — if CI doesn't have a .pscale.yml, how does the PR-bot run psdb:migrate? This is handled by the later PR-bot post, but the gap is open here.
  • Pre-dates newer PlanetScale product surface. 2023-03-20 publication predates the later Coutermarsh 2024 post on the PR-bot automation, the 2024-09-04 Noach instant-deploy-requests launch (which compresses the fast path for ALGORITHM=INSTANT-eligible changes), and the 2024 gated-deployments mechanism for multi-change deploy requests. The workflow described here is the manual- automation altitude; the later posts canonicalise the fully- automated altitude built on top.

Source

Last updated · 470 distilled / 1,213 read