Introduction
Schema migrations change your database structure. Data migrations transform existing data to match new requirements. Seed files populate the database with initial or reference data. Understanding the difference and knowing the right strategies for each is critical for maintaining production databases.
Key Concepts
- Data migration: A migration that modifies existing data (backfills, transforms, cleans up) rather than changing the schema.
- Seed file: A script (
db/seeds.rb) that populates the database with initial data needed for the application to function. - Idempotent migration: A migration that can be run multiple times without producing different results or errors.
in_batches: Processes large datasets in chunks to avoid memory issues and long-running transactions.
Real World Context
When you add a full_name column, you need to backfill it from first_name and last_name. When you change a status from string to enum, you need to transform existing values. When you split a table, you need to copy data. These are all data migrations. Getting them wrong can mean hours of downtime or lost data.
Deep Dive
Data Migration Strategies
Inline Data Migration (Small Tables)
For small tables (under 10,000 rows), you can transform data directly:
rubyclass BackfillUserFullName < ActiveRecord::Migration[8.1] def up execute <<-SQL UPDATE users SET full_name = first_name || ' ' || last_name WHERE full_name IS NULL SQL end def down # Data migration -- no reverse needed end end
Using raw SQL is fastest for simple transformations and avoids loading Active Record objects.
Batch Data Migration (Large Tables)
For large tables, process in batches to avoid locking and memory issues:
rubyclass BackfillLargeTable < ActiveRecord::Migration[8.1] disable_ddl_transaction! def up say_with_time "Backfilling records" do LargeModel.unscoped.in_batches(of: 1000) do |batch| batch.update_all(new_column: "default_value") sleep(0.1) # Reduce database load end end end end
The sleep(0.1) between batches prevents overwhelming the database on high-traffic systems. The say_with_time method logs progress in the migration output.
Seed Files
The db/seeds.rb file populates initial data:
ruby# db/seeds.rb # Idempotent: find_or_create_by prevents duplicates Role.find_or_create_by!(name: "admin") do |role| role.description = "Full system access" end Role.find_or_create_by!(name: "editor") do |role| role.description = "Content management" end Role.find_or_create_by!(name: "viewer") do |role| role.description = "Read-only access" end # Environment-specific seeds if Rails.env.development? 10.times do |i| User.find_or_create_by!(email: "user#{i}@example.com") do |user| user.name = "Test User #{i}" user.role = Role.find_by!(name: "editor") end end end
Key principle: seeds must be idempotent. Running rails db:seed twice should not create duplicate data or raise errors.
Idempotent Migrations
rubyclass BackfillSafely < ActiveRecord::Migration[8.1] def up # Only update rows that haven't been backfilled execute <<-SQL UPDATE orders SET normalized_status = LOWER(status) WHERE normalized_status IS NULL SQL end end
The WHERE normalized_status IS NULL clause makes this safe to run multiple times.
Separating Schema and Data Migrations
ruby# Migration 1: Schema change class AddNormalizedEmailToUsers < ActiveRecord::Migration[8.1] def change add_column :users, :normalized_email, :string add_index :users, :normalized_email, unique: true end end # Migration 2: Data backfill (separate file) class BackfillNormalizedEmail < ActiveRecord::Migration[8.1] disable_ddl_transaction! def up User.unscoped.in_batches(of: 5000) do |batch| batch.update_all("normalized_email = LOWER(email)") end end end
Separating schema and data migrations makes each one simpler, reversible, and easier to debug.
Common Pitfalls
- Using Active Record models in migrations -- Model classes can change between when the migration was written and when it runs. Prefer raw SQL or use
reset_column_informationbefore accessing model attributes. - Non-idempotent seeds -- Using
create!instead offind_or_create_by!causes errors on re-run. Always make seeds idempotent. - Large single-transaction data migrations -- Updating 10 million rows in one transaction can lock the table for minutes. Use
disable_ddl_transaction!andin_batches.
Best Practices
- Make data migrations idempotent -- Use
WHERE column IS NULLorfind_or_create_byso they can be safely re-run. - Separate schema and data migrations -- One migration for the DDL change, another for the data backfill.
- Use raw SQL for performance --
batch.update_all("col = value")is much faster than loading records into Ruby.
Summary
- Data migrations transform existing data; schema migrations change structure.
- Use
in_batcheswithdisable_ddl_transaction!for large table backfills. - Seed files must be idempotent using
find_or_create_by!. - Separate schema and data migrations for clarity and easier debugging.
- Prefer raw SQL over Active Record objects in migrations for performance and stability.
Code Examples
# Idempotent seed file
Role.find_or_create_by!(name: "admin") do |role|
role.description = "Full system access"
end
# Batch data migration
class BackfillStatus < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def up
Order.unscoped.where(status: nil).in_batches(of: 1000) do |batch|
batch.update_all(status: "pending")
end
end
end