Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

db/migrate/…_create_kaksha_tables.rb: four tables Ruby · kaksha/db/migrate/20260301090000_create_kaksha_tables.rb
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:

db:migrate runs both files, db:migrate:status lists them Ruby · sessions/migrate_session.rb
# 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:

Interactive · step by step One migration, forwards and back Step through db:migrate and then db:rollback for the AddCapacityToBatches migration. Each step changes the box it names.
One migration, forwards and back, step 6 of 6. Showing The migration (status: down), The batches table (check, then column), schema_migrations (version removed), db/schema.rb (two lines gone). In focus: schema_migrations, db/schema.rb, The migration.changedumpThe migrationstatus: downThe batches tablecheck, then columnschema_migrationsversion removeddb/schema.rbtwo lines gone

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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, and what it changed in schema.rb Ruby · sessions/rollback_session.rb
# 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

  1. Question 1 of 2 Which table does Rails read to learn which migrations have already run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: schema_migrations

    db:migrate:status prints one line for each file in db/migrate/, marked up when its version is recorded in schema_migrations and down when it is not.

  2. Question 2 of 2 AddCapacityToBatches ran add_column and then add_check_constraint. In which order does db:rollback undo them?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The check constraint first, then the column

    A rollback is the migration played backwards: the last step is undone first, so a step is always undone while the things it depends on still exist.

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:

change_column in change, in up and down, and in reversible Ruby · reversible_demo.rb
# 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

  1. Question 1 of 2 A change method calls change_column :batches, :fee_paise, :bigint. What happens on bin/rails db:rollback?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises ActiveRecord::IrreversibleMigration

    change_column needs the old type to be undone, and a change method does not carry it. Giving the old type in a reversible block, or writing up and down, solves it.

  2. Question 2 of 2 Which version of the removal can Active Record roll back?

    Read the code, then choose one answer.

    # A
    def change
      remove_column :batches, :capacity
    end
    
    # B
    def change
      remove_column :batches, :capacity, :integer, null: false, default: 30
    end
    Show the answer: Question 2 of 2

    Answer: Only B

    A removal is undone by adding the thing back, so the removal must say what the thing was. That is why remove_column, remove_index and remove_foreign_key need the original details inside change.

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:

db/seeds.rb: rows that can be seeded again Ruby · kaksha/db/seeds.rb
# 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:

db:prepare, db:seed, db:seed:replant Ruby · sessions/seed_session.rb
# 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:

Interactive · answer and compare Which db task fits what you want? Answer three questions. Tasks that clash with an answer are marked, with the reason and the columns that decide.
Does the database hold rows you want to keep?
Is there a new migration to apply?
Do you want db/seeds.rb to run?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five database tasks against whether they keep rows, run migrations and run seeds
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

  1. One question In the recorded session, bin/rails db:seed printed "Seeds created 0 rows" right after db:prepare had printed 3. Why?

    Choose one answer.

    Show the answer: One question

    Answer: find_or_create_by! found rows that already existed

    An idempotent seed finds or creates each row, so the same file can run on a new database, on an old one, and after db:seed:replant.

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 bad writes, five exceptions, no validations Ruby · constraint_errors.rb
# 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:

Interactive · stage by stage A write meets the rules of the database Follow Teacher.create! with a phone number that is already taken, from the call to the exception.
A write meets the rules of the database, step 5 of 5. Showing Your code (rescue or fix it), Active Record (INSERT INTO teachers), SQLite (UNIQUE index says no), The outcome (no row, an error). In focus: Your code.the exceptionYour coderescue or fix itActive RecordINSERT INTO teachersSQLiteUNIQUE index says noThe outcomeno row, an errorRecordNotUnique

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. The INSERT is built. Active Record turns the attributes into one INSERT statement and sends it to the database over the connection.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 In the recorded run, which exception did a batch with fee_paise of -500 raise?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ActiveRecord::CheckViolation

    Each kind of rule has its own exception class: NotNullViolation, RecordNotUnique, InvalidForeignKey and CheckViolation.

  2. Question 2 of 2 A script inserts batches with insert_all, which skips every model validation. Which line still stops a negative fee?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: t.check_constraint "fee_paise >= 0"

    A rule in the database holds for every writer. A validation is still worth having for the message it gives, but it cannot be the only guard.

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:

generate migration with references, string! and :uniq Ruby · sessions/generate_session.rb
# 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

  1. One question Which of these does batch:references write into a generated create_table migration?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • An index on batch_id
    • A batch_id column
    • A foreign key to the batches table

    One argument writes three database objects. The model association is separate code that you add to the model.

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 schema files of a new Rails 8.1 app Ruby · sessions/layout_session.rb
# 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

  1. Question 1 of 2 A PostgreSQL database has a trigger that stamps a column on insert. Which schema file can describe it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: db/structure.sql, after setting the schema format to sql

    Switch to structure.sql when the database has features the Ruby schema cannot express; a new database is then built by running the SQL in it.

  2. Question 2 of 2 Which file holds the table definitions of the Solid Queue database in a new Rails 8.1 app?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: db/queue_schema.rb

    A new app has db/cache_schema.rb, db/queue_schema.rb and db/cable_schema.rb next to db/schema.rb.

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:

