Rails Migrations: schema.rb vs structure.sql and When Each Wins
How Active Record migrations work, what schema.rb cannot express, when to switch to structure.sql, and how db:prepare and strong_migrations keep a team safe.
Ruby migration classes from db/migrate, with change or up and down methods. SchemaDrawer runs ActiveRecord 8.1 on Ruby 3.4 against a throw-away database of the engine you choose and returns an SVG of the schema it produces.
Upload db/migrate: files named <version>_<title>.rb, each holding a class named after the title that extends ActiveRecord::Migration[x.y]. Nested directories are found too.
The sample below is the PetClinic schema written for ActiveRecord (Rails), one of the sets SchemaDrawer's own tests draw on every engine:
db/migrate/ ├── 001_create_owners_and_pets.rb ├── 002_create_vets.rb └── 003_create_visits.rb
Ruby migration files.
ActiveRecord::MigrationContext#migrate, without a Rails application around it. The database is a fresh PostgreSQL, MySQL, MariaDB, SQL Server, Oracle or SQLite container in the version you ask for; nothing of yours is touched.
PostgreSQL 10, 11, 12, 13, 14, 15, 16, 17, 18 MySQL 8.0, 8.4, 9 MariaDB 10.6, 11.4, 11.8, 12 SQL Server 2017, 2019, 2022, 2025 Oracle SQLite
Not offered for ActiveRecord (Rails): Oracle. The tool ships no driver for it, so nothing could run.
migrationType=ACTIVE_RECORDOwners, pets, types, vets, specialties and visits: the same seven tables every sample set on this site creates. These are the exact files the tests upload.
class CreateOwnersAndPets < ActiveRecord::Migration[8.1]
def change
# `id: :integer, default: nil` is the only spelling that yields a plain INT
# primary key on both engines. Plain `id: :integer` yields `serial` on
# PostgreSQL and `int AUTO_INCREMENT` on MySQL; mentioning :default at all
# is what switches the auto-increment behaviour off.
create_table :owners, id: :integer, default: nil do |t|
t.string :first_name, limit: 50, null: false
t.string :last_name, limit: 50, null: false
t.string :address, limit: 255
t.string :city, limit: 80
t.string :telephone, limit: 20
end
create_table :types, id: :integer, default: nil do |t|
t.string :name, limit: 80, null: false
end
create_table :pets, id: :integer, default: nil do |t|
t.string :name, limit: 50, null: false
t.date :birth_date
t.integer :type_id, null: false
t.integer :owner_id, null: false
end
add_foreign_key :pets, :types, column: :type_id, name: "fk_pets_types"
add_foreign_key :pets, :owners, column: :owner_id, name: "fk_pets_owners"
end
end
class CreateVets < ActiveRecord::Migration[8.1]
def change
create_table :vets, id: :integer, default: nil do |t|
t.string :first_name, limit: 50, null: false
t.string :last_name, limit: 50, null: false
end
create_table :specialties, id: :integer, default: nil do |t|
t.string :name, limit: 80, null: false
end
# A real composite primary key (Rails 7.1+): no surrogate `id` column is
# added, and the columns come out in the order of the block.
create_table :vet_specialties, primary_key: [:vet_id, :specialty_id] do |t|
t.integer :vet_id, null: false
t.integer :specialty_id, null: false
end
add_foreign_key :vet_specialties, :vets, column: :vet_id, name: "fk_vet_specialties_vets"
add_foreign_key :vet_specialties, :specialties, column: :specialty_id, name: "fk_vet_specialties_specialties"
end
end
class CreateVisits < ActiveRecord::Migration[8.1]
def change
create_table :visits, id: :integer, default: nil do |t|
t.integer :pet_id, null: false
t.date :visit_date
t.string :description, limit: 255
end
add_foreign_key :visits, :pets, column: :pet_id, name: "fk_visits_pets"
end
end
Every variant of the sample produces these seven tables. This diagram was drawn from the Flyway variant on PostgreSQL 18; on MySQL the types read INT and VARCHAR, on Oracle the names are upper case.
One multipart request queues the drawing; poll the job until it succeeds and download the SVG. Or let the GitHub Action do it and open a pull request with the diagram.
curl -X POST https://schemadrawer.com/api/v1/drawing \
-H "Request-Id: $(uuidgen)" \
-F "userId=5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f" \
-F "migrationType=ACTIVE_RECORD" \
-F "databaseType=POSTGRESQL" \
-F "version=18" \
-F "files=@db/migrate/001_create_owners_and_pets.rb" \
-F "files=@db/migrate/002_create_vets.rb" \
-F "files=@db/migrate/003_create_visits.rb"
# {"jobId":"0b6e2f4c-8d1a-4c3e-9f7a-2a5b6c7d8e9f"}
Then poll GET https://schemadrawer.com/api/v1/drawing/{jobId} until status is SUCCESS and fetch diagramUrl. Details in the getting started guide and the API reference.
- uses: schemadrawercom/action@v1
with:
sql-directory: db/migrate
output-path: docs/schema-diagram.svg
migration-type: ACTIVE_RECORD
database-type: POSTGRESQL
database-version: '18'
github-token: ${{ secrets.GITHUB_TOKEN }}
The action uploads the directory, waits for the diagram and opens a pull request with the SVG. See the action reference.
Rails.env, cannot run.schema_migrations and ar_internal_metadata are left out of the diagram.How Active Record migrations work, what schema.rb cannot express, when to switch to structure.sql, and how db:prepare and strong_migrations keep a team safe.
Editing applied migrations, environment-specific scripts, ORM auto-DDL in production, untested rollbacks: ten habits that break deployments, with a fix for each.
How Diesel manages a schema in Rust: the CLI, timestamped folders with up.sql and down.sql, the versions table, the generated schema.rs and embedded migrations.
Upload the migration directory, get an SVG back. Free while we build it.