Strong Migrations
Catch unsafe migrations at dev time
🍊 Battle-tested at Instacart
Installation
Add this line to your application’s Gemfile:
gem 'strong_migrations'
Dangerous Operations
- adding a column with a non-null default value to an existing table
- changing the type of a column
- renaming a table
- renaming a column
- removing a column
- executing arbitrary SQL
- adding an index non-concurrently (Postgres only)
- adding a
json
column to an existing table (Postgres only)
For more info, check out:
- Rails Migrations with No Downtime
- Safe Operations For High Volume PostgreSQL (if it’s relevant)
Also checks for best practices:
- keeping indexes to three columns or less
The Zero Downtime Way
Adding a column with a default value
- Add the column without a default value
- Commit the transaction
- Backfill the column
- Add the default value
class AddSomeColumnToUsers < ActiveRecord::Migration
def up
# 1
add_column :users, :some_column, :text
# 2
commit_db_transaction
# 3.a (Rails 5+)
User.in_batches.update_all some_column: "default_value"
# 3.b (Rails < 5)
User.find_in_batches do |users|
User.where(id: users.map(&:id)).update_all some_column: "default_value"
end
# 4
change_column_default :users, :some_column, "default_value"
end
def down
remove_column :users, :some_column
end
end
Renaming or changing the type of a column
If you really have to:
- Create a new column
- Write to both columns
- Backfill data from the old column to the new column
- Move reads from the old column to the new column
- Stop writing to the old column
- Drop the old column
Renaming a table
If you really have to:
- Create a new table
- Write to both tables
- Backfill data from the old table to new table
- Move reads from the old table to the new table
- Stop writing to the old table
- Drop the old table
Removing a column
Tell ActiveRecord to ignore the column from its cache.
# For Rails 5+
class User < ActiveRecord::Base
self.ignored_columns = %w(some_column)
end
# For Rails < 5
class User < ActiveRecord::Base
def self.columns
super.reject { |c| c.name == "some_column" }
end
end
Once it’s deployed, create a migration to remove the column.
Adding an index (Postgres)
Add indexes concurrently.
class AddSomeIndexToUsers < ActiveRecord::Migration
def change
commit_db_transaction
add_index :users, :some_index, algorithm: :concurrently
end
end
Adding a json column (Postgres)
There’s no equality operator for the json
column type, which causes issues for SELECT DISTINCT
queries. Replace all calls to uniq
with a custom scope.
scope :uniq_on_id, -> { select("DISTINCT ON (your_table.id) your_table.*") }
Assuring Safety
To mark a step in the migration as safe, despite using method that might otherwise be dangerous, wrap it in a safety_assured
block.
class MySafeMigration < ActiveRecord::Migration
def change
safety_assured { remove_column :users, :some_column }
end
end
Dangerous Tasks
For safety, dangerous rake tasks are disabled in production - db:drop
, db:reset
, db:schema:load
, and db:structure:load
. To get around this, use:
SAFETY_ASSURED=1 rake db:drop
Faster Migrations
Only dump the schema when adding a new migration. If you use Git, create an initializer with:
ActiveRecord::Base.dump_schema_after_migration = Rails.env.development? &&
`git status db/migrate/ --porcelain`.present?
Schema Sanity
Columns can flip order in db/schema.rb
when you have multiple developers. One way to prevent this is to alphabetize them. Add to the end of your Rakefile
:
task "db:schema:dump": "strong_migrations:alphabetize_columns"
Credits
Thanks to Bob Remeika and David Waller for the original code.
Contributing
Everyone is encouraged to help improve this project. Here are a few ways you can help:
- Report bugs
- Fix bugs and submit pull requests
- Write, clarify, or fix documentation
- Suggest or add new features