Interactive · step by step Editing a migration that already ran Step through what happens to production, to your laptop and to a new clone when the file is edited, and how a new migration repairs it.
Editing a migration that already ran, step 5 of 5. Showing A new migration (sets the rule), CreateKakshaTables (restored), Production (runs it: NOT NULL), Your laptop (phone is NOT NULL), A new clone (phone is NOT NULL). In focus: A new migration, Production.A new migrationsets the ruleCreateKakshaTablesrestoredProductionruns it: NOT NULLYour laptopphone is NOT NULLA new clonephone is NOT NULL

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. One question Production ran CreateKakshaTables last month. Someone adds null false to the committed file and deploys. What does db:migrate do in production?

    Choose one answer.

    Show the answer: One question

    Answer: Nothing; the version is already recorded

    The edit changes new databases but not the ones that already ran the file, so they disagree. The change belongs in a new migration.

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.

  1. Question 1 of 14 In the table of db:migrate:status, what does down beside a migration mean?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: The file exists but its version is not recorded as run

    The status is a comparison of the files in db/migrate/ with the versions in schema_migrations: up if recorded, down if not.

  2. Question 2 of 14 Three migrations are up. You run bin/rails db:rollback once, with no arguments. What is undone?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: Only the newest migration

    db:rollback goes back one migration; db:migrate:redo goes back one and forward again.

  3. Question 3 of 14 Which call can Active Record reverse by itself inside a change method?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: add_column :batches, :capacity, :integer

    A call is reversible when its inverse can be computed from its arguments alone: adds and renames are, and removals are once they say what they remove.

  4. Question 4 of 14 This migration changes a string column to text, and it cannot be rolled back. Which rewrite fixes it and keeps one change method?

    Read the code, then choose one answer.

    def change
      change_column :batches, :name, :text
    end
    Show the answer: Question 4 of 14

    Answer: Wrap two change_column calls in a reversible block, text for direction.up and string for direction.down

    Either a reversible block inside change or separate up and down methods gives Rails the way back. Which one you use is a matter of readability, not of behaviour.

  5. Question 5 of 14 In the recorded seed session, how many rows did bin/rails db:seed:replant report creating?

    Type a number.

    Show the answer: Question 5 of 14

    Answer: 3 rows

    It truncated the tables first, so the seeds had to create the teacher and the two batches again; db:seed on its own had created 0.

  6. Question 6 of 14 Which of these tasks delete the rows of the database they run on?

    Choose every answer that is right.

    Show the answer: Question 6 of 14

    Answer:

    • bin/rails db:seed:replant
    • bin/rails db:reset

    Two tasks throw rows away on purpose. Run them on data you can rebuild, never on data people entered.

  7. Question 7 of 14 Which exception did a batch with teacher_id 999, a teacher who does not exist, raise in the recorded run?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: ActiveRecord::InvalidForeignKey

    The four rule kinds raise four classes. Active Record turns the database's message into the class, so rescue by class and not by message text.

  8. Question 8 of 14 A SQLite column has CHECK (fee_paise >= 0) and allows NULL. Can a row with a NULL fee be inserted?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: Yes, because a check that evaluates to NULL is not a violation

    Pair a check constraint with null false whenever a missing value should be refused too, as the batches table does.

  9. Question 9 of 14 What does code:string!:uniq write in a generated create_table migration?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: A string column with null false, and a unique index on it

    string! is the required column and :uniq is the index that forbids repeats; together they make a column both present and one of a kind.

  10. Question 10 of 14 You want the generated capacity column to have a default of 30. What do you do?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: Edit the generated migration and add default 30 before it runs

    Generated files are a starting point. A default or a check constraint is added by editing the file while it has not yet run anywhere.

  11. Question 11 of 14 What is db/schema.rb mainly used for?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: Building a new database quickly, without replaying every migration

    The schema file is a generated picture of the current structure. Commit it so that every checkout can build the same database.

  12. Question 12 of 14 Your app uses plain tables, indexes, foreign keys and check constraints on PostgreSQL. Which schema format should you keep?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: The default Ruby schema.rb

    Choose structure.sql when you need it, not before; the Ruby file is simpler to read and review.

  13. Question 13 of 14 Which edit of an existing migration is acceptable?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: A file you generated minutes ago, never committed, after rolling it back

    The test is whether any other database could have run the file. If one could, add a new migration.

  14. Question 14 of 14 A migration that ran in production lets phone be NULL, and 12 rows have it blank. You want phone required. What do you ship?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: A new migration, after the 12 blank phone numbers have been fixed

    History stays as it ran; the new rule is a new step, and existing rows that break it are dealt with before it runs.

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:

  1. CreateTeachers: name and phone, both required, timestamps, and a unique index so that no two teachers share a phone.
  2. CreateBatches: a required name, a required teacher that must exist, and fee_paise, which is required, 0 when not given and never below 0.
  3. CreateStudents as the script has it, and CreateEnrollments: an enrollment needs a real student and a real batch, and a student joins a batch only once.
  4. 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 with migrate(:down) in reverse order and check that the four tables are gone, which proves that every change can 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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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:migrate runs those not yet recorded in schema_migrations, db:rollback undoes the newest, and db:migrate:status shows which are up or down.
  • change rolls back on its own for steps with a clear opposite; a step such as change_column or execute needs reversible or up and down, and a step that destroys data should say plainly that it cannot be undone.
  • db:prepare creates and loads a new database or applies pending migrations, idempotent seeds can run at any time, and db:reset and db:seed:replant delete rows.
  • null: false, unique indexes, foreign keys and check constraints make the database refuse bad rows with exceptions such as RecordNotUnique and CheckViolation, whatever Ruby code or tool wrote them.
  • db/schema.rb is a generated snapshot from which new databases are built, structure.sql replaces 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.