Rails Database Migrations: Best Practices

Learn Rails database migration best practices for safer schema changes, data integrity, and reliable production deployments.


By Jean Emmanuel Cadet • 25 min read

Rails Database Migrations: Best Practices
Share with friends

Every Rails application changes shape over time. You add a status column to orders, split a table in two, or index a column that suddenly shows up in every slow query. Rails database migrations are how you make those changes in a controlled, repeatable way. Written well, they keep every developer’s machine, your staging server, and production in sync. Written carelessly, they cause failed deployments, locked tables, and data you cannot get back.

This guide walks through Rails migration best practices from first principles, then moves on to production concerns such as reversible migrations, data backfills, indexes, constraints, and safe schema changes on live databases. The examples target Rails 8.1, and version-specific behavior is called out where it matters. If your app runs an older release, use that version in the migration class declaration (for example ActiveRecord::Migration[8.0]).


Understanding Rails Database Migrations

A database migration in Rails is a Ruby class that describes one change to your database schema. Active Record migrations let you create tables, add columns, build indexes, and add constraints using Ruby instead of hand-written SQL for each database. Because migrations are plain files stored in db/migrate, they live in version control next to your application code.

Each migration file starts with a timestamp, such as 20261019120000_add_status_to_orders.rb. That timestamp is the migration’s version, and it determines the order in which migrations run. When a migration succeeds, Rails records its version in a table called schema_migrations. The next time you run bin/rails db:migrate, Rails compares the files in db/migrate with the versions stored in that table and runs only the ones that are missing.

This simple mechanism solves several real problems:

  • Collaboration: A teammate pulls your branch, runs the migrations, and gets the same schema you have.
  • Deployment: Production applies the same ordered list of changes that development and staging already ran.
  • Consistency: Every environment can be rebuilt from known, versioned steps instead of manual edits.

Rails also wraps each migration in a transaction on databases that support transactional DDL, which includes PostgreSQL and SQLite. If a migration fails halfway, the changes roll back automatically. MySQL and MariaDB do not support transactional DDL, so a failed migration there can leave the schema partially changed. This is the first of many places where your database choice affects how you should write migrations. If you want a refresher on how transactions work in Rails, see this practical guide to Rails transactions.


Creating and Running Migrations

You will use a handful of commands constantly. Knowing when to reach for each one saves time and prevents mistakes.

Generating a migration

bin/rails generate migration AddStatusToOrders status:string

The generator creates a timestamped file in db/migrate. Because the name follows the AddXToY pattern and you supplied a column definition, Rails fills in the body for you:

class AddStatusToOrders < ActiveRecord::Migration[8.1]
def change
add_column :orders, :status, :string
end
end

Running bin/rails generate migration with a name and no attributes creates an empty migration. That is the right choice when you plan to write the change yourself, such as a data backfill or a custom constraint.

Running and rolling back

bin/rails db:migrate

This runs every pending migration in timestamp order, then updates db/schema.rb (or db/structure.sql if your app uses SQL dumps). Use it in development after pulling new code or writing a migration.

bin/rails db:rollback

This reverses the most recent migration by running its reverse logic. Pass STEP=2 to roll back the last two. Rollback works well for fixing a migration you just wrote and have not shared. It is not a production recovery tool, a point we will return to later.

Checking status

bin/rails db:migrate:status

This prints a list of every migration with an up or down status. It is the quickest way to find out whether a migration actually ran in a given environment, and it shows migrations that exist in the table but whose files are missing (marked NO FILE).

Preparing a database

bin/rails db:prepare

This is a convenience command for setup. If the database does not exist, Rails creates it, loads the schema, and runs seeds. If it already exists, Rails runs any pending migrations. It is commonly used in setup scripts and container entrypoints, and Rails 8 apps use it in the generated bin/setup and Docker entrypoint.

Verifying a migration worked

Do not stop at “no errors.” After running a migration, check db/schema.rb for the expected column or index, run bin/rails db:migrate:status to confirm the version is up, and open bin/rails dbconsole or bin/rails console to inspect real data when the change involves it.

