Catch unsafe migrations at dev time
🍊 Battle-tested at Instacart
Add this line to your application’s Gemfile:
gem 'strong_migrations'The following operations can cause downtime or errors:
- 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
- adding an index non-concurrently (Postgres only)
- adding a
jsoncolumn 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
- Add the column without a default value
- Add the default value
- Commit the transaction
- Backfill the column
class AddSomeColumnToUsers < ActiveRecord::Migration
def up
# 1
add_column :users, :some_column, :text
# 2
change_column_default :users, :some_column, "default_value"
# 3
commit_db_transaction
# 4.a (Rails 5+)
User.in_batches.update_all some_column: "default_value"
# 4.b (Rails < 5)
User.find_in_batches do |users|
User.where(id: users.map(&:id)).update_all some_column: "default_value"
end
end
def down
remove_column :users, :some_column
end
endIf 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
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
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
endOnce it’s deployed, create a migration to remove the column.
Add indexes concurrently.
class AddSomeIndexToUsers < ActiveRecord::Migration
def change
commit_db_transaction
add_index :users, :some_index, algorithm: :concurrently
end
endThere’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.*") }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
endTo mark migrations as safe that were created before installing this gem, create an initializer with:
StrongMigrations.start_after = 20170101000000Use the version from your latest migration.
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:dropOnly 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?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"Analyze tables automatically (to update planner statistics) after an index is added. Create an initializer with:
StrongMigrations.auto_analyze = trueIt’s a good idea to set a lock timeout for the database user that runs migrations. This way, if migrations can’t acquire a lock in a timely manner, other statements won’t be stuck behind it.
ALTER ROLE myuser SET lock_timeout = '10s';Thanks to Bob Remeika and David Waller for the original code.
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