LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.