rubyguides

Rails Migrations: Versioning Database Changes in Ruby

Rails migrations give you a way to describe your database structure in Ruby code. Instead of writing raw SQL to create a table, you call a Ruby method. Rails translates that into the right SQL for whichever database you are using. Migration files live in db/migrate/ and are numbered in the order they run. That number is a timestamp, which keeps two developers from creating conflicting migration numbers.

intro context

Rails migrations are the bridge between your Ruby models and the actual database schema. They let you change structure in a repeatable, reviewable way instead of opening a SQL console and making ad hoc changes by hand. If your team shares the same migration history, everyone can move forward or roll back the same changes with the same command.

Think of migrations as a change log for the schema. A table creation, a new index, a renamed column, or a foreign key constraint all become small Ruby files that explain what changed and when. That is what makes Rails migrations feel safe enough for everyday development but structured enough for production deploys.

The other big advantage is that migrations make future work less risky. When you can see the exact step that introduced a column or constraint, it becomes easier to understand why a query behaves a certain way. That history is also useful during code review because schema changes are explicit instead of hidden inside a long branch diff.

tl;dr

  • Use change for simple reversible schema updates.
  • Use up and down when Rails cannot infer the reverse operation.
  • Prefer one migration per schema change so history stays easy to review.
  • Keep data changes out of migrations unless you really need them for bootstrapping.

creating tables

The most common migration you will write creates a table. The create_table method takes the table name and a block:

class CreateArticles < ActiveRecord::Migration[7.0]
  def change
    create_table :articles do |t|
      t.string :title, null: false
      t.text :body
      t.integer :views, default: 0
      t.boolean :published, default: false
      t.timestamps
    end
  end
end

The t.timestamps line adds created_at and updated_at columns automatically. Rails manages those columns for you whenever you save a record.

That convenience matters because almost every table ends up needing those fields. You get a little more consistency for free, and your models stay easier to sort and debug when you know the timestamps are always there.

You can skip the auto-incrementing primary key with id: false, or supply a custom primary key name. You can also pass if_not_exists: true to prevent Rails from raising an error if the table already exists when running in production or a shared development environment.

When you create a table, keep the first migration as small as possible. Add the columns that define the core shape of the data first, then follow up with indexes, foreign keys, or constraints in separate migrations if you need them. That habit makes the history easier to read and it gives you a cleaner rollback path when a later change needs to be adjusted.

adding and removing columns

Once a table exists, you will often need to add a column later. After launching your articles table, you might decide you need a slug column for URL-friendly article titles:

class AddSlugToArticles < ActiveRecord::Migration[7.0]
  def change
    add_column :articles, :slug, :string
    add_index :articles, :slug, unique: true
  end
end

Removing a column works the same way:

The timestamps helper adds created_at and updated_at automatically, which almost every table benefits from. When you create a table, keep the first migration as small as possible — add the columns that define the core shape first, then follow up with indexes, foreign keys, or constraints in separate migrations. That habit makes the history easier to read and gives you a cleaner rollback path.

remove_column :articles, :slug

You can rename columns if you misnamed something early on:

Adding a column is straightforward, but think about what the existing data already contains. A new non-null column may need a default value, and a renamed column typically requires a follow-up code change in the model or view layer. Small schema changes are easy to write, but they still need to match the data that already exists in the table.

rename_column :users, :login_count, :sign_in_count

Changing a column is possible but requires a bit more care. change_column works inside up/down methods rather than change, because Rails cannot always reverse it automatically:

Removing a column is a one-liner in a migration file, but renaming gives you more precision when the original name no longer fits. Once you rename a column in the database, any Ruby code that references the old name must be updated at the same time. ActiveRecord does not automatically rewrite your queries or validations to match the new column name, so a rename usually comes with a matching code change.

class ChangeTitleLimit < ActiveRecord::Migration[7.0]
  def up
    change_column :articles, :title, :string, limit: 500
  end

  def down
    change_column :articles, :title, :string, limit: 255
  end
end

When you add or remove columns, think about what the existing data already looks like. A new non-null column may need a default, and a renamed column may require a follow-up code change in the model or view layer. Small schema changes are easy to write, but they still need to match the data that already exists.

the change method vs up and down

Most simple migrations use change. Rails inspects the operation and knows how to reverse it. add_column reverses to remove_column, create_table reverses to drop_table, and add_index reverses to remove_index.

Some operations are not reversible, however. If you delete rows as part of a migration, Rails cannot know how to restore them. In those cases, write up and down explicitly:

class RemoveLegacyData < ActiveRecord::Migration[7.0]
  def up
    execute "DELETE FROM articles WHERE legacy = 1"
  end

  def down
    # You must restore the data manually if you ever roll back
    execute "INSERT INTO articles (legacy) VALUES (1)"
  end
end

When you need conditional logic inside a reversible migration, use the reversible block:

Renaming preserves the data while updating the label, which is safer than dropping and recreating the column from scratch. Changing a column type or limit requires more care because Rails cannot always reverse it automatically. The example below shows why change_column belongs inside explicit up and down methods: the framework needs to know how to restore the previous type if the migration is ever rolled back.