Command availability and behavior can vary by Rails version and application configuration. For example, apps with multiple databases have per-database tasks such as db:migrate:primary, and Rails 8 apps that use Solid Queue, Solid Cache, or Solid Cable with separate databases keep those migrations in their own directories. Run bin/rails -T db to see what your app supports.


Follow Rails Migration Naming Conventions

A migration name is documentation. Six months from now, a developer scanning db/migrate should understand what each file does without opening it. Compare these names:

  • AddStatusToOrders
  • CreateInvoices
  • RemoveLegacyFieldFromUsers

with vague alternatives like UpdateOrders or FixDatabase. The first set tells you the table and the operation. The second set tells you nothing and makes db:migrate:status output useless.

Rails also reads certain name patterns when generating code:

  • CreateX generates a create_table call.
  • AddColumnToTable generates add_column calls for the attributes you pass.
  • RemoveColumnFromTable generates remove_column calls.
  • AddCustomerToOrders customer:references generates an add_reference call.
  • CreateJoinTableXY generates a create_join_table call.

For example, this command:

bin/rails generate migration RemoveLegacyFieldFromUsers legacy_field:string

produces:

class RemoveLegacyFieldFromUsers < ActiveRecord::Migration[8.1]
def change
remove_column :users, :legacy_field, :string
end
end

Notice that the type is included. Rails needs it to reverse remove_column.

Generated code is a starting point, not a finished migration. The generator does not know your existing data, your database, or your performance needs. A command like AddCustomerToOrders customer:references produces add_reference :orders, :customer, null: false, foreign_key: true. That looks fine, but it fails on any table that already has rows, because existing orders have no customer value to satisfy null: false. Always read the generated file and adjust it before running it.


Keep Migrations Small and Focused

Each migration should ideally represent one logical schema change. Small migrations are easier to review, easier to debug when something fails, and easier to roll back without side effects.

Here is an overly broad migration:

class UpdateStoreSchema < ActiveRecord::Migration[8.1]
def change
create_table :coupons do |t|
t.string :code, null: false
t.timestamps
end

add_column :orders, :status, :string, default: "pending", null: false
rename_column :users, :name, :full_name
remove_column :products, :legacy_sku, :string
add_index :orders, :created_at
end
end

This migration mixes a new table, a new column, a rename, a destructive removal, and an index. If the fourth statement fails on MySQL, the first three are already committed and the migration is half applied. A reviewer also has to reason about five unrelated changes at once, and rolling back means undoing all of them even if only one caused the problem.

A cleaner approach splits each logical change into its own file:

# 20261019120000_create_coupons.rb
class CreateCoupons < ActiveRecord::Migration[8.1]
def change
create_table :coupons do |t|
t.string :code, null: false
t.timestamps
end
add_index :coupons, :code, unique: true
end
end

# 20261019120100_add_status_to_orders.rb
class AddStatusToOrders < ActiveRecord::Migration[8.1]
def change
add_column :orders, :status, :string, default: "pending", null: false
end
end

# 20261019120200_rename_name_to_full_name_on_users.rb
class RenameNameToFullNameOnUsers < ActiveRecord::Migration[8.1]
def change
rename_column :users, :name, :full_name
end
end

Grouping closely related changes is reasonable when they only make sense together. Creating a table and its indexes in the same migration is a good example, as shown in CreateCoupons above. The test is simple: if you would never want one half of the change without the other, keep them together.


Use the Right Migration Methods

Rails provides a rich set of schema methods. Knowing what each one does, and where it behaves differently across databases, helps you write migrations that work the first time.

create_table

class CreateInvoices < ActiveRecord::Migration[8.1]
def change
create_table :invoices do |t|
t.references :customer, null: false, foreign_key: true
t.string :number, null: false
t.integer :total_cents, null: false, default: 0
t.timestamps
end

add_index :invoices, :number, unique: true
end
end

t.references adds a customer_id column, an index on it by default, and the foreign key. Rails adds id as the primary key and created_at and updated_at through t.timestamps.

add_column, remove_column, and rename_column

add_column :users, :last_seen_at, :datetime
remove_column :users, :legacy_code, :string
rename_column :users, :name, :full_name

Pass the type to remove_column so Rails can reverse it. Be careful with rename_column on live applications: running code that still references the old name breaks the moment the migration finishes. We cover a safer approach in the production section.

