Rails seeds that survive a second run
A seed file is written on day one and then run for years, and the second run is the one nobody
tests. The first run is easy: the table is empty, every create! succeeds, the output looks right.
Everything interesting about Rails seed data happens on run two, on run two hundred, and on the run
where two deploys overlap and both processes reach the same line at the same moment.
This post is about what the framework actually guarantees there, which is less than the method names suggest, and about the import task this site runs for its 48 quiz answers. Everything below was reproduced against activerecord 8.1.3.1 on PostgreSQL 17.7, and the SQL is copied out of the log.
db:prepare seeds exactly once, and that once is already behind you
bin/rails db:seed loads db/seeds.rb every time you ask. bin/rails db:prepare does not, and
db:prepare is what deploys run. This site's Procfile and the product's both say
release: bin/rails db:prepare db:load_solid_schemas, and the Kamal path says the same thing in
bin/docker-entrypoint. The decision is four lines of
active_record/tasks/database_tasks.rb:
def prepare_all
seed = false
each_current_configuration(env) do |db_config|
database_initialized = initialize_database(db_config)
seed = true if database_initialized && db_config.seeds?
end
# ... migrate, dump schema ...
load_seed if seed
end
database_initialized is true only on the run that created the database. So the seeds ran once, on
the very first deploy, and every deploy since has skipped the file entirely. A plan row added to
db/seeds.rb in month six is in the repository, is in the slug, and is not in the database.
seeds? is configuration_hash.fetch(:seeds, primary?) in
database_configurations/hash_config.rb:161, which is also why a multi-database app does not
re-seed when only the cache database is created.
There is a task that does re-run the file wholesale, and it is db:seed:replant, defined as
task replant: [:load_config, :truncate_all, :seed]. It truncates every table first.
truncate_all depends on check_protected_environments, so in production it raises
ActiveRecord::ProtectedEnvironmentError (migration.rb:206) with "You are attempting to run a
destructive action against your 'production' database." That guard is the only thing between a
tired operator and an empty users table, and DISABLE_DATABASE_ENVIRONMENT_CHECK=1 removes it.
The practical conclusion is that db/seeds.rb is a development convenience. Data that has to exist
in production, and has to keep existing as it changes, needs a task of its own that you can run on
purpose.
find_or_create_by is a SELECT and an INSERT
The standard idempotent seed line looks atomic and is not:
Plan.find_or_create_by!(key: "pro") { |p| p.name = "Pro"; p.amount_cents = 2900 }
Plan Load (0.7ms) SELECT "plans".* FROM "plans" WHERE "plans"."key" = $1 LIMIT $2
TRANSACTION (0.1ms) BEGIN
Plan Exists? (0.2ms) SELECT 1 AS one FROM "plans" WHERE "plans"."key" = $1 LIMIT $2
Plan Create (0.4ms) INSERT INTO "plans" ("key", "name", ...) VALUES ($1, $2, ...) RETURNING "id"
TRANSACTION (0.2ms) COMMIT
Three statements, and two of them are reads. The Plan Exists? line is
validates :key, uniqueness: true doing its own lookup, which is a second chance to be wrong rather
than a defence. Rails says so itself, in the doc comment above find_or_create_by in
active_record/relation.rb:227: "Please note this method is not atomic, it runs first a SELECT,
and if there are no results an INSERT is attempted."
Eight forked processes, released together, against an empty table with no unique index on key:
index unique? false
rows in table: 7
7x ok
1x ActiveRecord::RecordInvalid: Validation failed: Key has already been taken
Seven duplicate rows. The uniqueness validator caught one process out of eight, which is what a
validator is worth under concurrency. The same eight processes against the same table with
add_index :plans, :key, unique: true:
index unique? true
rows in table: 1
8x ok
One row, no exceptions, nothing to clean up. The index is doing all of the work. If you take one
thing from this post it is that the phrase "idempotent seeds" describes a schema, not a method: the
database constraint is the idempotency, and find_or_create_by is a convenience wrapped around it.
Why the second run raised nothing at all is a Rails 7.1 change. In activerecord 7.0.8,
relation.rb:168:
def find_or_create_by(attributes, &block)
find_by(attributes) || create(attributes, &block)
end
In 8.1.3.1, relation.rb:231:
def find_or_create_by(attributes, &block)
find_by(attributes) || create_or_find_by(attributes, &block)
end
create_or_find_by rescues ActiveRecord::RecordNotUnique and looks the row up again. Seven
processes hit the unique index, got the violation, and found the winner's row.
The retry that raises RecordNotFound
That rescue has a sharp edge, and it is deterministic enough to hit without any concurrency at all.
create_or_find_by re-finds with find_by!(attributes), meaning every attribute you passed, not
just the one the index covers. A model with no uniqueness validator, one stored row, and a seed line
whose attributes drifted:
Bare.create!(key: "pro", name: "Professional")
Bare.find_or_create_by!(key: "pro", name: "Pro")
ActiveRecord::RecordNotFound: Couldn't find Bare with [WHERE "plans"."key" = $1 AND "plans"."name" = $2]
The SELECT misses because name differs, the INSERT violates the index on key, and the rescue
searches for a row that cannot exist. Somebody renamed a plan in the admin, and now the seed task
crashes. This was filed as rails#51149 against 7.1.3 and closed as not planned, which is defensible:
the method is behaving exactly as documented, and the documentation is what is surprising. Keep the
lookup attributes down to the ones the unique index covers and put everything else in the block,
where it only runs on create.
Savepoints, sequence gaps and the subtransaction scare
create_or_find_by opens transaction(requires_new: true), which outside a transaction is a plain
BEGIN. Inside one it is a savepoint per attempted row:
TRANSACTION BEGIN
Bare Load SELECT "plans".* FROM "plans" WHERE "plans"."key" = $1 LIMIT $2
Bare Load SELECT "plans".* FROM "plans" WHERE "plans"."key" = $1 LIMIT $2
TRANSACTION SAVEPOINT active_record_1
Bare Create INSERT INTO "plans" ("key", "name", ...) VALUES ($1, $2, ...) RETURNING "id"
TRANSACTION RELEASE SAVEPOINT active_record_1
Bare Load SELECT "plans".* FROM "plans" WHERE "plans"."key" = $1 LIMIT $2
TRANSACTION SAVEPOINT active_record_1
Bare Create INSERT INTO "plans" ("key", "name", ...) VALUES ($1, $2, ...) RETURNING "id"
TRANSACTION RELEASE SAVEPOINT active_record_1
TRANSACTION COMMIT
This is the behaviour behind rails#51052, where a large installation reported that the seed of subtransactions "can grow so large that the working set no longer fits into memory" and said "for now, we're going to patch ActiveRecord to revert the change". The issue is still open and marked stale.
Being honest about the size of that risk matters, because the version of it that circulates is
worse than the real one. The PostgreSQL 17 documentation says "Up to 64 open subxids are cached in
shared memory for each backend; after that point, the storage I/O overhead increases significantly
due to additional lookups of subxid entries in pg_subtrans." The word doing the work is open.
Rails releases each savepoint immediately, so a thousand-row seed does not hold a thousand open
subxids; it opens and closes one at a time. A seed loop is not the shape that triggers the
pathology. A long-lived request that opens nested transactions and keeps them open is.
What every savepoint does cost, unavoidably, is a transaction id. Measured in psql:
BEGIN;
SELECT pg_current_xact_id(); -- 9294372
SAVEPOINT s1; INSERT INTO sp(k) VALUES ('a'); RELEASE SAVEPOINT s1;
SAVEPOINT s2; INSERT INTO sp(k) VALUES ('b'); RELEASE SAVEPOINT s2;
SAVEPOINT s3; INSERT INTO sp(k) VALUES ('c'); RELEASE SAVEPOINT s3;
SELECT xmin FROM sp ORDER BY id; -- 9294373, 9294374, 9294375
COMMIT;
Primary keys go the same way. Five create_or_find_by! calls that created nothing at all moved
plans_id_seq.last_value from 2 to 7, because PostgreSQL allocates the sequence value before the
index rejects the row. find_or_create_by burned none of them, since its leading SELECT found the
row and it never reached the INSERT. That is the actual argument for find_or_create_by over
create_or_find_by in a seed: on a table that is mostly already seeded, the SELECT is the cheap path.
upsert_all does the whole batch in one statement
When the Rails seed you are writing is a list of rows that should end up matching a file, the one-statement answer is better than the loop:
Plan.upsert_all(
[{ key: "pro", name: "Pro", amount_cents: 2900 },
{ key: "biz", name: "Business", amount_cents: 9900 }],
unique_by: :key
)
INSERT INTO "plans" ("key","name","amount_cents","created_at","updated_at")
VALUES ('pro', 'Pro', 2900, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('biz', 'Business', 9900, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("key") DO UPDATE SET
updated_at=(CASE WHEN ("plans"."name" IS NOT DISTINCT FROM excluded."name"
AND "plans"."amount_cents" IS NOT DISTINCT FROM excluded."amount_cents")
THEN "plans".updated_at ELSE CURRENT_TIMESTAMP END),
"name"=excluded."name","amount_cents"=excluded."amount_cents"
RETURNING "id"
The CASE WHEN is the part worth knowing about, and it is generated by
touch_model_timestamps_unless in active_record/insert_all.rb:284: a row whose payload is
identical to what is stored keeps its old updated_at. Rails is quietly implementing "already
current" for you. That matters more than it sounds, because updated_at is what this site prints as
<lastmod> in its sitemap, for the same reason described in
Counter caches by hand: a timestamp that moves when nothing
changed tells a crawler a lie, once per deploy.
insert_all with the same unique_by builds ON CONFLICT ("key") DO NOTHING instead, which is the
right verb for rows that are created once and then owned by the application. Confirmed: a second
insert_all with name: "Pro (renamed)" left the stored name as "Pro".
One failure worth knowing before it happens in a release script. A batch that mentions the same key twice does not deduplicate:
ActiveRecord::StatementInvalid: PG::CardinalityViolation: ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time
rows after: 0
Zero rows. The whole statement rolled back because of one repeated key in a YAML file, which is exactly the kind of thing a hand-edited seed file grows.
What upsert_all silently skips
Nothing model-shaped runs. A Plan declared with before_save { raise "callback ran" } and a
validate { errors.add(:base, "validation ran") } was written by upsert_all without either one
firing. Most people expect that much.
Custom attribute writers go too, and that one is easy to miss. A model normalising a jsonb column:
def choices=(value)
super(Array.wrap(value).map { |c| { "body" => c["body"].to_s, "correct" => c["correct"] == true } })
end
through the setter: [{"body" => "yes", "correct" => false}]
through upsert_all: [{"body" => "yes", "correct" => "1"}]
The string "1" is now sitting in a jsonb column where the rest of the application expects a
boolean, and nothing raised. Column type casting still happens, so the Hash became valid jsonb; the
method that made it correct jsonb did not. Anything you rely on in a writer, a normalisation, or a
before_validation has to be re-done in the payload you hand upsert_all, and
jsonb columns in Rails is where that bites hardest, because jsonb
will accept nearly anything you give it.
Fixtures empty the table before they fill it
Fixtures come up in every seeding discussion, and one line of their SQL settles it. Loading
fx/plans.yml with a single record:
ALTER TABLE "plans" DISABLE TRIGGER ALL
Fixtures Load DELETE FROM "plans";
INSERT INTO "plans" ("id", "key", "name", ...) VALUES (733271808, 'pro', 'Pro', ...)
ALTER TABLE "plans" ENABLE TRIGGER ALL
DELETE FROM "plans" first, foreign keys disabled around the whole thing, and a raw INSERT with no
model involved. The Plan used for that run had validates :key, format: { with: /\A\d+\z/ } and a
before_save that raises; the fixture violated the format and neither fired.
That id is not random. ActiveRecord::FixtureSet.identify (fixtures.rb:619) is
Zlib.crc32(label.to_s) % MAX_ID with MAX_ID = 2**30 - 1, and Zlib.crc32("pro") % (2**30 - 1)
is 733271808. Stable across machines, which is what lets one fixture reference another by label.
So fixtures are a fast, destructive, whole-table replacement designed to give every test the same
starting state. They are excellent at that and disqualified from production by the DELETE alone.
Factories are the other direction: neither this site nor the product ships a single fixture file,
both ship spec/factories/, and factory_bot is on 6.6.0 released 4 May 2026, so it is a live
dependency rather than a bet. A factory builds one object for one example and knows nothing about a
file on disk, which is the wrong shape for seeds for the opposite reason. Use fixtures or factories
for tests, and neither of them for the rows your production database has to keep.
What this site's importer does instead
The 48 quiz answers at /quiz are editable in an admin and therefore live in the database, not in
files like these Yield posts. They travel in db/data/quiz_answers.yml, written by
bin/rails quiz:export and replayed by bin/rails quiz:import. Three runs, in order: an empty
database, the same import again, and then the same import after one row was rolled back to an old
updated_at.
quiz:import done - 48 created, 0 updated, 0 already current.
quiz:import done - 0 created, 0 updated, 48 already current.
quiz:import done - 0 created, 1 updated, 47 already current.
The third count is the one that earns the task. "Already current" is not a rounding of "updated": the
file is a snapshot, not an authority, and the rule is that the newer updated_at wins.
if answer.nil?
QuizAnswer.new(attrs).save!(validate: true)
created += 1
elsif attrs["updated_at"].present? && answer.updated_at >= attrs["updated_at"]
skipped += 1
else
answer.update!(attrs.except("created_at"))
updated += 1
end
Verified by editing a title in the database with an updated_at an hour in the future and re-running:
0 created, 0 updated, 48 already current, and the local edit survived. Replaying a stale export
cannot silently undo somebody's correction.
Timestamps are carried in the file and assigned rather than generated, which is why the task sets
QuizAnswer.record_timestamps = false and restores it in an ensure. Without that, every import
would stamp all 48 rows with the current time, and the sitemap would tell Google that all 48 pages
changed today, every single deploy. Worth knowing that record_timestamps is a class_attribute
(active_record/timestamp.rb:47), so the flag is process-wide for that class: fine in a rake task,
dangerous anywhere a web request could run in parallel.
And it is a find_by plus save! loop rather than upsert_all, on purpose, for the reason two
sections up. QuizAnswer#choices= normalises what the YAML holds into
[{ "body" => ..., "correct" => bool }], validate: true is passed explicitly, and the model has a
lock_version column. upsert_all would bypass all three. The safety net underneath is still the
schema: index_quiz_answers_on_slug is unique, so even if two imports overlapped, the second would
be rejected by PostgreSQL rather than duplicating 48 rows.
Where I would not reach for any of this
The position, stated plainly: use find_or_create_by for development fixtures-in-spirit, use
upsert_all when a file is the authority, and write a named rake task with counts for anything
production depends on. The product's db/seeds.rb follows the first rule and gets away with it,
because all four of its find_or_create_by! lookups sit on a unique index
(index_users_on_email_address, index_subscriptions_on_stripe_id,
index_transactions_on_stripe_id). Remove any one of those indexes and the seed file becomes a
duplicate generator under a parallel CI run.
What I would not do is reach for a gem. seed-fu last released 2.3.9 on 7 April 2018 and seedbank
last released 0.5.0 on 3 December 2018. Both solve real problems, ordering and per-environment seed
files, and both have been unmaintained for seven years; the whole of quiz.rake is 75 lines
including its comments, export as well as import, which is cheaper than a dependency that old.
What would change my mind: a seed set large enough that the per-row round trip stops being
affordable. 48 rows import in under a second and the counts are worth more than the milliseconds. At
fifty thousand rows the loop is the wrong tool, upsert_all in batches is the right one, and the
"already current" count has to be reconstructed from the CASE WHEN on updated_at rather than
decided in Ruby. This codebase has not needed that, and it is not worth building before you do.
Comments
No comments yet. Be the first.