Resolving schema.rb Merge Conflicts
Two pull requests each added a migration, each regenerated db/schema.rb, and the second one to merge now has a conflict on the very first line — ActiveRecord::Schema[7.1].define(version: 2026_09_18_101500) versus version: 2026_09_17_164200 — plus conflicting hunks inside create_table "orders". It is tempting to resolve it by hand, picking lines until it parses. That is how a schema.rb ends up describing a database no migration ever produced: a column from one branch missing, an index from the other duplicated, a version that makes db:schema:load skip a migration. Because new environments and CI load the schema dump rather than replaying migrations, a wrong dump silently diverges every fresh database from production. This guide shows the correct resolution, and how to make the dump impossible to get wrong. It rounds out Rails Active Record Migrations.
Symptom / Error Signatures
The conflict itself is obvious:
<<<<<<< HEAD
ActiveRecord::Schema[7.1].define(version: 2026_09_18_101500) do
=======
ActiveRecord::Schema[7.1].define(version: 2026_09_17_164200) do
>>>>>>> feature/order-region
The consequences of resolving it badly are less obvious and appear later:
bin/rails db:schema:loadproduces a database that lacks a column or index that exists in production, and tests pass locally but fail in production (or the reverse).- After a fresh setup,
bin/rails db:migrate:statusshows a migration asdownthat production has applied — orupalthough its changes are absent — because theversionin the dump was set wrongly. - The CI step that regenerates the dump produces a diff on every build.
Root Cause Analysis
db/schema.rb is generated by bin/rails db:schema:dump (run automatically after db:migrate) by inspecting the current database. Its version argument is the latest migration version applied, and when the dump is loaded Rails inserts all migration versions up to that one into schema_migrations, assuming their effects are in the dump. Two branches that each ran migrations produce two dumps, each reflecting only its own branch’s changes plus the common base. Neither is correct after the merge: the correct dump reflects all migrations.
Hand-merging tries to compute that union by reading two text files. It fails on details — column order within create_table, index names, check_constraint blocks, foreign keys — and it fails silently, because schema.rb stays valid Ruby. Regenerating computes the union by actually running the migrations, which is the only reliable way. With structure.sql (config.active_record.schema_format = :sql) the same applies, and conflicts are worse because the file is a raw pg_dump, including the INSERT INTO schema_migrations list at the end.
| Artifact | Conflicts on | Correct resolution |
|---|---|---|
schema.rb define(version:) |
latest version per branch | regenerate; result is the highest version |
schema.rb table blocks |
columns and indexes per branch | regenerate |
structure.sql body |
DDL per branch | regenerate |
structure.sql version list |
INSERT INTO schema_migrations rows |
regenerate |
| migration files | never, unless same file name | keep both |
Immediate Mitigation
1. Resolve the conflict by taking the target branch’s dump. During a rebase or merge onto main, take main’s version of the file — which includes everything already merged — and discard your branch’s.
# Shell · feature branch, mid-merge from main · no database changes yet
# WARNING: in a rebase, --ours and --theirs are swapped; check which side is main before choosing.
git checkout --theirs db/schema.rb # during `git merge main`: take main's dump
git add db/schema.rb
2. Rebuild a local database from that dump and apply your branch’s migrations. Rails re-dumps automatically after db:migrate.
# Shell · local development database only · drops and recreates it
# WARNING: destroys local development data.
bin/rails db:drop db:create db:schema:load
bin/rails db:migrate
git diff --stat db/schema.rb # should show only your branch's additions and the new version
3. Commit the regenerated dump. Review the diff: it should add exactly the columns, indexes and constraints your migrations create, and the version should be your newest migration’s timestamp (or main’s, if it is higher).
4. If a wrong dump was already merged, fix it the same way. Check out main, load the dump into a clean database, run db:migrate, and compare with a database built by running all migrations from scratch; commit the corrected dump.
Permanent Fix / Long-Term Pattern
Treat the dump as a generated artifact with a check, not as source code. Add a CI step that rebuilds the dump from migrations and fails if it differs from the committed file, so a hand-edited or stale dump can never merge:
# YAML · CI job · PostgreSQL service container matching production's version
- name: Verify schema dump matches migrations
run: |
bin/rails db:create
bin/rails db:schema:load
bin/rails db:migrate
git diff --exit-code db/schema.rb db/structure.sql
env:
RAILS_ENV: test
Reduce the frequency of conflicts, too. Timestamped migration names already avoid file-name collisions; the remaining conflict is the version line, which is unavoidable but trivial once regeneration is the habit. Some teams add a .gitattributes merge driver for db/schema.rb that always takes one side and relies on the CI check plus a local db:migrate to correct it. Periodically compare a database built from all migrations with one loaded from the dump, and with production itself, to catch drift that slipped past review — the approach in detecting production schema drift against a desired state. The version-ordering side of the same problem, for tools with sequential numbers, is covered in resolving migration version conflicts during merges.
Verification Checklist
Frequently Asked Questions
Can I just keep the higher version number and merge the rest by hand? You can, but it is error-prone and the mistakes are silent. The version line is only one part of the conflict; the table blocks must also reflect both branches exactly. Regenerating by running migrations produces the correct union every time.
Why does db:schema:load skip migrations?
Loading the dump inserts every migration version up to the dump’s version into schema_migrations, on the assumption that their effects are in the dump. If the version is too high or the dump is missing a change, those migrations are marked applied without their effects existing.
Should I switch to structure.sql to avoid this?
Not for conflicts — structure.sql conflicts are usually larger. Switch to it when you need database features schema.rb cannot express, such as triggers, custom types or complex partial indexes, and apply the same regenerate-and-check discipline.
What about conflicts in schema_migrations in production?
There are none: production’s schema_migrations table records what actually ran. Merge conflicts only affect the committed dump, which is why the dump must be regenerated rather than trusted as a merge result.