add_index and remove_index

add_index :orders, :status
remove_index :orders, column: :status

Using column: in remove_index keeps the call reversible, because Rails knows which index to recreate.

add_reference and add_foreign_key

add_reference :orders, :coupon, foreign_key: true
add_foreign_key :orders, :customers

add_reference creates the column, an index, and optionally the foreign key in one call. add_foreign_key adds a constraint to an existing column and assumes a conventionally named customer_id column unless you pass column:. On SQLite, which has limited ALTER TABLE support, Rails works around some limitations by rebuilding the table, which can be slow on large tables.

change_column, change_column_null, and change_column_default

change_column :products, :sku, :string, limit: 64
change_column_null :users, :email, false
change_column_default :orders, :status, from: "pending", to: "draft"

change_column redefines the entire column, so any option you leave out (such as null: false or a default) may be dropped depending on the adapter. It also cannot be reversed automatically, because Rails does not know the previous definition. change_column_null and change_column_default are more targeted and are reversible, provided you pass from: and to: for defaults.

Behavior also varies by adapter. PostgreSQL 11 and later can add a column with a constant default without rewriting the table, while older versions and some other databases may rewrite every row. MySQL has its own online DDL behavior that depends on the operation and version. Rails does not hide these differences, so check your database documentation for any operation that touches a large table. For a deeper look at how the two most common Rails databases compare, read SQLite vs. PostgreSQL: which should you use.


Prefer Reversible Migrations

Rails gives you three ways to write a migration: change, up, and down.

  • change describes the forward change, and Rails figures out how to reverse it.
  • up and down let you write both directions explicitly.

Here is a reversible migration using change:

class AddStatusToOrders < ActiveRecord::Migration[8.1]
def change
add_column :orders, :status, :string, default: "pending", null: false
end
end

On db:rollback, Rails runs remove_column :orders, :status, :string, default: "pending", null: false. It can do that because it has every detail it needs.

Rails can automatically reverse many operations, including create_table, add_column, add_index, add_reference, add_foreign_key, rename_column, and change_column_null. But not every operation written inside change is reversible. Examples include change_column, remove_column without a type, execute with raw SQL, and change_column_default without from: and to:. When Rails cannot reverse a step, rollback raises ActiveRecord::IrreversibleMigration. On PostgreSQL and SQLite the failed rollback happens inside a transaction, so nothing changes, but you are left unable to roll back.

Using up and down

When you need explicit control, write both methods:

class ChangeOrdersNoteToText < ActiveRecord::Migration[8.1]
def up
change_column :orders, :note, :text
end

def down
change_column :orders, :note, :string
end
end

Even this is not a perfect round trip. If any note longer than 255 characters was saved while the column was text, rolling back to string may fail or truncate data depending on the database. A working down method is not the same as a safe one.

Using reversible blocks

If most of a migration is reversible but one step is not, use a reversible block inside change:

class AddNormalizedEmailToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :email_normalized, :string

reversible do |direction|
direction.up do
execute "UPDATE users SET email_normalized = LOWER(email)"
end
end
end
end

On the way up, the column is added and the SQL runs. On the way down, there is nothing to undo for the data step, and Rails drops the column. If you need a step to be explicitly blocked on rollback, raise the error yourself:

def down
raise ActiveRecord::IrreversibleMigration, "Original values were not preserved"
end

An honest IrreversibleMigration is better than a down method that pretends to restore data it cannot.


Avoid Editing Migrations That Have Already Run

Once a migration has run anywhere beyond your own machine, treat the file as history. Rails only runs a migration when its version is missing from schema_migrations. If you edit a migration that already ran, your change will not run again on databases that have recorded it.

Think about the different environments involved:

  • Local: Your database, which you can usually rebuild freely.
  • Shared: Teammates’ databases, which already contain the original migration’s result.
  • Staging and production: Databases that may hold real or important data and should never be rebuilt casually.

If you edit an applied migration, your machine, your teammate’s machine, and production can all end up with different schemas, even though db:migrate:status says every migration is up. Bugs from this kind of drift are hard to diagnose because the code looks correct.

