I used to have a little ritual before every risky deploy: write the up, write a matching down, and tell myself that if anything went wrong I could just roll it back. It was a comfort blanket. And like most comfort blankets, it was hiding the fact that I was cold. This is the story of how I learned that rails zero downtime migrations aren't about being able to undo things - they're about designing so you never need to.
Why this bugged me for years
The migration that finally broke the spell looked completely, boringly safe in review. It added a compound index to speed up product search:
class OptimizeProductSearch < ActiveRecord::Migration[7.0]
def up
add_index :products, [:category_id, :price, :created_at],
name: 'idx_products_category_price_date'
end
def down
remove_index :products, name: 'idx_products_category_price_date'
end
end
The up built the index over 15-45 minutes under shared locks - slow queries, a sad p95, but survivable. The trap was the down. When a deploy went sideways and the pipeline cheerfully auto-rolled-back, the DROP INDEX grabbed an exclusive lock for a few seconds. On our busiest table, that was a brief but total outage. Sit with that for a second: the rollback - the thing I'd added specifically to keep us safe - was the incident. I found a writeup that argues exactly this point, that in high-load systems a rollback-first mindset manufactures the worst failure modes, and reading it was a little embarrassing, because it named a thing I'd been doing to myself for years.
Once I saw it, I saw it everywhere. Dropping an index or column freezes the fast path with exclusive locks at exactly the moment traffic is highest. Data migrations lose information, so recreating the original is wishful thinking. Rolling code back while the DB is half-migrated leaves a ghost state nobody can reason about mid-incident. And teams that fear schema changes quietly stop shipping the refactors they actually need - which is the slow, invisible cost that hurts most.
The irreversibility one deserves a concrete example, because people underestimate it. We had a migration that normalized free-form addresses into city, country, and postal_code, then dropped the original address column. The down gamely tried to reconstruct the address by string-joining the pieces back together - but the formatting, the ordering, the apartment numbers were simply gone. That migration was irreversible without data loss, full stop. Writing a fantasy down for it was worse than useless; it handed me false confidence I'd cash in at the worst possible time.
The thing that finally clicked
The fix turned out to be a discipline, not a gem. It's what Martin Fowler calls Parallel Change, and what database folks call expand and contract. Every change becomes a little sequence of small, individually-deployable steps, each one compatible with the app version before it. You expand - add new structures without blocking, and leave the old ones alone. You migrate - backfill, dual-write, move reads over gradually. Then you contract - remove the legacy stuff, but only after a full compatibility window has passed.
That compatibility window is the part everyone skips and everyone regrets. The new schema has to keep supporting the previous app version for at least one deploy cycle. Do that, and rolling code back is a non-event - the old code still runs perfectly against the expanded schema, no ghost state, no cold sweat. The whole trick is just refusing to collapse those three steps back into one clever migration, no matter how much your inner tidiness gremlin wants you to.
Dual-writes are the bridge
During the window I write to both the old and new structures, so either app version reads consistent data, and I move reads over slowly:
class User < ApplicationRecord
has_many :user_statuses, -> { order(:effective_from) }
def status=(new_status)
if has_attribute?(:status)
write_attribute(:status, new_status) # legacy field
end
user_statuses.create!( # always maintain the new system
status_type: UserStatus.status_types[new_status] || 0,
effective_from: Time.current
)
end
end
When touching every write path felt too risky, I leaned on a database trigger to auto-sync legacy writes into the new table for the length of the rollout, then dropped the trigger in the contract phase. Either way the rule is the same, and it's the whole point: nobody, ever, reads a half-populated new table.
Never take a lock you can't afford
The other half of zero-downtime is refusing to grab a lock that'll hurt. The rule the Rails migrations guide and strong_migrations both nudge you toward: add nullable columns with no default (fast), backfill asynchronously in batches, then add the NOT NULL constraint in a separate later migration.
class AddColumnWithoutDowntime < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def up
add_column :large_table, :new_field, :string # fast, no default
queue_background_migration('FillNewFieldJob') # backfill in batches
# change_column_null later, once backfill completes
end
end
The background job walks the table in ordered batches and re-enqueues itself until it runs out of rows, so a table with tens of millions of rows never holds one long transaction or a table-wide lock. On Postgres the same instinct means building indexes concurrently - CREATE INDEX CONCURRENTLY skips the write-blocking lock, at the cost of a second table scan. Cheap trade. Take it.
The rehearsal that ended most of my incidents
Here's the embarrassingly cheap thing that fixed the most pain: I started running migrations against production-sized data in staging before they ever touched production. A change that runs in 40 milliseconds on a seed database can run for 40 minutes against 50 million rows, and there is no way to feel that difference on your laptop. So I'd generate a production-scale dataset, run each pending migration inside a transaction I rolled back for repeatability, and record duration, memory delta, and any blocking queries it kicked off. The output was a short report the team actually read before sign-off. Nine times out of ten the rehearsal caught the problem - a missing concurrent flag, an unbatched backfill - back when it was still boring to fix, which is the only time fixing anything is fun.
Make the safety rails automatic
Discipline that depends on everyone remembering is discipline that evaporates at 2 AM. So I pushed the rules into tooling. If you're on Rails, the strong_migrations gem already codifies most of this and is the fastest possible start - it fails the build on unsafe operations and prints the safe rewrite right there in your face. I layered a little duration estimator on top. A CI check refuses the deploy if a recent migration adds a NOT NULL column with no default, creates a blocking index, or is estimated to run long:
if total_estimated_time > 300 # 5 minutes
unless ENV['FORCE_LONG_MIGRATION'] == 'true'
puts 'Set FORCE_LONG_MIGRATION=true to proceed'
exit 1
end
end
I also instrument the migrations themselves - subscribing to sql.active_record notifications, logging any migration query over a second and pushing its duration to StatsD, so a slow migration shows up on a dashboard instead of in an incident channel. And before anything risky runs in production, a safety-net step snapshots the affected tables so there's a credible recovery point. Estimators, circuit breakers around big data migrations, a staging rehearsal task - you build the set once and reuse it across every service, and it pays you back forever.
How it feels now
Forward-only evolution didn't just change my migrations, it changed how the deploy button feels. Instead of fear I have a checklist: design for backward compatibility, expand first and contract later, split the scary change into small ones so data migrates asynchronously while reads drift over, and recover forward with fixes and checkpoints instead of fantasy rollbacks. None of it is clever. It's mostly the willingness to turn one terrifying migration into three boring ones - and I've made my peace with boring.
The part I didn't expect was cultural. When schema changes stopped being scary, the team stopped avoiding them, and all those long-postponed refactors we'd been quietly routing around finally shipped. That's the real payoff. If your database changes still page your on-call, start with just two things: the compatibility window, and the pre-deploy check. They're the two highest-leverage moves, and they're the ones that let you sleep.
Sources & further reading
- Martin Fowler - Parallel Change (expand and contract): https://martinfowler.com/bliki/ParallelChange.html
- strong_migrations - catch unsafe migrations in development (GitHub): https://github.com/ankane/strong_migrations
- PostgreSQL - CREATE INDEX, including the CONCURRENTLY option: https://www.postgresql.org/docs/current/sql-createindex.html
- Rails - Active Record Migrations guide: https://guides.rubyonrails.org/active_record_migrations.html
- A thorough end-to-end zero-downtime schema evolution playbook someone published, estimators and staging rehearsal task included: https://dorokhovich.com/blog/rails-database-schema-evolution?utm_source=devto&utm_medium=article&utm_campaign=success-story&utm_content=rails-database-schema-evolution
Top comments (0)