class ManageTrigger < ActiveRecord::Migration[7.0]
  def change
    reversible do |dir|
      dir.up   { execute "ENABLE TRIGGER audit_trigger" }
      dir.down { execute "DISABLE TRIGGER audit_trigger" }
    end
  end
end

The change method is the simplest option because Rails can reverse it automatically when the operation is obvious. Reach for up and down when you are doing something Rails cannot infer, such as raw SQL, data deletion, or other one-way changes.

references and foreign keys

Rails offers two ways to create a foreign key relationship. The table builder methods t.references and t.belongs_to are equivalent and create a column named <association>_id with an index:

create_table :comments do |t|
  t.references :article, foreign_key: true, null: false
  t.text :body
  t.timestamps
end

This produces an article_id column on comments with a foreign key constraint pointing back to articles. The polymorphic: true option is useful for comments that can belong to multiple models:

When a migration involves data changes or raw SQL statements, the change method cannot guess how to reverse the operation. Writing up and down explicitly gives you full control over both directions and makes the intent clear to anyone reading the migration later. The reversible block shown next is an alternative approach that keeps the structure closer to change while still handling irreversible operations.

add_reference :comments, :commentable, polymorphic: true, index: true

This adds both commentable_type and commentable_id columns.

The standalone add_reference method creates the same column outside a table creation block:

The up and down pattern is explicit but verbose. For migrations where most operations are reversible except one or two, the reversible block lets you keep the change method while marking the exceptions with dir.up and dir.down blocks. This keeps the file shorter and makes the developer’s intent easier to spot during a code review.

add_reference :articles, :author, foreign_key: true, null: false

If you already have a column and want to add the foreign key constraint separately, use add_foreign_key:

Database constraints protect data integrity even when application code misses a validation. A direct SQL script, a background job, or a maintenance task can still write bad rows if the database does not enforce the relationship. A foreign key keeps the schema honest at the database level, which is usually cheaper than cleaning up inconsistent data later in a support ticket.

add_foreign_key :articles, :authors, column: :author_id,
                                    name: :fk_articles_author,
                                    on_delete: :cascade

Database constraints are worth adding early because they protect data even when application code misses something. A direct SQL script, a background job, or a future maintenance task can still write bad rows if the database does not enforce the relationship. A foreign key keeps the schema honest, and that is usually cheaper than cleaning up inconsistent data later.

These helpers are worth using because they keep the relationship rules close to the schema itself. The database can enforce the rule even if a buggy Ruby path forgets to validate it first.

running and rolling back migrations

The rake task rails db:migrate runs all pending migrations in order. To check what is running, rails db:migrate:status shows each migration with an up or down indicator:

rails db:migrate:status

If you made a mistake and need to undo the last migration, rails db:rollback drops the last batch. You can roll back more than one step with STEP=n:

The standalone add_reference method creates the column and index outside a table creation block. If you already have a column and only need the foreign key constraint by itself, add_foreign_key gives you finer control: you can set the constraint name, define the cascade behavior, and specify the exact column to reference without touching the column definition.

rails db:rollback STEP=3

To migrate to a specific version, pass the timestamp:

Seeds are for initial or sample data, not for structural schema work. A migration changes the shape of the database; a seed file fills that shape with starter rows. Keeping them separate means you can run seeds independently of the migration chain, which is useful for development environments where you want fresh sample data without replaying every schema change.

rails db:migrate VERSION=20260329100000

If a migration fails partway through on PostgreSQL or MySQL, the entire migration rolls back automatically because those databases support transactional DDL. SQLite has limited support for transactional schema changes, so certain operations may leave partial results on failure.

Once the schema is in place, the next step is to see how Rails moves that data through the app. The Rails MVC pattern tutorial shows how the model layer, controller layer, and view layer all depend on the tables you define here. That makes migrations easier to remember because every change eventually feeds a request, form, or page render.

That difference is why production teams pay close attention to the database adapter before they deploy a migration. The Ruby code may look the same, but the rollback behavior underneath it can be very different.

seeding data

Migrations are for structure, not data. When you need initial data, use db/seeds.rb:

10.times do |i|
  Article.create!(
    title: "Article #{i + 1}",
    slug: "article-#{i + 1}",
    published: i.even?
  )
end

Run it with rails db:seed. It is not part of the migration chain, so it does not run automatically when you run rails db:migrate.

Use seeds for sample or bootstrap data, not for structural work. A migration should change the shape of the database; a seed file should fill that structure with starter rows.

common gotchas

Changing a column with change_column requires up/down methods. Rails will warn you if you try to use it inside change.

Column order matters in some databases. Adding a non-null column without a default to an existing table with rows will fail on most databases because every existing row needs a value. Add the column first with a default or nullable, populate the data, then tighten the constraint in a separate migration.

Avoid editing migrations that have already run on other machines. Instead, write a new migration to correct the schema. Shared migration history is a shared contract.

Another good rule is to favor reversible changes whenever possible. If you can express a change with change, Rails can often reverse it without extra code. When you need up and down, keep the logic explicit and keep the rollback path realistic. That makes deploys safer because a bad migration can be undone without guessing what the database should look like.

See Also