The fix is to write a new migration that corrects or extends the earlier one:

bin/rails generate migration ChangeOrdersStatusDefaultToDraft
class ChangeOrdersStatusDefaultToDraft < ActiveRecord::Migration[8.1]
def change
change_column_default :orders, :status, from: "pending", to: "draft"
end
end

There is one exception. If a migration exists only on your local branch and has never been pushed or run elsewhere, you can edit it. Roll it back first with bin/rails db:rollback, edit the file, and run it again.

Resetting a disposable development database with bin/rails db:reset or db:drop db:create db:migrate is fine when no data matters. It is not an equivalent solution for shared or production databases, because it destroys data and bypasses the migration history those environments rely on.


Handle Data Migrations Carefully

A schema migration changes the structure of the database. A data migration changes the rows inside it, such as populating a new column or transforming legacy values. They are different kinds of work with different risks, and mixing them carelessly is a common source of slow or failing deployments.

A safe backfill example

Suppose you added a display_name column and need to fill it for existing users:

class BackfillUserDisplayNames < ActiveRecord::Migration[8.1]
disable_ddl_transaction!

class MigrationUser < ActiveRecord::Base
self.table_name = "users"
end

def up
MigrationUser.where(display_name: nil).find_each(batch_size: 1_000) do |user|
user.update_columns(display_name: user.email.split("@").first)
end
end

def down
# Nothing to undo: rolling back the migration that added the column drops it.
end
end

Several decisions here are deliberate.

A migration-local model. MigrationUser is defined inside the migration instead of using the app’s User model. If you later rename User, add validations, or remove an attribute, an old data migration that depends on the real model can break. A tiny, self-contained model keeps the migration working as the application evolves.

Batching. find_each loads 1,000 rows at a time instead of the whole table, which keeps memory use predictable. disable_ddl_transaction! lets each update commit on its own, so a long backfill does not hold one giant transaction open. The trade-off is that a failure partway through leaves some rows updated, so the backfill should be safe to run again. The where(display_name: nil) condition makes this one repeatable.

Skipping callbacks and validations. update_columns writes directly to the database without running callbacks, validations, or touching updated_at. That prevents emails from being sent and avoids failures from validations added after the data was created. It also means you must be sure the values are correct, because nothing checks them.

update_all and direct SQL

When the transformation can be expressed as a single statement, update_all or raw SQL is much faster than updating records one at a time:

MigrationUser.in_batches(of: 5_000) do |batch|
batch.update_all("email = LOWER(email)")
end

The trade-off is that you bypass Active Record entirely, so there are no type casts, callbacks, or validations, and raw SQL fragments may behave differently across databases. A single unbatched UPDATE on a huge table can also lock rows for a long time, which is why batching still matters.

Reversibility and staged work

Data changes are often not reversible. If a migration overwrites or deletes values, rolling back will not restore them unless you saved the originals somewhere, such as a backup column or table. Be honest about this in your down method.

For small tables, a data migration in the same deployment is fine. For large production tables, a single migration that updates every row is risky. It can run for minutes or hours, hold locks, and block your deploy. Instead, keep the schema migration small and fast, and move the backfill into a separate step: a one-off Rake task, a background job, or a gem such as data_migrate that tracks data migrations separately from schema migrations. Run it in batches, watch the database load, and finish it before the deployment that depends on the data.


Add Database Indexes Thoughtfully

Indexes make reads faster by letting the database find rows without scanning a whole table. They are not free. Every index uses storage and adds work to each insert, update, and delete. The goal is not to index everything, but to index what your queries actually need. This practical guide to database indexes in Rails explores the topic in depth.

Where indexes usually help

  • Columns used often in WHERE, JOIN, and ORDER BY clauses.
  • Foreign key columns, such as orders.customer_id, which are joined on constantly. add_reference and t.references add this index for you by default.
add_index :orders, :status

Unique indexes versus validations

A model validation like validates :email, uniqueness: true checks for duplicates before saving, but two requests can pass that check at the same moment and both insert. Only a unique index can guarantee uniqueness:

add_index :users, :email, unique: true

Use both. The validation gives users a friendly error, and the index protects the data. Your code can also rescue ActiveRecord::RecordNotUnique for the rare race.

