Resetting a Rails database
"Reset the database" means four different things in Rails and the commands that do them are not interchangeable. Two of them drop your database, one empties the tables and leaves them standing, and the one that sounds safest re-ran the seed file when nobody asked it to. The differences are not in the documentation strings; they are in which tasks each one has as prerequisites.
Everything below was run against rails 8.1.3.1, ruby 4.0.5 and PostgreSQL 17.7 on port 15432, on an
Apple M2 Max with 12 cores under macOS 26.5.1. The application is a scratch app generated with
rails new --database=postgresql, carrying 64 migrations, 63 tables, a db/seeds.rb that creates
one Post, and one migration that INSERTs three plans rows with execute. Every transcript is
pasted out of the terminal.
What db:reset actually invokes
db:reset is two words in databases.rake and then a chain of six more tasks. Asking rake to print
the chain is faster than reading the file:
$ bin/rails db:reset --trace 2>&1 | grep -E "^\*\* Execute"
** Execute environment
** Execute db:load_config
** Execute db:check_protected_environments
** Execute db:drop
** Execute db:drop:_unsafe
** Execute db:create
** Execute db:schema:load
** Execute db:seed
** Execute db:abort_if_pending_migrations
** Execute db:setup
** Execute db:reset
Three lines in activerecord 8.1.3.1 explain all of it. databases.rake:305 is
task reset: [ "db:drop", "db:setup" ]. databases.rake:392 is
task setup: ["db:create", :environment, "db:schema:load", :seed]. And databases.rake:400 is the
db:seed task, whose first statement is db_namespace["abort_if_pending_migrations"].invoke and
whose second is ActiveRecord::Tasks::DatabaseTasks.load_seed. That ordering is the reason the
pending-migration check shows up after the seed in the trace, and it matters later.
The word missing from that list is migrate. db:reset rebuilds your schema from db/schema.rb
and nothing else, which is fast, deterministic, and the source of the one surprise that costs people
an afternoon.
Rows a migration INSERTed do not survive db:reset
db/schema.rb describes tables, columns, indexes and foreign keys. It contains no rows. So a
migration that creates a lookup table and fills it in the same file gives you a table after a reset
and an empty one. This migration is the shape people actually write:
class CreatePlansWithRows < ActiveRecord::Migration[8.1]
def up
create_table :plans do |t|
t.string :key, null: false
t.integer :cents, null: false
end
execute <<~SQL
INSERT INTO plans (key, cents) VALUES ('free', 0), ('pro', 8000), ('team', 24000)
SQL
end
def down
drop_table :plans
end
end
Three rows before the reset, none after, while the seeded Post survives because db/seeds.rb is
the file db:reset does run:
$ bin/rails runner "puts Plan.count, Post.count"
3
1
$ bin/rails db:reset
Dropped database 'rails_reset_database_development'
Dropped database 'rails_reset_database_test'
Created database 'rails_reset_database_development'
Created database 'rails_reset_database_test'
$ bin/rails runner "puts Plan.count, Post.count"
0
1
Nothing warned. The task exited 0, printed four cheerful lines, and left an application whose
plans table is empty and whose checkout will raise the first time somebody looks up
Plan.find_by(key: "pro"). The generated create_table "plans" block in db/schema.rb is present
and correct, which is what makes this hard to spot: the schema diff after a reset is empty.
The position I would take from that: reference data belongs in db/seeds.rb, written so a second
run is a no-op, and a migration that backfills should be treated as a one-shot piece of deploy
machinery that nobody will ever replay. The cost is real. You now maintain the same three rows in
two places for as long as the migration file exists, and on a db:migrate:reset you get them twice
unless the seed file is idempotent. The alternative, putting reference data only in migrations, is
worse: it means every new developer's database is correct and every reset one is not.
db:migrate:reset replays the migrations and skips the seeds
db:migrate:reset is a different chain, declared one line up at databases.rake:164:
task reset: ["db:drop", "db:create", "db:schema:dump", "db:migrate"]
Drop, create, dump, migrate. No db:setup, so no db:seed. Run it on the same application and the
two counts swap places, because the migration's INSERT ran again and db/seeds.rb did not:
$ bin/rails db:migrate:reset
-- execute("INSERT INTO plans (key, cents) VALUES ('free', 0), ('pro', 8000), ('team', 24000)\n")
-> 0.0039s
== 20260927120000 CreatePlansWithRows: migrated (0.0161s) =====================
$ bin/rails runner "puts Plan.count, Post.count"
3
0
The second thing it leaves behind is an empty test database. db:drop and db:create in
development act on both the development and the test config, because
database_tasks.rb:610 reads
environments << "test" if environment == "development" && !ENV["SKIP_TEST_DATABASE"] && !ENV["DATABASE_URL"].
db:migrate does not. So the test database is created and then nothing builds its schema:
$ bin/rails runner -e test "puts ActiveRecord::Base.lease_connection.tables.size"
0
That state repairs itself on the next test run, which is why it goes unnoticed. With no
maintain_test_schema line anywhere in config/, so on the default of true, one Minitest file
against the zero-table test database above both passed and rebuilt it:
$ bin/rails test test/models/post_test.rb
1 runs, 3 assertions, 0 failures, 0 errors, 0 skips
$ bin/rails runner -e test "puts ActiveRecord::Base.lease_connection.tables.size"
65
Turn that default off, which plenty of projects do to stop the schema check costing a second on
every run, and db:migrate:reset hands you a test suite with no tables in it.
The command that leaves both databases right
bin/rails db:migrate:reset db:seed db:test:prepare is the one to type when you want a database
built by the migrations, seeded, with a test database that matches. On this application it exits 0
and leaves 3 plans, 1 Post and 65 tables in the test database. db:test:prepare is worth
knowing about because it does not appear in bin/rails -T; bin/rails db:test:prepare --trace
shows it as db:test:purge followed by db:test:load_schema.
If what you want instead is a database built from db/schema.rb, which is the option that still
works on a project whose oldest migrations reference model code that has since been deleted, then
bin/rails db:reset is already that command and the only thing you owe it is a seed file carrying
whatever reference data a migration used to insert.
One unrun migration file turns db:reset into a wipe
db:seed calls abort_if_pending_migrations before load_seed, and db:reset has already dropped
your databases by the time it gets there. So a single migration file sitting in db/migrate that
has not run, the usual cause being a git pull you have not migrated yet, produces this:
$ bin/rails db:reset; echo "exit $?"
Run `bin/rails db:migrate` to update your database then try again.
Dropped database 'rails_reset_database_development'
Dropped database 'rails_reset_database_test'
Created database 'rails_reset_database_development'
Created database 'rails_reset_database_test'
You have 1 pending migration:
20260927140000 AddSubtitleToPosts
exit 1
$ bin/rails runner "puts Post.count"
0
Read the exit code, not the output. The task failed, both databases were still dropped and
recreated, and the seeds never ran. The first line, "Run bin/rails db:migrate to update your
database then try again", is written to stderr while the four "Dropped"/"Created" lines go to
stdout, so on a terminal the advice appears above the destruction it is too late to prevent.
The fix is bin/rails db:migrate and then bin/rails db:reset again, and it costs you nothing
because the database was already empty. The thing to take from it is that db:reset is not
atomic and does not check anything before it drops.
ProtectedEnvironmentError fires on the metadata row, not on RAILS_ENV
ActiveRecord::ProtectedEnvironmentError is the guard everybody meets once and then mis-remembers
as "Rails will not let me drop production". What it actually reads is the environment row of
ar_internal_metadata in the database in front of it. Restore a production dump into your
development database, which is the most ordinary thing in the world to do, and the row comes with
it. Here it is, on a development database, with RAILS_ENV unset:
$ psql ... -c "update ar_internal_metadata set value = 'production' where key = 'environment'"
UPDATE 1
$ bin/rails db:reset
bin/rails aborted!
ActiveRecord::ProtectedEnvironmentError: You are attempting to run a destructive action against your 'production' database. (ActiveRecord::ProtectedEnvironmentError)
If you are sure you want to continue, run the same command with the environment variable:
DISABLE_DATABASE_ENVIRONMENT_CHECK=1
Tasks: TOP => db:reset => db:drop => db:check_protected_environments
exit 1
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 is what the message tells you to use and it works, including
against a real RAILS_ENV=production database, which is the whole reason to be careful with it. The
quieter fix is that bin/rails db:migrate rewrites the row for you:
$ bin/rails db:migrate
exit 0
$ psql ... -tAc "select key, value from ar_internal_metadata where key='environment'"
environment|development
It printed nothing. Nothing in the output says a guard was just disarmed, and that is the part worth remembering: the command you run to check whether your dump landed is also the command that removes the protection against dropping it.
db:drop cannot drop a database somebody is connected to
db:reset fails outright if a bin/rails console, a running bin/rails server, a psql window or
a database GUI is holding a connection. On activerecord 8.1.3.1 the drop is a plain
DROP DATABASE, so PostgreSQL refuses:
$ bin/rails db:reset
PG::ObjectInUse: ERROR: database "rails_reset_database_development" is being accessed by other users
DETAIL: There is 1 other session using the database.
Couldn't drop database 'rails_reset_database_development'
bin/rails aborted!
ActiveRecord::StatementInvalid: PG::ObjectInUse: ERROR: database "rails_reset_database_development" is being accessed by other users (ActiveRecord::StatementInvalid)
Tasks: TOP => db:drop:_unsafe
Close the console. If you cannot find what is holding it,
select pid, application_name, state from pg_stat_activity where datname = '<database>' names it,
application_name and all: two open psql sessions showed up as pids 69244 and 69165, both
active. Rails 8.2 removes the problem by appending WITH (FORCE) above PostgreSQL 13, which is
measured in the Rails 8.2 notes on this site and is the change in
that release you will notice first.
db:truncate_all when you want the rows gone and the tables kept
bin/rails db:truncate_all empties every table without dropping anything, which is the right tool
when the schema is fine and the data is not. Subscribing to sql.active_record while calling
ActiveRecord::Tasks::DatabaseTasks.truncate_all(Rails.env) prints exactly what it sends:
ALTER TABLE "schema_migrations" DISABLE TRIGGER ALL;ALTER TABLE "ar_internal_metadata" DISABLE TRIGGER ALL;ALTER TABLE "posts" DISABLE TRIGGER ALL;ALTER TABLE "comments" DISABLE TRIGGER ALL
TRUNCATE TABLE "posts", "comments"
ALTER TABLE "schema_migrations" ENABLE TRIGGER ALL;ALTER TABLE "ar_internal_metadata" ENABLE TRIGGER ALL;ALTER TABLE "posts" ENABLE TRIGGER ALL;ALTER TABLE "comments" ENABLE TRIGGER ALL
Two details there are load bearing. schema_migrations and ar_internal_metadata have their
triggers disabled but are not in the TRUNCATE, so your migration history survives. And the
statement carries no RESTART IDENTITY, which build_truncate_statement in
database_statements.rb:672 confirms is just "TRUNCATE TABLE #{quote_table_name(table_name)}". The
sequences keep counting:
$ bin/rails runner "puts Post.count, Post.maximum(:id), Plan.count"
3
3
0
$ bin/rails db:truncate_all
$ bin/rails runner "puts Post.count, Plan.count, Post.create!(title: %q(next)).id"
0
0
4
Empty table, and the next row is id 4. If you have a test asserting on id 1, or a fixture file
keyed by id, truncating is not the same as resetting and you want db:reset. bin/rails
db:seed:replant is truncate_all followed by db:seed and inherits the same behaviour.
db:prepare seeded development because the test database was missing
db:prepare is the non-destructive one: create the database if it does not exist, migrate it if it
does. It is the task that goes on a release line: this site's own Procfile line 7 reads
release: bin/rails db:prepare db:load_solid_schemas, and bin/docker-entrypoint line 5 is
./bin/rails db:prepare. It also has a behaviour that is hard to believe until you watch it. Drop
only the test database, then run db:prepare, and db/seeds.rb runs again against
development:
$ bin/rails runner "puts Post.count"
1
$ dropdb -h 127.0.0.1 -p 15432 rails_reset_database_test
$ bin/rails db:prepare
Created database 'rails_reset_database_test'
$ bin/rails runner "puts Post.count"
2
prepare_all at database_tasks.rb:174 loops over every configuration for the environment, which
in development means the development config and the test config, and sets one shared flag:
seed = true if database_initialized && db_config.seeds? on line 181. The test config is
primary?, so seeds? returns true for it. Line 205 is load_seed if seed, which runs against
ActiveRecord::Base, which is development. Nothing scopes the flag to the database that was
initialized.
Run db:prepare a second time and nothing happens, because the test database now exists. That is
what makes it easy to dismiss as a fluke. SKIP_TEST_DATABASE=1 bin/rails db:prepare confines the
whole thing to development and does not re-seed, and the same variable makes db:reset touch only
the development database, which is worth setting on any machine where the test database is expensive
to rebuild.
The dead end: a failed db:migrate:reset overwrote a good schema.rb
The wrong turn on this page was assuming db:migrate:reset would be the slow but safe option. Look
again at databases.rake:164: db:drop, db:create, db:schema:dump, db:migrate. The dump
runs against the database that db:create just made, which is empty. So db/schema.rb is
overwritten with an empty schema before a single migration runs, and only rebuilt if db:migrate
reaches the end. Add one migration that fails:
$ wc -l db/schema.rb
519 db/schema.rb
$ bin/rails db:migrate:reset
== 20260927150000 Boom: migrating =============================================
-- add_column(:plans, :key, :string)
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
PG::DuplicateColumn: ERROR: column "key" of relation "plans" already exists
$ wc -l db/schema.rb
17 db/schema.rb
$ grep "define(version" db/schema.rb
ActiveRecord::Schema[8.1].define(version: 0) do
$ bin/rails runner "puts ActiveRecord::Base.lease_connection.tables.size"
65
519 lines down to 17, 63 create_table calls down to zero, and the database itself is fine with all
65 tables in it. Your working tree now holds a 500 line deletion in a file whose diff nobody reads
carefully, and if that lands on a branch, every teammate's bin/rails db:reset builds an empty
database. The recovery is bin/rails db:migrate, which dumps the real schema again, or git
checkout db/schema.rb.
The same ordering leaves a second trace that is harmless and worth recognising. During db:migrate,
migrate_all at database_tasks.rb:243 calls initialize_database, which at lines 660 to 664
loads the schema file when schema_migrations does not exist yet. At that moment the schema file is
the empty one db:schema:dump wrote, so assume_migrated_upto_version(0) runs
INSERT INTO schema_migrations (version) VALUES ('0') at schema_statements.rb:1406. The row stays:
$ bin/rails db:drop db:create db:migrate # then count schema_migrations
64
["20260927080448", "20260927080449", "20260927090000"]
$ bin/rails db:migrate:reset # then count again
65
["0", "20260927080448", "20260927080449"]
64 migration files, 65 rows. If you have ever wondered where the 0 in your schema_migrations
table came from, somebody ran db:migrate:reset.
What it costs in wall clock, and why the ranking is not what you expect
Three runs of each command on this machine, /usr/bin/time -p, against 64 migrations and 63 tables,
with bin/rails runner nil measured alongside so you can subtract the boot:
| Command | run 1 | run 2 | run 3 |
|---|---|---|---|
bin/rails runner nil (boot only) |
0.78 | 0.75 | 0.72 |
bin/rails db:reset |
4.06 | 2.29 | 1.95 |
bin/rails db:migrate:reset |
3.11 | 1.79 | 1.60 |
bin/rails db:migrate:reset db:seed db:test:prepare |
2.73 | 2.04 | 2.27 |
bin/rails db:seed:replant |
0.99 | 0.95 | 1.01 |
db:migrate:reset came out faster than db:reset on every run, which is the opposite of the usual
advice and is not a point in its favour: it is faster because it never builds the test database.
Once you add the two tasks that make it equivalent, the three way difference across 64 trivial
migrations is under half a second and none of it is worth choosing a command over. The folklore that
replaying migrations is slow is about migrations that do data work; 64 create_table calls are not
that, and on a real application with backfills in the history the gap goes the other way by orders
of magnitude.
The number that actually matters in that table is the last one. db:seed:replant costs about
0.25 seconds of work on top of a 0.75 second boot, because it never drops anything. If your loop is
"put the app back to a known state twenty times an hour", truncating is the command and dropping is
a habit.
What this page does not cover
PostgreSQL only, and one database. Multi-database applications, including the Rails 8 default where
Solid Cache, Solid Queue and Solid Cable each carry their own config and migrations_paths, change
which of these tasks touch what, and db:reset:primary versus db:reset is a real distinction this
page does not make. MySQL and SQLite differ on the drop and on TRUNCATE; nothing here was run
against either. structure.sql instead of schema.rb changes the dump and load path entirely and
is not measured. Resetting a database on a platform you do not administer is a different command
with its own confirmation flow, heroku pg:reset being the obvious one, and nothing on this page
was run against a hosted database. The only thing measured here about DATABASE_URL is the one
line of database_tasks.rb:610 that drops the test config out of the loop when it is set.
Comments
No comments yet. Be the first.