← Back to list

ActiveRecord Migrations

Still exploring Rails fundamentals and documenting my notes as I go. This one is on ActiveRecord migrations — how Rails lets you evolve…

Abdo Amin · 2026-06-07 13:48 · 0 claps · 4.0 min read
Open on Medium ↗
Wiki topics: 🌐 · Web Development

ActiveRecord Migrations

Still exploring Rails fundamentals and documenting my notes as I go. This one is on ActiveRecord migrations — how Rails lets you evolve your database over time without hand-writing SQL.

Definition

It’s an ActiveRecord feature that lets you evolve your database schema over time. Instead of writing SQL schema modifications, ActiveRecord lets you describe changes to the schema using Ruby.

Think of each migration as a new version of the database — each one writes changes to the schema. And think of the schema as the structure of the database (its tables, columns, and indexes — not the data inside).

A migration file lives in the db/migrate folder, and Rails uses a convention where a timestamp is prefixed to the file name. This timestamp tells Rails which order to run migrations in.

The format is YYYYMMDDHHMMSS - Year, Month, Day, Hours, Minutes, Seconds.

Run the following command to generate a migration file:

rails generate migration CreateBooks

A file will be created in db/migrate matching the migration name:

# 20260605232747_create_books.rb
class CreateBooks < ActiveRecord::Migration[8.1]
  def change
    create_table :books do |t|
      t.timestamps
    end
  end
end

Inside this block, define the attributes you want. In our case, we create a books table with a title:

class CreateBooks < ActiveRecord::Migration[8.1]
  def change
    create_table :books do |t|
      t.string :title
      t.timestamps
    end
  end
end

Run rails db:migrate and db/schema.rb will be updated to include the books table in the new version of the database.

Generating Migrations

1. Table generation

Format: Create<TableName>

rails generate migration CreateBooks

This results in what we saw before:

class CreateBooks < ActiveRecord::Migration[8.1]
  def change
    create_table :books do |t|
      t.timestamps
    end
  end
end

2. Column creation

Format: Add<Column(s)>To<TableName> <column:type> <...rest>

rails generate migration AddTitleAndYearToBook title:string year:string
class AddTitleAndYearToBook < ActiveRecord::Migration[8.1]
  def change
    add_column :books, :title, :string
    add_column :books, :year, :string
  end
end

3. Column removal

Format: Remove<Column(s)>From<TableName> <column:type> <...rest>

Note: you need to specify the column type when removing a column, so the migration can be reversed in case of a rollback.

rails generate migration RemoveYearFromBook year:string
class RemoveYearFromBook < ActiveRecord::Migration[8.1]
  def change
    remove_column :books, :year, :string
  end
end

4. Associations

There are mainly two use cases.

a) Establish a relationship between two tables by creating a foreign key reference in one of them.

rails g migration AddAuthorToBooks author:belongs_to
class AddAuthorToBooks < ActiveRecord::Migration[8.1]
  def change
    add_reference :books, :author, null: false, foreign_key: true
  end
end

In schema.rb it added three things:

t.bigint "author_id", null: false
t.index ["author_id"], name: "index_books_on_author_id"
# ...rest
add_foreign_key "books", "authors"

author_id references the authors table's primary key (id).

Which brings us to: what is an index, and how is it used? An index in a book is a section that lists topics with the page numbers where they appear. Indexing a column creates a separate data structure (e.g. index_books_on_author_id) that keeps that column's values sorted, each pointing to its row - so the database can find matches without scanning the whole table.

b) Establish a join table between two tables. A join table stores foreign keys referencing two other tables — in the example below, both author_id and book_id.

You can also add two composite indexes for fast lookups in both directions. t.index [:author_id, :book_id] gives a fast lookup to find all books for an author, while t.index [:book_id, :author_id] gives a fast lookup to find the authors of a book.

rails g migration CreateJoinTableAuthorsBooks authors books
class CreateJoinTableAuthorsBooks < ActiveRecord::Migration[8.1]
  def change
    create_join_table :authors, :books do |t|
      t.index [:author_id, :book_id]
      t.index [:book_id, :author_id]
    end
  end
end

Migration Methods

1. Creating a table

create_table creates a table by the name provided and takes a block to describe its columns. The code below creates a products table with a name column:

create_table :products do |t|
  t.string :name
end

By default it creates a primary key column called id. You can rename it with the :primary_key option:

create_table :products, primary_key: 'product_id' do |t|
  t.string :name
end

To omit the primary key entirely, pass id: false:

create_table :products, id: false do |t|
  t.string :name
end

2. Creating an association

Via create_table:

def change
  create_table :products do |t|
    t.belongs_to :category
  end
end

This creates a category_id column, which we'll use to query for the category later:

product = Product.find(1)
# SELECT * FROM products WHERE id = 1 LIMIT 1;
category = product.category
# SELECT * FROM categories WHERE id = <product.category_id> LIMIT 1;

Or directly:

def change
  add_reference :users, :role
end

Removing would be:

def change
  remove_reference :products, :user
end

3. Creating a join table

create_join_table :products, :categories

This creates a join table named (by default) categories_products - the two names ordered alphabetically - with two columns, category_id and product_id. You can customize the name with the :table_name option:

create_join_table :products, :categories, table_name: :categorization

create_join_table also accepts a block to add indexes or additional columns:

create_join_table :products, :categories do |t|
  t.index :product_id
  t.index :category_id
end

4. Changing tables

change_table :products do |t|
  t.remove :description, type: :string
  t.string :title
  t.index :part_number
  t.rename :qrcode, :qr_code
end

5. Changing columns

change_column :books, :year, :integer
change_column_default :books, :published, from: true, to: false
change_column_null :books, :author_id, false

6. Reversible actions

When a migration runs forward it goes in the “up” direction; when you roll it back it goes “down”. For simple changes, Rails works out the down direction on its own. But some operations can’t be auto-reversed — change_column is the classic example, because Rails has no way of knowing the original type to restore. For those, you spell out both directions yourself.

Using reversible:

class ChangeBooksPrice < ActiveRecord::Migration[8.1]
  def change
    reversible do |direction|
      direction.up   { change_column :books, :price, :string }
      direction.down { change_column :books, :price, :integer }
    end
  end
end

Separate up and down:

class ChangeBooksPrice < ActiveRecord::Migration[8.1]
  def up
    change_column :books, :price, :string
  end
  def down
    change_column :books, :price, :integer
  end
end

Both do the same job — they tell Rails exactly how to undo the change. Reach for reversible when you want everything in one change method, and for separate up/down when the two directions are different enough that splitting them reads more clearly.

Wrapping up

That’s the slice of the migration toolkit I reach for most: generating migrations, adding and removing columns, wiring up associations and join tables, changing tables and columns, and keeping it all reversible. Migrations are version control for your schema — treat each one as a small, reversible step and your database history stays easy to read and easy to roll back.


메타데이터
post_id
b48ebee00e6e
slug
activerecord-migrations-b48ebee00e6e
url
https://medium.com/@abdoamin/activerecord-migrations-b48ebee00e6e
canonical_url
https://medium.com/@abdoamin/activerecord-migrations-b48ebee00e6e
author_url
https://medium.com/@abdoamin
status
ok
fetched_at
2026-06-16 19:09:56