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 migrateanti-pattern; the Rails instance israils db:migratein 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
pscaleCLI +planetscale_railsgem; (2)pscale branch switch my-feature --database my-db-name --create(theswitchsubcommand writes a.pscale.ymlto the local directory so Rails knows which branch to target); (3)bundle exec rails psdb:migrate(the gem's analog todb:migrate); (4)pscale deploy-request create database-name my-feature(creates the deploy request); (5) open a GitHub PR with the migration file -
schema.rbchanges + 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_railsgem 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 — thepsdb:prefix is a deliberate namespace separation fromdb:so both can coexist for local development (against a dev MySQL) vs PlanetScale targets. (Source: same post) -
schema_migrationstable is handled transparently. "You may be wondering what happens with theschema_migrationstable when using PlanetScale deploy requests. The answer is, nothing changes! Deploy requests will keep it up to date just likedb:migratedoes for you. When branching, theschema_migrationstable 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 sameschema_migrationsstate 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
defaultvalue concern familiar from classic MySQLALTER TABLEis explicitly retired here — "Since we are using Deploy Requests, we do not need to worry aboutdefaultvalues. 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 theignored_columnsline and deploy. Canonicalises theignored_columnsidiom 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 consolesession usingfind_eachfor memory safety; (2) complex case → a reusableclass MigrateProjectData; def self.run; initialize.run; end; def run; # migration logic; end; endclass that gets code-reviewed, deployed, then invoked from a console viaMigrateProjectData.run. Canonicalises the schema-change-first / data- migration-second ordering: get the schema in production, then reshape the data. Thefind_eachchoice (Rails's batched-iteration primitive) is load bearing — "usefind_eachto limit data in memory" — prevents the naiveProject.all.eachfrom loading every row into memory at once. -
Dogfooded workflow, not marketed-only. "We've recently released the
planetscale_railsgem. 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 onapp.planetscale.comitself, 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 ofdb:migrate, but runs against a PlanetScale branch (not production) via the.pscale.ymlconfiguration file.rake psdb:rollback— rollback the last migration on the current branch.rake psdb:schema:load— load the currentschema.rbinto the target database (typically for fresh-database initialisation).rake psdb:setup_pscale— sets up a proxy connection to PlanetScale (wrapspscale connectunder 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 withup/downorchangemethods. These files are authored once and run against a PlanetScale branch viapsdb:migrate.schema.rb— the canonical serialised schema snapshot. Gets updated bypsdb:migrateand 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:primarystyle 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 --createprimitive. The dev branch is wherepsdb:migrateruns; branches inherit the production schema and theschema_migrationstable state. - Deploy requests —
pscale deploy-request create database-name my-featurewraps 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_migrationsin 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):
ignored_columnsidiom (verbatim):- Rename-column backfill template (verbatim):
- Reusable data-migration class skeleton (verbatim):
- Unused-index verification query (verbatim):
- 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_railsgem internals are not disclosed. The post names the gem and its Rake tasks but doesn't explain howpsdb:migrateactually talks to PlanetScale — is it a wrapper aroundpscale shell+db:migrate? Does it use the PlanetScale API directly? What Rails versions are supported? What happens if the.pscale.ymlfile 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 NULLconstraint that the partially-migrated state violates?). ignored_columnshas footguns. Setting it in the model file doesn't protect against raw SQL,activerecord-importbulk 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 whileignored_columnsis set, ActiveRecord may silently skip the column in the migration.sys.schema_unused_indexeshas 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_migrationstable look like after a revert? Does Rails'sdb:rollbackstill work? What about theschema.rbfile — 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_migrationsgem 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.ymlis a local-only artifact. The post notes it's written bypscale branch switchbut doesn't discuss CI — if CI doesn't have a.pscale.yml, how does the PR-bot runpsdb: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¶
- Original: https://planetscale.com/blog/zero-downtime-rails-migrations-planetscale-rails-gem
- Raw markdown:
raw/planetscale/2026-04-21-zero-downtime-rails-migrations-with-the-planetscale-rails-ge-f0407cee.md
Related¶
- sources/2026-04-21-planetscale-zero-downtime-laravel-migrations —
Laravel sibling post by Holly Guevara (2022-08-29). Same playbook,
different framework; Laravel's
php artisan migrateanti-pattern = Rails'sdb:migrateanti-pattern; five-step column rename is identical across both. - sources/2026-04-21-planetscale-how-planetscale-makes-schema-changes — Coutermarsh's 2024 sequel walking the PR-bot automated version of this same workflow, plus the per-change-type deploy-order rules (concepts/schema-change-deploy-order).
- sources/2026-04-21-planetscale-non-blocking-schema-changes — Lucy Burns 2021-05-20 launch post for the underlying branch-plus- deploy-request platform that this gem integrates with.
- sources/2026-04-21-planetscale-safely-making-database-schema-changes — Taylor Barnett 2023-04-13 pedagogical overview of the engine-agnostic version of this playbook.
- sources/2026-04-21-planetscale-revert-a-migration-without-losing-data — Barnett 2022-03-24 launch post for the schema-revert safety net this post names.
- sources/2026-04-21-planetscale-behind-the-scenes-how-schema-reverts-work — Guevara + Noach 2022-10 mechanism post for the same revert substrate.
- systems/planetscale-rails-gem — canonical system page for the gem this post introduces.
- systems/ruby-on-rails — framework this workflow targets.
- systems/planetscale — platform this workflow uses.
- concepts/deploy-request — the core review primitive.
- concepts/schema-change-deploy-order — the per-change-type rule governing which deploy ships first.
- patterns/expand-migrate-contract — the Rails instantiation of the classic parallel-change pattern.
- patterns/branch-based-schema-change-workflow — the overarching workflow this post realises in Rails.
- patterns/developer-owned-schema-change — the meta-pattern this post dogfoods in production.
- companies/planetscale — author's organisation.