Composite indexes and column order

A composite index covers multiple columns, and the order matters:

add_index :orders, [:customer_id, :created_at]

This index helps queries that filter by customer_id, or by customer_id and created_at together. It generally does not help queries that filter only by created_at, because the database uses the leftmost columns first. Put the column you filter by most often, or with equality, first.

Avoiding redundant indexes

The composite index above also covers lookups on customer_id alone, so a separate index on customer_id is usually redundant. Check for this before adding indexes, because redundant indexes cost write performance and storage without helping reads.

Choosing indexes from real queries

Do not guess. Look at the queries your application runs, then use EXPLAIN (or relation.explain in Rails) to see whether the database is scanning or using an index. The guide to optimizing Active Record queries covers how to find and measure those queries.

Large tables on PostgreSQL

A normal CREATE INDEX can block writes on the table while it builds. On PostgreSQL, you can avoid that with a concurrent build:

class AddIndexToOrdersOnCustomerIdAndCreatedAt < ActiveRecord::Migration[8.1]
disable_ddl_transaction!

def change
add_index :orders, [:customer_id, :created_at], algorithm: :concurrently
end
end

Two things to know. First, algorithm: :concurrently cannot run inside a transaction, which is why disable_ddl_transaction! is required. Second, if a concurrent build fails, PostgreSQL can leave behind an invalid index that you must drop before retrying.

This option is specific to PostgreSQL. MySQL and SQLite do not support algorithm: :concurrently in Rails, so do not copy this pattern for every adapter. If you run SQLite in production, see the guide to optimizing SQLite for Rails 8 production.


Protect Data Integrity With Database Constraints

Model validations run only when data goes through Active Record. Plenty of writes do not: update_all, raw SQL, bulk imports, console sessions, other services, and race conditions between concurrent requests. Validations are good for user feedback, but only the database can enforce rules for every write.

The main tools are:

  • null: false: Rejects missing values.
  • Unique indexes: Prevent duplicates.
  • Foreign keys: Prevent records that point to rows that do not exist.
  • Database defaults: Supply a value when none is given.
  • Check constraints: Enforce rules like “this number cannot be negative” (supported on PostgreSQL, MySQL 8.0.16 and later, and SQLite).

Together with validations, they look like this:

class Order < ApplicationRecord
belongs_to :customer
validates :total_cents, numericality: { greater_than_or_equal_to: 0 }
end
class AddIntegrityRulesToOrders < ActiveRecord::Migration[8.1]
def change
add_check_constraint :orders, "total_cents >= 0", name: "orders_total_cents_non_negative"
end
end

The model gives a clear error message, and the constraint guarantees the rule even if something bypasses the model.

Adding constraints to existing data

Adding a constraint to a table that already has violating rows fails, or worse, blocks the table while the database checks every row. Check and clean the data first.

To find orphaned records before adding a foreign key:

Order.where.missing(:customer).count

To find duplicates before adding a unique index:

User.group(:email).having("COUNT(*) > 1").count

For null: false, change_column_null accepts a fourth argument that fills existing NULL values first:

change_column_null :orders, :status, false, "pending"

That backfills every NULL row in one statement, which is fine for small tables. For big ones, backfill in batches in an earlier step.

On PostgreSQL, you can add foreign keys and check constraints without validating existing rows immediately, then validate them in a separate step:

class AddCustomerForeignKeyToOrders < ActiveRecord::Migration[8.1]
def change
add_foreign_key :orders, :customers, validate: false
end
end

class ValidateCustomerForeignKeyOnOrders < ActiveRecord::Migration[8.1]
def change
validate_foreign_key :orders, :customers
end
end

New writes are checked right away, and the slower validation of old rows happens later with a lighter lock. This is a PostgreSQL feature, so confirm what your database supports before relying on it.


Make Production Migrations Safer

A migration that takes a second on your laptop can take an hour on a production table with 50 million rows. Production migrations fail in ways development ones rarely do: table locks that block requests, long-running statements that time out a deploy, and mismatches between old running code and the new schema.

Remember that a migration does not give you zero-downtime deployments by itself. During a deploy, old and new versions of your application often run at the same time against the same database. Every schema change has to work for both.

