Ruby on Rails Module 2 – Active Record: models and the database
Migrations and the database schema
Write reversible Rails migrations, run and roll them back, seed data twice safely, and let the database refuse bad rows with constraints.
What you will learn
- Write reversible migrations that create tables with references, indexes, foreign keys and check constraints
- Run, roll back and inspect migrations with db:migrate, db:rollback and db:migrate:status
- Choose change, reversible or up and down for a step that Active Record cannot undo by itself
- Explain what db/schema.rb holds, and when structure.sql is needed instead
- Load development data repeatably with idempotent seeds, db:seed and db:seed:replant
Before you start
What you’ll learn · Before you start
What you will learn
- Write reversible migrations that create tables with references, indexes, foreign keys and check constraints
- Run, roll back and inspect migrations with db:migrate, db:rollback and db:migrate:status
- Choose change, reversible or up and down for a step that Active Record cannot undo by itself
- Explain what db/schema.rb holds, and when structure.sql is needed instead
- Load development data repeatably with idempotent seeds, db:seed and db:seed:replant
Before you start
On this page
A 32-line migration: A Ruby file in db/migrate/ that changes the database schema one step, named with the time it was made so that every database runs the steps in the same order. More in “The Rails command line, console and generators” is enough to build Kaksha’s four tables, with 6 indexes, 3 foreign key: A column whose values must match a row of another table, enforced by the database, so a batch cannot point at a teacher who does not exist. and a rule that no batch fee can be negative, and Rails can undo it again with one command. Where those rules live decides whether a bad row can ever get in: Ruby code in a model can be skipped, a rule in the database cannot.
This lesson works from recorded runs in the Kaksha app. You will run and roll back migrations and read the schema they leave behind, choose how to write a step that Rails cannot undo by itself, set up and seed a database so that doing it twice is harmless, and watch the database itself refuse a negative fee, a repeated phone number and an orphaned row.
A migration is one numbered step that Rails can run and undo
Here is the migration that gives Kaksha its first tables. It is a Ruby file in db/migrate/. The start of the file
name is the moment it was made, in UTC, and the class inside is named after the rest of the name, CreateKakshaTables:
class CreateKakshaTables < ActiveRecord::Migration[8.1]
def change
create_table :teachers do |t|
t.string :name, null: false
t.string :phone, null: false
t.timestamps
end
add_index :teachers, :phone, unique: true
create_table :batches do |t|
t.string :name, null: false
t.references :teacher, null: false, foreign_key: true
t.integer :fee_paise, null: false, default: 0
t.timestamps
t.check_constraint "fee_paise >= 0", name: "batches_fee_not_negative"
end
create_table :students do |t|
t.string :name, null: false
t.string :phone, null: false
t.timestamps
end
add_index :students, :phone, unique: true
create_table :enrollments do |t|
t.references :student, null: false, foreign_key: true
t.references :batch, null: false, foreign_key: true
t.timestamps
end
add_index :enrollments, [:student_id, :batch_id], unique: true
end
end The change method describes the step forwards. Each t. line becomes a column, null: false becomes NOT NULL, and
t.references :teacher adds a teacher_id column with an index, which foreign_key: true ties to the teachers
table. A second file, AddCapacityToBatches, adds a column and a check to batches. Rails runs the files in the order
of their numbers and remembers which ones it has run in a table called schema_migrations. This session runs bin/rails db:migrate on a new database, then asks for the status of each file:
# Two migrations of the Kaksha app run with db:migrate (the numbers in their file names are the moments they were
# made, so they print as "…"), and the status of each afterwards.
require_relative "session"
Session.run(<<~SH, masks: [[/== \d{14} (\w+): (migrating|migrated|reverting|reverted)(?: \(…s\))? =+/, "== … \\1: \\2 ====="], [/-> \d+\.\d+s/, "-> …s"], [/\b\d{14}\b/, "…"]])
cd ../kaksha
bin/rails db:migrate
bin/rails db:migrate:status
SH Output
$ cd ../kaksha
$ bin/rails db:migrate
== … CreateKakshaTables: migrating =====
-- create_table(:teachers)
-> …s
-- add_index(:teachers, :phone, {unique: true})
-> …s
-- create_table(:batches)
-> …s
-- create_table(:students)
-> …s
-- add_index(:students, :phone, {unique: true})
-> …s
-- create_table(:enrollments)
-> …s
-- add_index(:enrollments, [:student_id, :batch_id], {unique: true})
-> …s
== … CreateKakshaTables: migrated =====
== … AddCapacityToBatches: migrating =====
-- add_column(:batches, :capacity, :integer, {null: false, default: 30})
-> …s
-- add_check_constraint(:batches, "capacity > 0", {name: "batches_capacity_positive"})
-> …s
== … AddCapacityToBatches: migrated =====
$ bin/rails db:migrate:status
database: storage/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up … Create kaksha tables
up … Add capacity to batches
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby migrate_session.rb
Each == line frames one migration, and each -- line is one schema call that change made, with the time it took
(printed as …s here, like the version numbers, which print as …). The status table lists every file with up once
its version is recorded in schema_migrations; a file that has not run is down. The same command also rewrote the
schema file: The snapshot of the database structure that Rails keeps in the repository, db/schema.rb by default; a new database is built from it instead of replaying every migration., db/schema.rb, to match the database. Step through one migration forwards and
back, and watch the four boxes: the file, the table, the record of versions and the snapshot in git:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The migration waits. AddCapacityToBatches sits in db/migrate/, but its version is in no table yet, so db:migrate:status lists it as down. Nothing has changed in the database.
- db:migrate runs change. Rails calls change: add_column gives batches a capacity column with default 30, then add_check_constraint adds the rule capacity > 0.
- The version is recorded. Rails inserts the version of the file into schema_migrations, which is how it knows on the next run that this step has been done.
- schema.rb is rewritten. Rails reads the database back and rewrites db/schema.rb, stamped with the newest version. The file now has two more lines, the capacity column and its check.
- db:rollback runs it backwards. Rails inverts each step and runs them in the opposite order: remove_check_constraint first, then remove_column. The recorded rollback printed them in that order.
- The version and snapshot follow. The version row is deleted, so the status is down again, and schema.rb loses exactly the two lines that the diff of the recorded session listed.
bin/rails db:rollback undoes the newest migration. STEP=3 undoes three, and bin/rails db:migrate:redo rolls the
newest one back and migrates it again. This session migrates, keeps a copy of schema.rb, rolls back, and compares the
copy with the file Rails wrote afterwards:
# db:rollback undoes the newest migration. The schema.rb saved after db:migrate is compared with the one Rails
# rewrote after the rollback.
require_relative "session"
Session.run(<<~SH, fails: ["diff -I 'define[(]version' ../schema-after-migrate.rb db/schema.rb"], masks: [[/== \d{14} (\w+): (migrating|migrated|reverting|reverted)(?: \(…s\))? =+/, "== … \\1: \\2 ====="], [/-> \d+\.\d+s/, "-> …s"], [/\b\d{14}\b/, "…"]])
cd ../kaksha
bin/rails db:migrate > /dev/null
cp db/schema.rb ../schema-after-migrate.rb
bin/rails db:rollback
diff -I 'define[(]version' ../schema-after-migrate.rb db/schema.rb
SH Output
$ cd ../kaksha
$ bin/rails db:migrate > /dev/null
$ cp db/schema.rb ../schema-after-migrate.rb
$ bin/rails db:rollback
== … AddCapacityToBatches: reverting =====
-- remove_check_constraint(:batches, "capacity > 0", {name: "batches_capacity_positive"})
-> …s
-- remove_column(:batches, :capacity, :integer, {null: false, default: 30})
-> …s
== … AddCapacityToBatches: reverted =====
$ diff -I 'define[(]version' ../schema-after-migrate.rb db/schema.rb
20d19
< t.integer "capacity", default: 30, null: false
22d20
< t.check_constraint "capacity > 0", name: "batches_capacity_positive"
[exit status 1]
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby rollback_session.rb
The rollback printed its steps in the opposite order to the migration: the
check constraint: A rule written as a condition on a table, such as fee_paise >= 0, that the database tests on every insert and update and refuses the write when it is false. went first, then the column. The diff lists two lines, the column
and the check, so the schema file is back to what it was before AddCapacityToBatches. The line with the version at the top of the file was left out of the comparison, since it
changes with every migration, and the [exit status 1] under the output is how diff reports that two files differ,
which is exactly what the session asked it to show.
Two more commands aim at one migration by its version, the number at the start of its file name. bin/rails db:migrate:up VERSION=… runs that migration alone and db:migrate:down VERSION=… undoes it alone. Rails does nothing
if the migration is already in the state you asked for, and it raises an error for a version that has no file. All of
these commands work on the development database unless you name another environment, as in RAILS_ENV=test.
Commit schema.rb with the migration
Put db/schema.rb in the same commit as the migration that changed it. When a teammate reads the change in a pull
request, the diff of that file shows the change to the database in a form quicker to read than the migration, and when
two branches both change it, running bin/rails db:migrate again writes the file afresh.
Seen from the files, the step has three parts that always move together: the migration file, the rows of
schema_migrations, and the lines of schema.rb.
Key idea
A migration is a numbered step: Rails runs the steps whose versions are not yet in schema_migrations, in order, and
rewrites db/schema.rb to match the database afterwards.
Checkpoint
Check: A migration as a numbered step
Which steps undo themselves, and what to write for the rest
Rolling back works because Rails can work out the opposite of each step in change: the opposite of create_table is
drop_table, of add_column is remove_column, of add_index is remove_index. A step with no single opposite
breaks that. This program widens fee_paise from integer to bigint in three ways, and rolls each one back:
# Which migration steps Active Record can undo by itself. A change that calls change_column cannot be rolled back
# (it does not know the old type); the same change written with up and down can, and reversible does the same in
# the middle of a change method.
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
create_table(:batches) { |t| t.string :name, null: false }
end
class AddFee < ActiveRecord::Migration[8.1]
def change
add_column :batches, :fee_paise, :integer, null: false, default: 0
end
end
class WidenFeeWithChange < ActiveRecord::Migration[8.1]
def change
change_column :batches, :fee_paise, :bigint, null: false, default: 0
end
end
class WidenFeeWithUpDown < ActiveRecord::Migration[8.1]
def up
change_column :batches, :fee_paise, :bigint, null: false, default: 0
end
def down
change_column :batches, :fee_paise, :integer, null: false, default: 0
end
end
class WidenFeeWithReversible < ActiveRecord::Migration[8.1]
def change
reversible do |direction|
direction.up { change_column :batches, :fee_paise, :bigint, null: false, default: 0 }
direction.down { change_column :batches, :fee_paise, :integer, null: false, default: 0 }
end
end
end
def fee_type = ActiveRecord::Base.connection.columns(:batches).find { |c| c.name == "fee_paise" }&.sql_type || "(no column)"
def run(migration, direction)
migration.new.migrate(direction)
puts "#{migration.name} #{direction}: fee_paise is #{fee_type}"
rescue ActiveRecord::IrreversibleMigration => error
puts "#{migration.name} #{direction}: #{error.class}"
error.message.strip.each_line { |line| puts " #{line.chomp}" }
end
run(AddFee, :up)
puts
[WidenFeeWithChange, WidenFeeWithUpDown, WidenFeeWithReversible].each do |migration|
run(migration, :up)
run(migration, :down)
puts
end
run(AddFee, :down) Output
AddFee up: fee_paise is INTEGER WidenFeeWithChange up: fee_paise is bigint WidenFeeWithChange down: ActiveRecord::IrreversibleMigration This migration uses change_column, which is not automatically reversible. To make the migration reversible you can either: 1. Define #up and #down methods in place of the #change method. 2. Use the #reversible method to define reversible behavior. WidenFeeWithUpDown up: fee_paise is bigint WidenFeeWithUpDown down: fee_paise is INTEGER WidenFeeWithReversible up: fee_paise is bigint WidenFeeWithReversible down: fee_paise is INTEGER AddFee down: fee_paise is (no column)
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby reversible_demo.rb
change_column inside change raised ActiveRecord::IrreversibleMigration on the way down. Rails cannot know the old
type unless it is told, and its error message offers the two ways of telling it. Both worked: the up and down pair
and the reversible block each named integer for the way back, and each rolled back to INTEGER. The last line shows
that the first migration, AddFee, was undone too, which is why fee_paise no longer exists. What to write for each kind of step:
| Your step | What to write |
|---|---|
Adds or renames something: create_table, add_column, add_index, add_reference, add_foreign_key, add_check_constraint, rename_column |
change alone |
Removes something: remove_column, remove_index, remove_foreign_key, remove_check_constraint |
change, giving the type or expression the thing had, so Rails can put it back |
Alters a column with change_column, or runs SQL with execute |
reversible inside change, or up and down |
| Destroys data that no code can bring back | up, and a down that raises ActiveRecord::IrreversibleMigration |
Removing a column without saying what it was
remove_column :batches, :capacity in change rolls forward and then stops the rollback with “remove_column is only
reversible if given a type”. Write remove_column :batches, :capacity, :integer, null: false, default: 30, the
column as it was, and Rails can add it back.
reversible keeps the two directions of one step side by side inside a change that also has ordinary steps. up and
down suit a migration that is awkward to reverse as a whole. Where the database supports DDL transactions, which
include SQLite, Rails also wraps each migration in one, so a migration that fails halfway leaves nothing behind. The
habit that matters is the same either way: when you write a step, write how to undo it, and run db:rollback once
before you commit.
Checkpoint
Check: Reversible steps, reversible, up and down
Setting up a database: prepare, seed and replant
A new teammate clones Kaksha and needs a working database, and next week they will need a clean one again after a bad
experiment. The file db/seeds.rb holds the rows every copy of the app should start with. Kaksha’s finds or creates
each row instead of inserting it, and counts what it created, so that running it twice shows whether it changed
anything:
# Seeds run again and again (db:seed, db:setup, and db:prepare on a database it creates), so each row is found or
# created, never inserted blindly. The blocks run only for a row that did not exist yet, so they also count the work.
created = 0
asha = Teacher.find_or_create_by!(phone: "+919812345001") do |teacher|
teacher.name = "Asha Verma"
created += 1
end
[["Class 10 Science", 1_500_000], ["Class 12 Physics", 1_800_000]].each do |name, fee_paise|
Batch.find_or_create_by!(name: name) do |batch|
batch.teacher_id = asha.id
batch.fee_paise = fee_paise
created += 1
end
end
puts "Seeds created #{created} rows; the tables hold #{Teacher.count} teacher and #{Batch.count} batches" The session first copies in the schema.rb that a checkout would hold, then finds where the generated app calls
db:prepare. It prepares a new database, lists the database files, seeds a second time, and finally replants:
# A checkout holds the schema file that was committed with the last migration (copied in as the first step). Where the
# generated app calls db:prepare, a new database prepared from db/schema.rb (in development db:prepare sets
# up the test database as well, which ls shows, and runs the seeds of a database it creates), the seeds run again, and
# db:seed:replant, which empties every table before it seeds. The seeds print their own row counts.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
# A checkout holds the schema file that was committed with the last migration.
cp ../committed_schema.rb db/schema.rb
grep -n db:prepare bin/docker-entrypoint bin/setup
bin/rails db:prepare
ls storage
bin/rails db:seed
bin/rails db:seed:replant
SH Output
$ cd ../kaksha # A checkout holds the schema file that was committed with the last migration. $ cp ../committed_schema.rb db/schema.rb $ grep -n db:prepare bin/docker-entrypoint bin/setup bin/docker-entrypoint:5: ./bin/rails db:prepare bin/setup:24: system! "bin/rails db:prepare" $ bin/rails db:prepare Seeds created 3 rows; the tables hold 1 teacher and 2 batches $ ls storage development.sqlite3 test.sqlite3 $ bin/rails db:seed Seeds created 0 rows; the tables hold 1 teacher and 2 batches $ bin/rails db:seed:replant Seeds created 3 rows; the tables hold 1 teacher and 2 batches
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby seed_session.rb
bin/setup and the generated bin/docker-entrypoint both call bin/rails db:prepare; the entrypoint does it when the
container is about to start the server. In development db:prepare set up two databases, as the ls shows, the
development one and the test one. It created each, loaded db/schema.rb into it and, because it had created databases,
ran the seeds once: 3 rows created. On databases that already exist it only runs the migrations that are pending and
leaves the seeds alone. db:seed ran the same file again and created 0 rows, since find_or_create_by! found every row,
which is why the Rails guide asks for seeds that are safe to run at any time. db:seed:replant emptied the tables first,
so it created 3 rows again. Keep seeds to the rows the app needs in order to start; sample data for tests belongs in
fixture: Sample rows for a table, written in YAML under test/fixtures/, that Rails puts in the test database so that every test starts with them. More in “The Rails command line, console and generators”, which the test run loads for itself.
Choosing between the tasks comes down to whether you can lose the rows and whether a migration is waiting. Answer the three questions in the table, and read why each task that clashes with an answer is marked:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Task | Your rows survive | Migrations run | Seeds run |
|---|---|---|---|
| db:migrate | Good changes the schema in place and keeps the rows | Good runs every migration not yet recorded, in order | Poor never touches db/seeds.rb |
| db:prepare | Good an existing database keeps its rows | Good creates the database, or runs the pending migrations | Mixed only for a database it just created |
| db:reset | Poor drops the database, so every row goes | Mixed rebuilds from the schema file, not by replaying migrations | Good loads the seeds after rebuilding |
| db:seed | Good adds rows; idempotent seeds add nothing twice | Poor runs none | Good runs db/seeds.rb once, as written |
| db:seed:replant | Poor truncates every table, then seeds | Poor runs none | Good empties the tables, then runs db/seeds.rb |
How the answers decide
- “Does the database hold rows you want to keep?” Yes, keep every row: rules out db:reset and db:seed:replant. Both throw rows away, one by dropping the database and one by truncating its tables, so they are for data you can rebuild.
- “Is there a new migration to apply?” Yes, a migration is pending: rules out db:seed and db:seed:replant. Neither runs a migration, so a pending migration stays pending.
- “Do you want db/seeds.rb to run?” Yes, load the seed rows: rules out db:migrate and adds a caveat to db:prepare. db:migrate never loads the seeds. db:prepare loads them only when it has just created the database.
Two of these tasks delete rows
db:reset drops the database, and db:seed:replant truncates every table. Use them on a database you can rebuild
from schema.rb and the seeds, which means your laptop and never data that people entered.
Checkpoint
Check: Preparing and seeding a database
Let the database refuse bad rows
Bad rows get in without anyone meaning them to: an admin screen that accepts a fee of -500, a script that skips the model, two requests that arrive at the same moment, a teacher deleted while a batch still points at them. A rule written in the migration holds against every one of these. This program builds the same kinds of tables in memory, gives its models no validations on purpose, and makes five bad writes. It prints the versions first, because the messages belong to them:
# Five rules kept by the database itself, and the exception Active Record raises when a write breaks each one.
# The models have no validations on purpose: every error below comes from SQLite, not from Ruby code.
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
create_table :teachers do |t|
t.string :name, null: false
t.string :phone, null: false
t.index :phone, unique: true
end
create_table :batches do |t|
t.string :name, null: false
t.references :teacher, null: false, foreign_key: true
t.integer :fee_paise, null: false, default: 0
t.check_constraint "fee_paise >= 0", name: "batches_fee_not_negative"
end
create_table :students do |t|
t.string :name, null: false
end
create_table :enrollments do |t|
t.references :student, null: false, foreign_key: true
t.references :batch, null: false, foreign_key: true
t.index [:student_id, :batch_id], unique: true
end
end
class Teacher < ActiveRecord::Base; end
class Batch < ActiveRecord::Base; end
class Student < ActiveRecord::Base; end
class Enrollment < ActiveRecord::Base; end
puts "Rails #{ActiveRecord.version}, SQLite #{SQLite3::SQLITE_VERSION}"
asha = Teacher.create!(name: "Asha Verma", phone: "+919812345001")
science = Batch.create!(name: "Class 10 Science", teacher_id: asha.id, fee_paise: 1_500_000)
meera = Student.create!(name: "Meera Iyer")
Enrollment.create!(student_id: meera.id, batch_id: science.id)
puts "Valid rows saved: #{Teacher.count} teacher, #{Batch.count} batch, #{Student.count} student, #{Enrollment.count} enrollment"
puts
def attempt(label)
yield
puts "#{label}: saved (no error)"
rescue ActiveRecord::ActiveRecordError => error
puts "#{label}: #{error.class}"
puts " #{error.message.lines.first.strip}"
end
attempt("Negative fee") { Batch.create!(name: "Class 9 Maths", teacher_id: asha.id, fee_paise: -500) }
attempt("Duplicate phone") { Teacher.create!(name: "Another Asha", phone: "+919812345001") }
attempt("Batch without a name") { Batch.create!(name: nil, teacher_id: asha.id, fee_paise: 0) }
attempt("Batch of a missing teacher") { Batch.create!(name: "Class 8 English", teacher_id: 999, fee_paise: 0) }
attempt("Same enrollment twice") { Enrollment.create!(student_id: meera.id, batch_id: science.id) }
puts
puts "Rows after the five failed writes: #{Teacher.count} teacher, #{Batch.count} batch, #{Enrollment.count} enrollment" Output
Rails 8.1.4, SQLite 3.54.0 Valid rows saved: 1 teacher, 1 batch, 1 student, 1 enrollment Negative fee: ActiveRecord::CheckViolation SQLite3::ConstraintException: CHECK constraint failed: batches_fee_not_negative Duplicate phone: ActiveRecord::RecordNotUnique SQLite3::ConstraintException: UNIQUE constraint failed: teachers.phone Batch without a name: ActiveRecord::NotNullViolation SQLite3::ConstraintException: NOT NULL constraint failed: batches.name Batch of a missing teacher: ActiveRecord::InvalidForeignKey SQLite3::ConstraintException: FOREIGN KEY constraint failed Same enrollment twice: ActiveRecord::RecordNotUnique SQLite3::ConstraintException: UNIQUE constraint failed: enrollments.student_id, enrollments.batch_id Rows after the five failed writes: 1 teacher, 1 batch, 1 enrollment
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby constraint_errors.rb
Each failed write raised an Active Record exception that names the rule, and the last line shows that none of them left a row behind. Every rule came from one line of a migration:
| The rule | The migration line | The exception |
|---|---|---|
| A value is required | null: false |
ActiveRecord::NotNullViolation |
| No repeats | add_index ..., unique: true |
ActiveRecord::RecordNotUnique |
| The row it points at exists | t.references ..., foreign_key: true |
ActiveRecord::InvalidForeignKey |
| A condition on the row holds | t.check_constraint "fee_paise >= 0" |
ActiveRecord::CheckViolation |
A unique index: An index that refuses a second row with the same value, or the same combination of values, in its columns, so the database itself keeps repeats out. works on a combination of columns as well, which is how the enrollment
table allows a student in many batches and a batch to hold many students, but never the same pair twice. A
check constraint is a condition that the database tests each time a row is written. In SQLite a check that evaluates to
NULL is not a violation, which is one reason fee_paise is also NOT NULL. A default refuses nothing: it fills in a
value the write left out, like the 0 in fee_paise. Follow one write, the duplicate phone number, from the call to
the exception:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The call. Teacher.create! is called with a name and the phone +919812345001, which the first teacher already has. The model has no validation, so Ruby has nothing to object to.
- The INSERT is built. Active Record turns the attributes into one INSERT statement and sends it to the database over the connection.
- The database tests its rules. Before it writes the row, SQLite tests the rules of the table: NOT NULL columns, the check constraints, the foreign keys and the unique indexes. The unique index on phone finds the number is taken.
- Nothing is written. SQLite refuses the statement with a constraint error, and the row count stays at 1. The same refusal happens for a negative fee, a missing name or a batch of a teacher who does not exist.
- The error comes back with a name. Active Record translates the SQLite message into a class that says which kind of rule broke: here ActiveRecord::RecordNotUnique. The exception travels back to the code that called create!.
The messages come from the SQLite that the sqlite3 gem loaded here, version 3.54.0 as the first line of the output says, and Active Record 8.1.4 turned them into exceptions. Another database words its messages its own way, so rely on the exception classes. The foreign key was refused because Active Record switches SQLite’s foreign key check on for each connection, which SQLite leaves off by default.
Keep the validation as well
A model validation gives a friendly message, but it runs only when Ruby saves through the model. update_columns,
insert_all and a console one-liner go round it, and two requests can both pass a uniqueness check before either
inserts. Let the constraint guarantee the rule and the validation explain it, as the next lesson does.
Checkpoint
Check: Constraints that refuse bad rows
One generator line writes a reference, a required column and a unique index
You do not have to type a migration from nothing. A generator: A bin/rails generate command that writes the files a Rails convention expects for one name, such as a model with its migration, test and fixtures. More in “The Rails command line, console and generators” turns field arguments of the
form name:type:modifier into its lines, and the modifiers you met on a model work here too. This session generates the
migration for a homework table and prints it:
# A migration generated from field arguments, printed as it was written: references, the ! suffix and :uniq.
# The migration's file name starts with the moment it was made, so that part prints as "…".
require_relative "session"
Session.run(<<~SH, masks: [[/\d{14}_create/, "…_create"], [/\b\d{14}\b/, "…"]])
cd ../kaksha
bin/rails generate migration CreateHomeworks batch:references title:string! due_on:date code:string!:uniq
cat db/migrate/*_create_homeworks.rb
SH Output
$ cd ../kaksha
$ bin/rails generate migration CreateHomeworks batch:references title:string! due_on:date code:string!:uniq
invoke active_record
create db/migrate/…_create_homeworks.rb
$ cat db/migrate/*_create_homeworks.rb
class CreateHomeworks < ActiveRecord::Migration[8.1]
def change
create_table :homeworks do |t|
t.references :batch, null: false, foreign_key: true
t.string :title, null: false
t.date :due_on
t.string :code, null: false
t.timestamps
end
add_index :homeworks, :code, unique: true
end
end
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby generate_session.rb
Each argument became lines of the file:
| You typed | The migration wrote |
|---|---|
batch:references |
t.references :batch, null: false, foreign_key: true: a batch_id column, an index on it, and a foreign key to batches |
title:string! |
t.string :title, null: false |
due_on:date |
t.date :due_on, which stays optional |
code:string!:uniq |
t.string :code, null: false, and below the table add_index :homeworks, :code, unique: true |
The name CreateHomeworks told the generator to write a create_table; a name such as AddCapacityToBatches writes an
add_column for the table it names. The command line cannot carry a default: value or a check constraint, so add
those by editing the file while it is new. That is how capacity in the earlier migration got default: 30: the
batches that already exist need a value for a NOT NULL column.
Adding a foreign key column without its index
t.references adds the index for you; add_column :batches, :teacher_id, :integer does not, and a lookup of one
teacher’s batches then reads the whole table. Use add_reference :batches, :teacher, foreign_key: true, which adds
the column, the index and the foreign key together.
Checkpoint
Check: Generator arguments in a migration
Where the schema is kept: schema.rb, structure.sql and the Solid files
The migrations are a history; the schema file is the state. db/schema.rb is a Ruby file generated from the database
after each migration, and a new database is built from it by db:prepare, db:setup and db:schema:load. That is
quicker than replaying every migration, and safer, because an old migration can stop working when the code or the gems
around it have changed. This session copies in the schema.rb that a checkout would hold, lists the files in db/ and
the production databases of config/database.yml; it does not boot the app:
# The files that hold the schema in a new Rails 8.1 app: db/schema.rb (copied in as a checkout would hold it, since the
# fixture app has not run a migration) and one schema file for each Solid database, and the production databases in
# config/database.yml. None of these commands boots the app, so the session is quick.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
# A checkout holds the schema file that was committed with the last migration.
cp ../committed_schema.rb db/schema.rb
ls db
grep -n -E '^ (cache|queue|cable):|migrations_paths' config/database.yml
head -1 db/queue_schema.rb
SH Output
$ cd ../kaksha # A checkout holds the schema file that was committed with the last migration. $ cp ../committed_schema.rb db/schema.rb $ ls db cable_schema.rb cache_schema.rb migrate queue_schema.rb schema.rb seeds.rb $ grep -n -E '^ (cache|queue|cable):|migrations_paths' config/database.yml 29: cache: 32: migrations_paths: db/cache_migrate 33: queue: 36: migrations_paths: db/queue_migrate 37: cable: 40: migrations_paths: db/cable_migrate $ head -1 db/queue_schema.rb ActiveRecord::Schema[7.1].define(version: 1) do
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby layout_session.rb
Besides schema.rb, a new app has cache_schema.rb, queue_schema.rb and cable_schema.rb, one for each of the three
Solid databases that Rails 8 uses for caching, jobs and Action Cable in production. The Multiple Databases guide names
the rule: the primary database uses db/schema.rb, and every other one uses db/<name>_schema.rb. The production
section of database.yml gives each its own migrations_paths, such as db/queue_migrate, so that the app’s
migrations and theirs never mix. The last command reads the first line of one of those files: db/queue_schema.rb is at
a version of its own, 1, and asks for Active Record 7.1, because the file came from the Solid Queue gem’s installer
rather than from a migration of yours.
schema.rb can only describe what the migration DSL can express. Triggers, sequences and stored procedures are outside
it, and so is anything a migration built with execute that the DSL has no call for; raw SQL that creates an ordinary
table is dumped like any other, because the dump is read back from the database. When the database has such features,
set config.active_record.schema_format = :sql, or schema_format in one database’s configuration, and Rails dumps
db/structure.sql with the database’s own tool instead.
In daily use that gives two routes. A fresh checkout builds its database from the schema file with db:prepare. A
database that already holds rows moves forward with db:migrate, which runs only the new migrations and rewrites the
file. Replaying every migration from nothing is the route to avoid for a new database: db:migrate:reset does it, and
the Rails guide warns that it can let the schema drift when old migrations have been altered, reordered or removed. The
two formats side by side:
db/schema.rb |
db/structure.sql |
|
|---|---|---|
| Written in | Ruby, as create_table and add_index calls |
The database’s own SQL |
| Describes | What the migration DSL can express | Everything the database holds, triggers included |
| Needs | Nothing beyond Rails | The database’s dump tool, such as pg_dump for PostgreSQL |
| Default | Yes | Only when you set schema_format to :sql |
Key idea
db/schema.rb is a generated snapshot from which new databases are built; switch to db/structure.sql only when the
database holds something that the snapshot cannot express.
Checkpoint
Check: schema.rb, structure.sql and the Solid files
Add a new migration instead of editing one that has run
Last month’s migration allowed a NULL phone number, and now you want it required. The tempting change is to open
CreateKakshaTables and add null: false. Rails records the version of every migration that has run, so any database
that already ran this file will skip it: it sees nothing pending. Follow what happens to three databases when the file is
edited, and what repairs it:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The original ran everywhere. Last month the migration ran in production and on your laptop, with a phone column that allows NULL. Both databases recorded its version.
- You edit the file. You add null: false to the phone column. To see it work you roll the migration back on your laptop and migrate again, so the laptop and its new schema.rb now say NOT NULL.
- Production skips it. The deploy runs db:migrate. The version of the edited file is already in production’s schema_migrations, so nothing is pending and nothing runs. Production still allows NULL.
- A new clone disagrees too. A teammate clones the repository and runs db:prepare, which loads the committed schema.rb. Their database says NOT NULL, like yours, and unlike production.
- Put it back, add a migration. The repair is to put the original file back and add a new migration. Every database, production included, runs it exactly once, after the blank phone numbers it holds have been fixed.
The Rails guide gives the rule and its one exception. An edit is fine for a migration that has not left your machine:
roll it back, edit it, and migrate again, or run db:migrate:redo. Once the migration is committed, and above all once
it has run anywhere else, write a new migration that makes the change. The new file runs exactly once on every database,
in its place in the order, and each database ends in the same state. Fixing blank phone numbers that already exist
before the rule is added is data work that the new migration, or a task before it, has to do.
Fixing production by editing history
Editing the old file and deploying changes nothing in production, but it does change what a freshly built database looks like, so the two now disagree. Leave the old file exactly as it ran and put the fix in a new one.
How to add a rule to a large live table without blocking writes is a different problem with its own techniques; the zero-downtime migrations lesson of the Active Record in depth module takes it up.
Key idea
A migration that has run anywhere else is history: restore it as it was, and make every later change in a new migration that each database runs once.
Checkpoint
Check: A new migration, never an edit
Practise: write the schema from a spec
The questions below come from the sessions and programs of this lesson. Each answer is explained at once:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Then write migrations yourself. constraint_errors.rb builds Kaksha’s four tables in one Schema.define block;
rewrite them as four migration classes with one change method each, in the style of reversible_demo.rb:
CreateTeachers:nameandphone, both required, timestamps, and a unique index so that no two teachers share a phone.CreateBatches: a requiredname, a requiredteacherthat must exist, andfee_paise, which is required,0when not given and never below0.CreateStudentsas the script has it, andCreateEnrollments: an enrollment needs a real student and a real batch, and a student joins a batch only once.- Run the four with
migrate(:up), leave the model classes without validations and repeat the script’s five attempts: each must still end in a database error. Then run them withmigrate(:down)in reverse order and check that the four tables are gone, which proves that everychangecan roll back.
Interview questions
Warm-up: how does a change migration know how to roll back, and what do you do when it cannot?
Active Record works out the opposite of each step in change: drop_table for create_table, remove_column for
add_column, and so on, and it runs the opposites in reverse order. Steps whose opposite depends on information it
does not have, such as change_column, execute, or remove_column without a type, raise
ActiveRecord::IrreversibleMigration on the way down. For those you tell Rails the way back: either a reversible
block with direction.up and direction.down inside change, or separate up and down methods. A migration that
destroys data should have a down that raises the same error on purpose. Run db:rollback once before committing.
Two questions of this module’s bank start where this lesson stops: a migration that someone changed after it had run, and a new rule for a table whose rows already break it.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
The migration that created enrollments ran in staging and production last month. A teammate edited the committed file to add a unique index on student_id and batch_id, and CI is green. What goes wrong, how could CI have caught it, and how do you repair the repository, staging and production now?
-
Mid to senior
Kaksha has 4,000 batches in production and wants the database to refuse a negative fee. An old import left 37 batches with a negative fee_paise. Plan the change, and say what happens on SQLite if you simply add the check constraint.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does db:migrate decide which migrations to run?
It runs, in version order, every migration whose version is missing from the schema_migrations table, then rewrites db/schema.rb to match the database. db:rollback undoes the newest one, and db:migrate:status lists each as up or down.
-
A change migration contains change_column. What happens on rollback, and how do you fix it?
Rollback raises ActiveRecord::IrreversibleMigration, because Rails cannot know the old column definition. Describe the way back with a reversible block or with separate up and down methods.
-
What do db:prepare, db:reset and db:seed:replant do to existing data?
db:prepare creates and loads a database that does not exist yet, or runs the pending migrations of one that does. db:reset and db:seed:replant both delete rows before seeding again.
-
Which schema rules make the database itself refuse a bad row, whatever code writes it?
null: false on a column, unique indexes, foreign keys and check constraints. A violation comes back as an exception such as ActiveRecord::RecordNotUnique.
-
When should an app keep db/structure.sql instead of db/schema.rb?
Only when the database holds something the migration DSL cannot express, such as triggers, sequences or stored procedures. New databases are built from the schema file, so it has to describe all of it.
-
A migration has already run on a teammate's machine or in production, and it needs a change. What do you do?
Leave it exactly as it was and write a new migration. Every database runs each version once, so an edited old file never reaches the databases that already ran it.
Key takeaways
- A migration is a numbered step:
db:migrateruns those not yet recorded inschema_migrations,db:rollbackundoes the newest, anddb:migrate:statusshows which are up or down. changerolls back on its own for steps with a clear opposite; a step such aschange_columnorexecuteneedsreversibleorupanddown, and a step that destroys data should say plainly that it cannot be undone.db:preparecreates and loads a new database or applies pending migrations, idempotent seeds can run at any time, anddb:resetanddb:seed:replantdelete rows.null: false, unique indexes, foreign keys and check constraints make the database refuse bad rows with exceptions such asRecordNotUniqueandCheckViolation, whatever Ruby code or tool wrote them.db/schema.rbis a generated snapshot from which new databases are built,structure.sqlreplaces it when the database has features that it cannot express, and Rails 8 adds a schema file for each Solid database.- A migration that has run anywhere else is never edited; the change goes into a new migration.
Terms in this lesson
- check constraint
- A rule written as a condition on a table, such as fee_paise >= 0, that the database tests on every insert and update and refuses the write when it is false.
- foreign key
- A column whose values must match a row of another table, enforced by the database, so a batch cannot point at a teacher who does not exist.
- schema file
- The snapshot of the database structure that Rails keeps in the repository, db/schema.rb by default; a new database is built from it instead of replaying every migration.
- unique index
- An index that refuses a second row with the same value, or the same combination of values, in its columns, so the database itself keeps repeats out.
References
- Active Record Migrations (Ruby on Rails)
- ActiveRecord::ConnectionAdapters::SchemaStatements (8.1.4) (Ruby on Rails)
- ActiveRecord::ConnectionAdapters::SQLite3Adapter (8.1.4) (Ruby on Rails)
- The Rails Command Line (Ruby on Rails)
- Multiple Databases with Active Record (Ruby on Rails)
- Configuring Rails Applications (Ruby on Rails)
- Testing Rails Applications (Ruby on Rails)
- Ruby on Rails 8.0 release notes (Ruby on Rails)
- SQLite foreign key support (SQLite)
- SQLite CREATE TABLE: constraints (SQLite)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress