
Use schema.rb unless your PostgreSQL database holds objects it cannot express: triggers, functions, views, materialized views, and custom domains. On Rails 8.1, schema.rb already round-trips check, unique, exclusion, and deferrable foreign-key constraints, expression and partial indexes, enum types, generated columns, comments, and extensions. Switch to structure.sql only at that limit — it needs pg_dump and psql on every machine.
Every command, file excerpt, and error message in this guide was run on Rails 8.1.3.1 (Ruby 3.4.10, pg 1.6.3) against PostgreSQL 17.11 with pg_dump and psql 17.11. The version-mismatch case ran against a PostgreSQL 18.4 server.
The Difference Between schema.rb and structure.sql
Both files describe the current shape of your database so that bin/rails db:schema:load can build a fresh one without replaying every migration. They differ in who writes them and in what they can say.
Generate a model, and Rails asks you to migrate:
bin/rails g model User first_name:string last_name:stringclass CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :first_name
t.string :last_name
t.timestamps
end
end
endRun bin/rails db:migrate and Rails dumps db/schema.rb:
ActiveRecord::Schema[8.1].define(version: 2026_09_10_140041) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "first_name"
t.string "last_name"
t.datetime "updated_at", null: false
end
endTwo things stand out. schema.rb is Ruby: Active Record’s schema dumper inspects the database and writes what it finds using the same methods your migrations use. That’s why the file works with any adapter Rails supports. And it is a translation: every column, index, and constraint has to map onto a dumper method, and anything without one is left out.
If you last saw this file on Rails 6, the shape has changed. The header names ActiveRecord::Schema[8.1], the extension is pg_catalog.plpgsql, columns are sorted alphabetically, and t.datetime no longer carries precision: 6. The generated comment at the top of the file now argues for loading it instead of migrating. bin/rails db:schema:load “tends to be faster and is potentially less error prone than running all of your migrations from scratch”.
structure.sql is not a translation. Rails shells out to the database’s own dump tool and commits the result. On PostgreSQL that is pg_dump --schema-only --no-privileges --no-owner, loaded back with psql --set ON_ERROR_STOP=1 --quiet --no-psqlrc. The Rails 8.1 source runs mysqldump for MySQL and MariaDB (not SHOW CREATE TABLE, as older articles say) and sqlite3 with .schema --nosys for SQLite. Whatever the database can hold, the file can hold, at the price of a file only that database can read.
What Each Format Can Express on Rails 8.1
Every row comes from the same test database, dumped in both formats, with each file then loaded into an empty database and the PostgreSQL catalog checked for the object.
schema.rb | structure.sql | |
|---|---|---|
Extensions (enable_extension) | Yes | Yes |
Enum types (create_enum, t.enum) | Yes | Yes |
Check constraints, including ones added by raw execute | Yes | Yes |
Unique constraints with deferrable: :deferred | Yes | Yes |
Exclusion constraints (using: :gist) | Yes | Yes |
Foreign keys with on_delete and deferrable | Yes | Yes |
| Expression and partial indexes | Yes | Yes |
Generated columns (t.virtual, stored: true) | Yes | Yes |
| Table and column comments | Yes | Yes |
citext and jsonb columns | Yes | Yes |
| Column defaults | As Ruby literals | As PostgreSQL expressions, casts included |
| Sequences | Only the ones behind id columns | Every CREATE SEQUENCE |
| Triggers | No | Yes |
| Functions | No | Yes |
| Views | No | Yes |
| Materialized views | No | Yes |
| Custom domains | No, the column dumps as its base type | Yes |
| Portable across database adapters | Yes | No |
| Written by | Active Record’s Ruby schema dumper | pg_dump (mysqldump or sqlite3 on other adapters) |
The domain row is the quiet one. A contact column of type email_address, defined as CREATE DOMAIN email_address AS text CHECK (VALUE ~ '@'), dumps to schema.rb as t.text "contact". The file loads without a warning, and the domain’s check is gone. Triggers, functions, and views vanish the same way; nothing fails until code depends on them.
Advantages of structure.sql over schema.rb
Three, each measured on the test database:
- SQL-only objects survive a fresh
db:schema:load. After loadingstructure.sqlinto an empty database, the catalog held the trigger, the function, the domain, the view, and the materialized view. After loadingschema.rbdumped from the same source database, it held none of them. - The file is exact.
pg_dumpwrites the schema PostgreSQL has, sequences and default expressions included, with no Ruby type mapping in between. That also sidesteps the Ruby dumper’s own failure mode, in which a table it cannot express is written intoschema.rbas a comment whilestructure.sqlkeeps it. - A fresh database no longer depends on your migration history.
db:schema:loadbuilds it from the file withpsql, so migrations that reference removed gems, renamed models, or data that no longer exists never run again, and nothing on that path is interpreted by Ruby.
The cost is real. The rich test schema is 388 lines of structure.sql against 58 lines of schema.rb, and the smallest possible app, one table and one constraint, dumps to 111 lines. Every machine that migrates needs pg_dump, every machine that loads needs psql, and the dump side must be no older than the server. The file is written in PostgreSQL’s dialect: switching adapters means regenerating it, and reviewing a pull request means reading SQL.
Switching From schema.rb to structure.sql
One line in config/application.rb:
module Myapp
class Application < Rails::Application
config.load_defaults 8.1
# Add this line:
config.active_record.schema_format = :sql
end
endThen, in order:
bin/rails db:schema:dumpwritesdb/structure.sql. So does everybin/rails db:migratein development, even one with nothing pending: a deletedstructure.sqlcame back on a no-op migrate. Production is generated withconfig.active_record.dump_schema_after_migration = false, so deploys never rewrite it.db/schema.rbis not regenerated under:sql. Delete it from the repository in the same commit, or the two files drift apart.- Commit
db/structure.sql. From now on, everyone who runsbin/rails db:prepareneedspsql. SCHEMA_FORMAT=sql bin/rails db:schema:dump(orSCHEMA_FORMAT=ruby) overrides the config for one run, which is the cheap way to compare the two dumps before you commit to the switch.- Multi-database apps can name the file per connection with
schema_dump: custom_structure.sqlunder the entry inconfig/database.yml. bin/rails db:prepareis the task for a new machine. On a missing database it created the database and loadedstructure.sql, with every version present inschema_migrations; on an existing database with a pending migration it ran the migration; on an up-to-date one it exited 0 with nothing to do.db:setupstill exists and works, but it expects an empty database.- The old
bin/rails db:structure:dumpanddb:structure:loadtasks no longer exist (the error reference has the message).db:schema:dumpanddb:schema:loadhandle both formats.
For the one-table app, the dump is 111 lines. It opens with pg_dump’s session settings (Rails strips the comment header, and PostgreSQL 17 adds SET transaction_timeout = 0;):
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';And it closes with the migration versions the database had run, newest first:
-- PostgreSQL database dump complete
--
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20260910140046'),
('20260910140041');That closing block is the part of the file that will matter in every merge conflict.
The setting most people reach for next is structure_dump_flags, in an initializer:
# config/initializers/structure_dump.rb
ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = ["--no-comments"]The default is nil, and structure_load_flags= exists for the psql side. People expect --no-comments to strip the -- Name: users; Type: TABLE; … banners that make the file long. It does not. It drops COMMENT ON statements, which is where the table and column comment: metadata from your migrations ends up, along with the extension descriptions. On the rich test schema the file went from 388 to 360 lines. Four COMMENT ON statements were gone, and 36 of the 40 -- Name: banners were still there (the four that headed the removed statements went with them). If your migrations set comments, this flag deletes them from every database built from the file.
Adding Database-level Constraints
Active Record makes reads easy: Company.find_by(name: 'Some Company') fetches a row by any column, and User.where(first_name: 'Dan') filters by another. (find takes a primary key; the 2020 version of this post wrote Company.find(name: 'Some Company'), which raises ActiveRecord::RecordNotFound.) Model validations look as easy, but they run in Ruby, so they only guard writes that go through Active Record’s validation path. Take this one:
class User < ApplicationRecord
validate :name_cannot_start_with_d
private
def name_cannot_start_with_d
if first_name.present? && first_name[0].downcase == 'd'
errors.add(:first_name, "cannot start with the letter 'D'")
end
end
endcreate! runs it:
User.create!(first_name: 'Dan')Validation failed: First name cannot start with the letter 'D' (ActiveRecord::RecordInvalid)update_attribute does not, and neither does update_columns or anyone with a psql prompt:
user = User.create(first_name: 'Pan')
user.update_attribute :first_name, 'Dan'
puts user.reload.first_nameDanThe row is persisted. The fix is a constraint the database enforces, and Rails 8.1 has a reversible migration method for it:
bin/rails g migration AddFirstNameConstraintToUserclass AddFirstNameConstraintToUser < ActiveRecord::Migration[8.1]
def change
add_check_constraint :users, "first_name !~* '^d'", name: "name_cannot_start_with_d"
end
endadd_check_constraint in change reverts on bin/rails db:rollback without a down method. The raw form from the 2020 version of this post still works and produces the same schema: execute "ALTER TABLE users ADD CONSTRAINT name_cannot_start_with_d CHECK (first_name !~* '^d')" in up, with a matching DROP CONSTRAINT IF EXISTS in down. If you take that route, write the down. Rails cannot infer it from a string of SQL, and a migration that cannot roll back is one you will clean up by hand later.
PostgreSQL checks the existing rows when it adds a CHECK constraint, so with the Dan row still in the table the migration fails:
PG::CheckViolation: ERROR: check constraint "name_cannot_start_with_d" of relation "users" is violated by some rowFix or delete the offending rows first, then migrate:
bin/rails db:migrateOpen db/structure.sql and the constraint is inside the table definition, followed by the sequence that only this file carries:
CREATE TABLE public.users (
id bigint NOT NULL,
first_name character varying,
last_name character varying,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL,
CONSTRAINT name_cannot_start_with_d CHECK (((first_name)::text !~* '^d'::text))
);
--
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.users_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;The 2020 version of this post then showed schema.rb unchanged after the same migration and concluded that the constraint had been lost. On Rails 8.1 that is no longer true. schema.rb gains the line whether the constraint came from add_check_constraint or from raw execute:
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "first_name"
t.string "last_name"
t.datetime "updated_at", null: false
t.check_constraint "first_name::text !~* '^d'::text", name: "name_cannot_start_with_d"
endA check constraint is not a reason to switch formats anymore. Triggers, functions, views, and domains are.
On a large live table, that row scan runs inside the ALTER TABLE, and a migration that holds a table for the duration of a scan is the kind Strong Migrations exists to flag. Good Database Migration Practices for Your Ruby on Rails App using Strong Migrations covers the migrations that lock a table and the multi-step patterns that avoid it.
Whichever file you commit, rerun the bypass and the database refuses the row:
PG::CheckViolation: ERROR: new row for relation "users" violates check constraint
PG::CheckViolation: ERROR: new row for relation "users" violates check constraint "name_cannot_start_with_d" (ActiveRecord::CheckViolation)
DETAIL: Failing row contains (2, Dan, null, 2026-09-10 14:00:50.751935, 2026-09-10 14:00:50.760331).Cause: A write reached PostgreSQL with a value the CHECK expression rejects. Here update_attribute skipped the model validation, and the database refused the row instead.
Fix: Nothing, if the constraint is doing its job: the write was wrong. If the constraint is wrong, change it in a migration (remove_check_constraint, then add_check_constraint), never by hand. Rescue ActiveRecord::CheckViolation where user input is expected to hit the constraint. It is a subclass of ActiveRecord::StatementInvalid, so older code that rescues StatementInvalid (the class this error carried on Rails 6) still catches it. The message names the constraint, which maps to the name: in your migration, and the DETAIL: line shows the failing row.
Growing Pains
Size first. The rich test schema has three tables, an enum, a domain, a function, a trigger, a view, a materialized view, two extensions, and a dozen constraints and indexes. It’s 58 lines of schema.rb and 388 lines of structure.sql. Reviewing a schema change means reading pg_dump output.
Merge conflicts are the complaint everyone has, so measure it. Two branches off the same base, each adding one column to a different table, dumped and merged with diff3. Each format produced exactly one conflict. In structure.sql it is the top of the INSERT INTO "schema_migrations" block:
INSERT INTO "schema_migrations" (version) VALUES
<<<<<<< ours.sql
('20260910140154'),
||||||| base.sql
=======
('20260910140158'),
>>>>>>> theirs.sql
('20260910140124'),In schema.rb it is the version line:
<<<<<<< ours.rb
ActiveRecord::Schema[8.1].define(version: 2026_09_10_140154) do
||||||| base.rb
ActiveRecord::Schema[8.1].define(version: 2026_09_10_140124) do
=======
ActiveRecord::Schema[8.1].define(version: 2026_09_10_140158) do
>>>>>>> theirs.rbThe resolutions differ, and getting them backwards is the source of a whole error family. In schema.rb, keep the higher version. In structure.sql, keep both lines. Every version in that block is a migration the database has run, and dropping one makes the next db:migrate re-run it against objects that already exist.
The two files also disagree about what “has run” means. structure.sql records it: the INSERT block lists exactly the versions in schema_migrations when the dump was taken. schema.rb assumes it: on load, ActiveRecord::Schema.define marks every migration file older than its version: as run, whether or not it ever did. Load a database from each file with one stray older migration and one stray newer migration in db/migrate. Rails reports two pending migrations under structure.sql and one under schema.rb, because the older stray was marked as migrated without running. On a branch-heavy team, decide which behavior you want before you pick a format. The record is the one you can audit; the assumption is the one that stays quiet.
Two hygiene rules from the 2020 version of this post still hold:
- Before switching branches, run
bin/rails db:rollback STEP=nfor thenmigrations on the branch you are leaving. Under:sql, the rollback rewritesstructure.sqland removes those version lines, so the file you carry back tomainis clean. - If you forgot, take a pristine
db/structure.sqlfrommainand rebuild your development database from it (bin/rails db:drop db:create db:schema:load) before generating a new migration. The file is regenerated from the database on everydb:migrate, so a clean file on its own is not enough.
For what the schema_migrations table is and how the version timestamps drive all of this, see Dissecting Rails Migrations. An index that one branch lost in a merge shows up as a slow query rather than an error. Finding the Slow Query Killing Your Rails App covers tracking one down.
Schema drift from a bad merge rarely fails in CI, where the database is rebuilt from the file. It fails in production: a constraint that exists there but never reached your dump raises ActiveRecord::CheckViolation on the first bad row, and an index one branch lost turns a fast query slow. With the revision config option set, AppSignal adds a deploy marker for every release, so the first exception or slow-query alert after a deploy points at the migration that shipped with it.
Switching Back to schema.rb
Set config.active_record.schema_format = :ruby, or delete the line, since :ruby is the default, and run bin/rails db:schema:dump. Rails writes db/schema.rb and leaves db/structure.sql where it is. Under :ruby the stale file is ignored completely: a structure.sql with garbage appended loaded without complaint under :ruby and failed loudly under :sql. Delete it in the same commit, before someone reads it as current.
Before you switch, list what the Ruby dump will drop. Everything in the “No” rows of the table goes silently: triggers, functions, views, materialized views, and custom domains, whose columns flatten to their base type. In psql, \df lists functions (extension functions such as citext’s appear there too, so look for the ones you wrote), \dv views, \dm materialized views, and \dD domains. Triggers appear at the end of \d posts for the table they belong to. Any object on those lists exists in your current database and in no database built from schema.rb. Stay on structure.sql, or replace the object with application code first.
One more trap on the way back: a schema.rb that was hand-edited during a conflict and lost its create_enum line fails on load with PG::UndefinedObject.
Schema Dump and Load Errors: Causes and Fixes
Every message in this section is copied from a real Rails 8.1.3.1 run against PostgreSQL 17.11. The only edit is the application path, shortened to db/structure.sql and db/migrate/…; the database is called myapp_development.
failed to execute: pg_dump
bin/rails aborted!
failed to execute:
pg_dump --schema-only --no-privileges --no-owner --file db/structure.sql myapp_development
Please check the output above for any errors and make sure that `pg_dump` is installed in your PATH and has proper permissions.Cause: Rails runs pg_dump with Kernel.system and raises whenever the command returns anything but success. When the binary is missing from PATH, system returns nil and nothing else is printed, not even a “command not found” line. So when nothing precedes this block, the binary was not found at all. Any other failure prints pg_dump’s own error first, then this block.
Fix: Install the PostgreSQL client tools on every machine that runs db:migrate or db:schema:dump, at a version no older than the server, and make sure pg_dump resolves on PATH for the user running Rails. CI images and Docker build stages are the usual offenders. The same block with psql in place of pg_dump comes from db:schema:load and db:prepare, so install psql wherever the schema is loaded. The message names the exact command; run it by hand to see the underlying error.
pg_dump: error: aborting because of server version mismatch
pg_dump: error: aborting because of server version mismatch
pg_dump: detail: server version: 18.4 (Debian 18.4-1.pgdg13+1); pg_dump version: 17.11 (Debian 17.11-0+deb13u1)
bin/rails aborted!
failed to execute:
pg_dump --schema-only --no-privileges --no-owner --file db/structure.sql myapp_developmentCause: pg_dump refuses to dump a server newer than itself. Here a PostgreSQL 17 client met an 18.4 server. That’s typical after a managed database upgrade, or when a Ruby Docker image ships an older postgresql-client than the postgres image next to it. Loading is not version-strict: psql 17 loaded the same file into the 18.4 server without complaint, so db:prepare keeps working on that machine while db:migrate starts failing.
Fix: Upgrade the client tools to at least the server’s major version; the other direction is fine, and pg_dump 18 dumped the 17 server without complaint. The detail: line names both versions.
Unrecognized command "db:structure:dump"
Unrecognized command "db:structure:dump" (Rails::Command::UnrecognizedCommandError)
Did you mean? db:schema:dumpCause: The db:structure:dump and db:structure:load tasks were removed; scripts, READMEs, and CI configs written for Rails 5 and 6 still call them.
Fix: Use bin/rails db:schema:dump and db:schema:load. They read config.active_record.schema_format and write or load whichever file matches, so the command is the same for both formats. Set SCHEMA_FORMAT=sql in the environment to force one format for a single run.
ERROR: relation "ar_internal_metadata" already exists
psql:db/structure.sql:26: ERROR: relation "ar_internal_metadata" already exists
bin/rails aborted!
failed to execute:
psql --set ON_ERROR_STOP=1 --quiet --no-psqlrc --output /dev/null --file db/structure.sql myapp_development
Please check the output above for any errors and make sure that `psql` is installed in your PATH and has proper permissions.Cause: db:schema:load ran against a database that already has a schema. pg_dump writes plain CREATE TABLE statements with no DROP and no IF NOT EXISTS, and ON_ERROR_STOP=1 aborts at the first collision. In a fresh dump the first CREATE TABLE is ar_internal_metadata, which is why that name shows up in the message. In a file that also defines types or domains, those come first, so the first collision is theirs: ERROR: type "email_address" already exists. schema.rb behaves differently: its force: :cascade drops and recreates each table, so loading it twice succeeds.
Fix: Load into an empty database, bin/rails db:drop db:create db:schema:load, or use bin/rails db:prepare for the everyday case. It loads the schema only when the database is missing and otherwise runs pending migrations. The psql:<file>:<line> prefix points at the end of the statement that collided.
PG::DuplicateObject: ERROR: type "email_address" already exists
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
PG::DuplicateObject: ERROR: type "email_address" already exists
db/migrate/20260910140124_add_sql_only_objects.rb:3:in 'AddSqlOnlyObjects#up'
Caused by:
ActiveRecord::StatementInvalid: PG::DuplicateObject: ERROR: type "email_address" already exists (ActiveRecord::StatementInvalid)Cause: A migration is running a second time against objects it already created. The usual route is the merge resolution described earlier: a version line was dropped from the INSERT INTO "schema_migrations" block in structure.sql. A database loaded from the file then has the objects but no record of the migration, so the next bin/rails db:migrate runs it again. When the re-run migration creates a table, the message is PG::DuplicateTable: ERROR: relation "users" already exists instead.
Fix: Restore the missing version line in structure.sql. On the affected database, either insert the version into schema_migrations by hand or rebuild from the corrected file with bin/rails db:drop db:create db:schema:load. The frame under the message names the migration file, and the version in its filename is the line to restore.
ActiveRecord::PendingMigrationError After db:schema:load
Migrations are pending. To resolve this issue, run:
bin/rails db:migrate
You have 2 pending migrations:
db/migrate/20260901000000_add_stray_older_index.rb
db/migrate/20260910140138_add_stray_newer_index.rbThat is the message the development middleware raises on the next request, and ActiveRecord::Migration.check_all_pending! raises the same one. The task form, from bin/rails db:abort_if_pending_migrations, reads:
Run `bin/rails db:migrate` to update your database then try again.
You have 2 pending migrations:
20260901000000 AddStrayOlderIndex
20260910140138 AddStrayNewerIndexCause: db/migrate holds files whose versions are not in schema_migrations. Right after db:schema:load, that means the file and the migrations directory disagree: someone committed a migration without re-dumping, or a merge dropped a version line. The two formats disagree here in an instructive way. Under structure.sql, both strays were pending, the older one (its version is missing from the INSERT block) and the newer one. Under schema.rb, only the newer one was: ActiveRecord::Schema.define inserts every older migration file’s version as migrated (assume_migrated_upto_version), so the older stray was marked done without ever running.
Fix: Run bin/rails db:migrate, then re-dump and commit the file so that file and directory agree again. If the “pending” migration is one the database has in fact already run (the merge case), restore its version line instead of migrating. Otherwise the migration re-runs and fails with an already-exists error. The message lists the exact files.
ActiveRecord::NoDatabaseError: Database not found
bin/rails aborted!
ActiveRecord::NoDatabaseError: Database not found: myapp_development. Available database configurations can be found in config/database.yml. (ActiveRecord::NoDatabaseError)
To resolve this error:
- Create the database by running:
bin/rails db:create
- Verify that config/database.yml contains the correct database name.Cause: The database named in config/database.yml does not exist on the server. The message is Active Record’s, so it appears from bin/rails runner, from the server, and from db:schema:load under :ruby. Under :sql, db:schema:load never reaches Active Record: psql fails first, followed by the failed to execute: psql block:
psql: error: connection to server at "db" (172.19.0.2), port 5432 failed: FATAL: database "myapp_development" does not existFix: bin/rails db:create, or bin/rails db:prepare, which creates the database and loads the schema in one step and is safe to run again later. On a CI runner or in a fresh container, db:prepare is the only database command you need. Both messages name the database; check it against config/database.yml and the DATABASE_URL in the environment.
PG::UndefinedObject: ERROR: type "post_status" does not exist
bin/rails aborted!
ActiveRecord::StatementInvalid: PG::UndefinedObject: ERROR: type "post_status" does not exist (ActiveRecord::StatementInvalid)
LINE 1: SELECT 'post_status'::regtype::oid /*application='Myapp'*/
^Cause: schema.rb declares a column as t.enum "status", … enum_type: "post_status", but the create_enum "post_status", [...] line that defines the type is missing. That block sits near the top of the file, between enable_extension and the first create_table, which is exactly where conflicts get resolved by hand. That’s why the line goes missing in conflict cleanups. The failing SELECT is Active Record looking the type up before it creates the column.
Fix: Put the create_enum line back, preferably by regenerating the file with bin/rails db:schema:dump from a database that has the type rather than typing it, and load again. The message names the type; search db/schema.rb for enum_type: "post_status" to find the columns that depend on it.
Could not dump table "companies" because of following ArgumentError
# Could not dump table "companies" because of following ArgumentError
# wrong number of arguments (given 2, expected 1)Cause: This one is silent. bin/rails db:migrate exits 0, and the table is written into schema.rb as a comment, because the Ruby dumper rescues any exception per table and keeps going. On the stack tested here (json 3.0.2 with activesupport 8.1.3.1, which is what rails new resolved on September 10, 2026), the trigger was a json or jsonb column with a default (default: {}, [], or "{}"). A plain t.jsonb without a default dumped fine. json 3.0.0 changed JSON.parse to keyword options, a change the 3.0.2 resolved here carries forward, and activesupport 8.1.3.1 still passes a positional hash, so ActiveSupport::JSON.decode raises ArgumentError while the dumper formats the default. The same ArgumentError breaks the app itself: Company.create! raised it while casting the column. So the dump failure is usually the first visible symptom of a broken gem pairing.
Loading that schema.rb builds a database without the table, and the first query raises PG::UndefinedTable: ERROR: relation "companies" does not exist. structure.sql from the same database kept the table, settings jsonb DEFAULT '{}'::jsonb NOT NULL included, because pg_dump never asks Ruby to format a default.
Fix: Search db/schema.rb for Could not dump after every migrate; in CI, a failing grep -c "Could not dump" db/schema.rb is enough. For this pairing, pin gem "json", "~> 2.9" (it resolved to 2.21.2) and re-dump: the table came back as t.jsonb "settings", default: {}, null: false. The comment names the table and the exception, and the column is the one whose default cannot be formatted.
Conclusion
Three questions decide the format:
- Does the database hold triggers, functions, views, materialized views, or domains? Check with
\df,\dv,\dm, and\dDinpsql, and\d table_namefor triggers. Anything you wrote on those lists meansstructure.sql; otherwiseschema.rbalready carries everything else on Rails 8.1. - Does every machine that migrates have a
pg_dumpno older than the server, and every machine that loads havepsql? If not,structure.sqlfails on the first deploy or CI run. - Will the team keep both
schema_migrationslines on every merge? If not, thePG::DuplicateObjectfamily is the tax on the switch.
P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Frequently asked questions
- Should I use schema.rb or structure.sql in Rails?
- Use schema.rb by default. On Rails 8.1 it captures check, unique, exclusion, and deferrable foreign-key constraints, expression and partial indexes, enum types, generated columns, comments, and extensions. Switch to structure.sql only when your database holds triggers, functions, views, materialized views, or custom domains.
- What can structure.sql capture that schema.rb cannot?
- Anything the Ruby schema dumper has no method for: triggers, functions, views, materialized views, and custom domains, which schema.rb flattens to their base type. structure.sql is the raw pg_dump output, so it preserves sequences and column defaults exactly. Constraints, indexes, enums, and generated columns round-trip through schema.rb on Rails 8.1.
- How do I switch a Rails app from schema.rb to structure.sql?
- Set config.active_record.schema_format = :sql in config/application.rb, run bin/rails db:schema:dump, commit db/structure.sql, and delete db/schema.rb. Every machine that migrates needs a pg_dump no older than the server, and every machine that loads the schema needs psql. The old db:structure:dump task no longer exists.
- Why does rails db:schema:dump fail with failed to execute: pg_dump?
- Rails shells out to pg_dump with --schema-only --no-privileges --no-owner and raises that message whenever the command returns anything but success. A missing binary prints nothing else; a server version mismatch prints pg_dump’s own error first. Install the PostgreSQL client tools matching your server, or point PATH at them.
- How do I resolve a merge conflict in structure.sql?
- When two branches touch different tables, the only conflict is the INSERT INTO schema_migrations block, where each branch added its own version line. Keep both lines. Dropping one makes Rails re-run that migration on the next db:migrate, which fails with an already-exists error such as PG::DuplicateObject.
Published , Updated
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Daniele Pestilli
Guest author Daniele is a full-stack developer from Italy with an eye for clean, elegant code. He has lived and worked on three continents, and as of late, splits his time working as a digital nomad between Italy and East Asia. Besides coding, Daniele enjoys reading, drawing and playing guitar.
All articles by Daniele PestilliBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