Practical safeguards

  • Review the SQL. Check the SQL Rails runs in log/development.log and read your database’s documentation for how that statement locks the table.
  • Test against realistic data. Run migrations on a copy of production data, or a database with similar volume, to see how long they take.
  • Monitor duration and load. Watch database CPU, lock waits, and replication lag while the migration runs.
  • Plan for recovery. Know your backup situation before you deploy. Rolling back a migration does not restore deleted or overwritten data.
  • Do not drop things that running code still uses. Wait until no deployed version depends on the column or table.
  • Consider tooling. Gems such as strong_migrations can flag risky operations during development and in CI.

The expand-and-contract approach

For risky changes, such as renaming or replacing a column, split the work across several deployments. Imagine replacing orders.total (a decimal) with orders.total_cents (an integer).

1. Add the new column.

class AddTotalCentsToOrders < ActiveRecord::Migration[8.1]
def change
add_column :orders, :total_cents, :integer
end
end

Old code ignores the new column, so this is safe to deploy.

2. Deploy code that supports both columns. Write to both so new data stays consistent:

class Order < ApplicationRecord
before_save :sync_total_cents

private

def sync_total_cents
return unless will_save_change_to_total?

self.total_cents = total.present? ? (total * 100).round : nil
end
end

3. Backfill existing rows safely, in batches, outside the main deploy path if the table is large:

Order.where(total_cents: nil).in_batches(of: 1_000) do |batch|
batch.update_all("total_cents = ROUND(total * 100)")
end

4. Switch reads and writes to the new column. Deploy code that uses total_cents everywhere and stops writing to total.

5. Verify nothing depends on the old column. Search the codebase, check background jobs and reports, and confirm that every running version of the app is on the new code. Then tell Active Record to ignore the old column, so cached column lists in running processes cannot break when it disappears:

class Order < ApplicationRecord
self.ignored_columns += ["total"]
end

6. Remove the old column in a later deployment.

class RemoveTotalFromOrders < ActiveRecord::Migration[8.1]
def change
remove_column :orders, :total, :decimal, precision: 10, scale: 2
end
end

This is slower than one rename_column, but each step is safe on its own, and you can stop or roll back between steps. Note that even here, remove_column is reversible for the schema but not for the data: once dropped, the values are gone.


Understand Schema Files and Migration History

After you run migrations, Rails updates a schema file that describes the current state of the database. By default this is db/schema.rb, a Ruby representation of your tables, columns, indexes, and constraints. New developers and CI environments can load it with bin/rails db:schema:load (or db:prepare), which is much faster than replaying years of migrations.

Some projects use db/structure.sql instead, which is a raw SQL dump made by the database’s own tools. You enable it with config.active_record.schema_format = :sql. The choice matters because schema.rb cannot represent every database feature. Things like triggers, stored procedures, and some database-specific objects may be missing from it, and what is supported depends on your database and configuration. If your schema relies on such features, structure.sql is the safer choice, though it needs the database’s command-line tools installed and tends to produce noisier diffs.

Commit both your migration files and the updated schema file. Together they are the record of your database: migrations describe how you got there, and the schema file describes where you are.

As covered earlier, the schema_migrations table records which migration versions have run. Avoid editing the schema file by hand to “fix” a problem, and avoid inserting or deleting rows in schema_migrations directly. Rails regenerates the schema file from the real database after migrations, and manual changes create a mismatch between what the file says and what the database contains. If your migration history is genuinely broken, fix it with a new migration or by rebuilding a disposable environment, and be careful with anything shared.


Write Tests for Important Migrations

Not every migration needs a test. A one-line add_column is fully covered by running it locally and checking the schema. Tests earn their place when a migration transforms data or enforces rules that would be costly to get wrong.

Useful, proportionate safeguards include:

  • Verifying the result. After migrating, confirm the schema has the expected columns, indexes, and constraints.
  • Testing data transformations. Run the migration against representative records, including awkward ones such as blanks, odd formats, and duplicates.
  • Round-trip checks. For reversible migrations, run bin/rails db:migrate, then db:rollback, then db:migrate again.

A normal model test suite will not catch most migration problems, because tests run against the already-migrated schema. A data migration can be tested directly:

require "test_helper"
require Rails.root.join("db/migrate/20261019120000_backfill_user_display_names")

class BackfillUserDisplayNamesTest < ActiveSupport::TestCase
test "fills display_name from the email prefix" do
user = User.create!(email: "[email protected]")
user.update_columns(display_name: nil)

ActiveRecord::Migration.suppress_messages do
BackfillUserDisplayNames.new.migrate(:up)
end

assert_equal "ada", user.reload.display_name
end
end

Use the file name of your real migration in the require line. For migrations that change structure on large tables, a disposable database loaded with production-like data tells you more than any unit test.


Common Rails Migration Mistakes to Avoid

Editing migrations that have already run

Risk: Environments silently drift apart because already-applied migrations never run again.
Better: Write a new migration that corrects or extends the earlier one.

Writing huge, multi-purpose migrations

Risk: They are hard to review, and on MySQL a failure can leave the schema half changed.
Better: Keep each migration to one logical change.

Assuming all migrations are reversible

Risk: change_column, raw SQL, and remove_column without a type can make rollback fail exactly when you need it.
Better: Use up and down or reversible blocks, and raise IrreversibleMigration when a change truly cannot be undone.

Adding null: false without accounting for existing records

Risk: The migration fails on tables that already contain NULLs, or locks the table while checking.
Better: Check and backfill first, then add the constraint.

Forgetting indexes or foreign keys

Risk: Slow joins, full table scans, and orphaned records.
Better: Index foreign keys and the columns your real queries filter on, and add foreign keys where relationships must hold.

Creating redundant indexes

Risk: Extra storage and slower writes for no read benefit.
Better: Remember that a composite index covers its leftmost columns, and review existing indexes before adding new ones.

Performing massive production data updates in one deployment

Risk: Long locks, timeouts, and failed deploys.
Better: Batch the work and run it as a separate, monitored step.

Dropping columns before deployed code stops using them

Risk: Running application servers raise errors the moment the column vanishes.
Better: Use ignored_columns, deploy, then drop the column in a later release.

Assuming development performance predicts production behavior

Risk: A fast migration on 1,000 rows becomes an outage on 50 million.
Better: Test against realistic data volumes and read the database’s locking behavior.

Ignoring database-specific behavior

Risk: Options like algorithm: :concurrently or validate: false work on PostgreSQL but not everywhere, and DDL transactions are not universal.
Better: Check what your adapter supports before copying a pattern from another project.


Recommended Rails Migration Workflow

The exact process depends on your application, database, and infrastructure, but this workflow works as a solid default:

  1. Generate a descriptive migration. Use a name that states the table and the change.
  2. Inspect and adjust the generated code. Add defaults, indexes, and constraints, and fix anything that ignores existing data.
  3. Consider existing records and data integrity. Decide how current rows will satisfy new rules.
  4. Run the migration locally. Use bin/rails db:migrate.
  5. Verify the schema and expected data changes. Check db/schema.rb, db:migrate:status, and sample records.
  6. Run relevant tests. Include any migration-specific tests, and try a rollback if the migration is meant to be reversible.
  7. Review database-specific risks and generated SQL when appropriate. Pay extra attention to large tables and locking.
  8. Commit the migration and updated schema representation. Commit them together.
  9. Deploy using a strategy that fits the risk and size of the change. Small changes can ship normally, while risky ones may need expand-and-contract across several releases.
  10. Monitor production and verify the result. Watch duration, errors, and database load, and confirm the change did what you expected.

Conclusion

Good Rails migration practices are about more than making a schema change work on your machine. They are about keeping your team’s databases consistent, protecting the data your users trust you with, and deploying changes without surprising anyone, including the production database. Small, focused, well-named migrations, careful handling of data, thoughtful indexes and constraints, and staged changes for risky work add up to an application that stays maintainable and reliable as it grows.

If you want to keep building these habits, explore more practical Ruby on Rails tutorials on CodeCurious.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Coder. Apprendre. Progresser.

Une newsletter conviviale où je partage des conseils, des leçons et des réussites de mon parcours de développeur.