LaunchKit
← All posts
· 15 min read · by The LaunchKit team · 1 views

Rails fixtures vs factories

A fixture is a row. A factory is a call to your own model. Almost every argument about rails fixtures vs factories is downstream of that one difference, and most of the arguments get the consequences backwards, including the speed one, which is the argument everybody leads with and the weakest of the three that matter.

This post is written from a codebase that picked factories. The product this site sells ships 141 spec files, 11 files under spec/factories/, and no fixtures directory at all. Below is what each side actually does at activerecord 8.1.3.1 and factory_bot 6.6.0, measured against a local PostgreSQL, and the condition that would move this codebase to the other side.

What the fixture loader emits

ActiveRecord::FixtureSet.create_fixtures reads the YAML, turns every file into table rows, and hands the whole batch to the adapter in one go. From active_record/connection_adapters/abstract/database_statements.rb:508:

def insert_fixtures_set(fixture_set, tables_to_delete = [])
  fixture_inserts = build_fixture_statements(fixture_set)
  table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name(table)}" }
  statements = table_deletes + fixture_inserts

  transaction(requires_new: true) do
    disable_referential_integrity do
      execute_batch(statements, "Fixtures Load")
    end
  end
end

Running that against a two table schema, with the Active Record logger attached, produces exactly this:

TRANSACTION (0.1ms)  BEGIN
 (0.2ms)  ALTER TABLE "users" DISABLE TRIGGER ALL;ALTER TABLE "posts" DISABLE TRIGGER ALL; ...
Fixtures Load (0.5ms)  DELETE FROM "users";
DELETE FROM "posts";
INSERT INTO "users" ("id", "email", "name", "admin", "created_at", "updated_at")
  VALUES (407088666, 'mehdi@example.com', 'Mehdi', TRUE, '2026-09-24 14:22:40.395739', ...),
         (205490495, 'not-an-email-at-all', DEFAULT, FALSE, '2026-09-24 14:22:40.395739', ...);
INSERT INTO "posts" ("id", "user_id", "title", "created_at", "updated_at")
  VALUES (936075699, 407088666, 'Welcome', ...), (108828309, 205490495, 'Generated at 42', ...)
 (0.3ms)  ALTER TABLE "users" ENABLE TRIGGER ALL; ...
TRANSACTION (0.3ms)  COMMIT

One statement batch, one transaction, triggers off. 500 users and 500 posts took 34.2 ms that way. created_at and updated_at were filled in by the loader even though nothing in the YAML mentioned them, DEFAULT appears where a fixture omitted a column, and the Generated at 42 title is ERB: fixture files are run through ERB before the YAML parser sees them, which is how the Rails 8 authentication generator writes one BCrypt::Password.create("password") at the top of test/fixtures/users.yml and reuses the digest for both users.

The id that comes from the label

A fixture never writes its own primary key, and the loader does not ask the sequence for one. It hashes the label:

MAX_ID = 2**30 - 1

def identify(label, column_type = :integer)
  if column_type == :uuid
    Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s)
  else
    Zlib.crc32(label.to_s) % MAX_ID
  end
end

identify("mehdi") is 407088666, on this machine and on yours, forever. Zlib.crc32("mehdi") % (2**30 - 1) computed by hand gives the same number, so there is nothing else in the mechanism. For a UUID primary key you get a version 5 UUID off the OID namespace instead: 7d85391a-26d2-5b78-b8a7-9c1dd381784a.

That single design decision is what makes the whole scheme work. user: mehdi inside posts.yml does not need users.yml to have been loaded, does not need a query, and does not need the files to be processed in any particular order, because the id is a pure function of the string. It is also why a fixture id is a nine digit number nobody can read, why reset_pk_sequence! has to run after every load to stop the sequence colliding with them, and why 2 to the 30 is a ceiling you inherit whether your ids are bigints or not.

Rows your application could not have created

Nothing in the load path builds a model. There is no new, no save, no valid?, and the SQL above is the whole story: the loader turns YAML into column values and sends an INSERT. The bug that buys you is a test world your application could never have produced.

The same test world, a post and two comments, asked for in two ways. The fixture path turns YAML straight into SQL with triggers off and never calls new, save or valid?, so before_create never runs and the row that lands is one the application could not have created. The factory path runs the model, so validations, before_create and the counter cache all fire and the object the test holds is the object the controller would have held.

A two line fixture proves it. Given a User with validates :email, format: { with: /@/ } and a before_create { self.name ||= "anonymous" }, this loads without complaint:

reader:
  email: not-an-email-at-all
  admin: false

and then:

user id=205490495 email="not-an-email-at-all" name=nil admin=false
after_create callbacks fired during fixture load: 0
reader.valid? = false errors=["Email must look like an address"]

The row is in the table, the callback that would have given it a name never ran, and loading it back gives a User that fails its own validations. Every test touching users(:reader) is now asserting against an object that cannot exist in production.

The same hole swallows counter caches, which is the version of this that actually bites. A Post with comments_count and two comment fixtures pointing at it:

fixtures:  comments in table = 2, comments_count column = 0, post.comments.size = 0
factories: comments in table = 2, comments_count column = 2

counter_cache hooks _create_record, as Counter caches by hand works through, and a fixture load never reaches it. size reads the column, gets 0, and the test passes or fails on a number that has nothing to do with the rows. The fix is to type comments_count: 2 into the fixture and remember to change it every time you add a comment there, which is the ripple this post is going to keep coming back to.

The foreign key checked after the row is already in

disable_referential_integrity means foreign keys are not enforced during the insert, so Rails checks them afterwards. active_record/fixtures.rb:696:

def check_all_foreign_keys_valid!(conn)
  return unless ActiveRecord.verify_foreign_keys_for_fixtures
  begin
    conn.check_all_foreign_keys_valid!
  rescue ActiveRecord::StatementInvalid => e
    raise "Foreign key violations found in your fixture data. ..."
  end
end

The flag defaults to false in the library and is set to true by config.load_defaults from 6.1 onward, so any app generated in the last five years has it on. Pointing a post fixture at a label that does not exist gives:

RuntimeError: Foreign key violations found in your fixture data. Ensure you aren't referring
to labels that don't exist on associations. Error from database:

PG::ForeignKeyViolation: ERROR:  insert or update on table "posts" violates foreign key
constraint "fk_rails_5b5ddfd518"
DETAIL:  Key (user_id)=(582155196) is not present in table "users".

582155196 is identify("nobody"), which is the tell: the loader happily hashed a label nobody defined and wrote the result as a foreign key. Worth knowing before you debug one of these: the verification runs after insert_fixtures_set has already committed, so the offending row is still sitting in the table when the error is raised. Post.count was 1 after that exception. On a suite that catches and continues, the next test inherits it.

What one factory call costs

FactoryBot.create(:post) with user declared in the factory produced this, with every statement logged:

BEGIN
INSERT INTO "users" ("email", "name", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"
COMMIT
BEGIN
INSERT INTO "posts" ("user_id", "title", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"
COMMIT

Six statements for one call, and a User row you did not ask for. Five hundred of those came to 3000 statements and 585.2 ms, against 34.2 ms for the same thousand rows as fixtures. That 17x is the number the fixture side of the argument quotes, and the next section is about why it is close to meaningless.

What the six statements buy is the thing fixtures cannot do: every one of those rows went through the model. Validations ran, before_create ran, the counter cache incremented, and the object the test holds is the object the controller would have held. create(:user, email: nil) raises ActiveRecord::RecordInvalid: Validation failed: Email can't be blank, which is a test telling you your factory has drifted away from your schema. A fixture in that state tells you nothing, because it is not asking.

The speed difference, measured per test

Comparing a whole fixture load against a whole batch of factory calls is the wrong comparison, because a fixture load happens once per process and a factory call happens once per test. ActiveRecord::TestFixtures#setup_fixtures caches on [fixture_table_names, fixture_paths, fixture_class_names] and only reloads when that key changes, and a second create_fixtures call in the same process emitted 0 SQL statements against 15 for the first. Each test after that pays a BEGIN and a ROLLBACK.

So the honest measurement is per test, with a world of three users and two posts, 300 iterations:

fixtures:  one load 23.0 ms, then 300 tests in 138.1 ms (0.46 ms/test), total 161.2 ms
factories: 300 tests in 365.9 ms (1.22 ms/test)
extrapolated to 3000 tests: fixtures 1.4 s, factories 4.0 s

Two and a half seconds across a three thousand test suite. Nobody has ever chosen a test data strategy for two and a half seconds, and anybody claiming fixtures made their suite twice as fast was not comparing this.

The cost is linear in objects created, which is where it turns into a real number. A separate run, 100 iterations, one user plus a growing number of posts:

factories,  5 rows per test: 1.32 ms/test ->  4.0 s over 3000 tests
factories, 20 rows per test: 4.18 ms/test -> 12.5 s over 3000 tests
factories, 50 rows per test: 9.80 ms/test -> 29.4 s over 3000 tests

The fixture side stays where it was, because the rows are already there: the one-time load went from 23.0 ms for five rows to 34.2 ms for a thousand, which is noise next to a 1.4 s suite. Half a minute is worth arguing about. Getting to 50 rows per test usually means a factory with three has_many associations calling factories that call factories, and the honest fix is often to stop creating 50 rows rather than to change how they are created. Measure your own suite before believing any of these numbers apply to it; a local PostgreSQL 17.7 flatters both sides equally, but your factories are not this factory.

The change that ripples, on both sides

Adding a required column is the test both strategies are graded on, and each fails it differently.

On the fixture side the failure is loud and immediate. A key the table does not have raises ActiveRecord::Fixture::FixtureError: table "users" has no columns named "nickname", and a NOT NULL column no fixture supplies raises ActiveRecord::NotNullViolation with the offending row printed in the DETAIL line. You find out at load time, before any test runs, which is the best possible moment. The cost is that you now edit every fixture file that names that table, and because the fixture world is shared by the whole suite, you cannot scope the edit to the tests that care.

The shared world has a second edge. Fixtures are loaded with DELETE FROM first, so the table contains exactly the fixture rows and nothing else, which makes assert_equal 3, User.count irresistible to write and lethal to inherit. Adding a fourth user for one new test breaks a count assertion in a file nobody opened.

On the factory side the same column is silent. create(:user) keeps working, every spec stays green, and the column is simply never exercised until something in production needs it. Adding it to the factory is one line in one file, which is the advantage, and nothing tells you that the line was needed, which is the price of the advantage. FactoryBot.lint exists for exactly this: it builds every factory you hand it and raises FactoryBot::InvalidFactoryError listing the ones that fail. Note the default is traits: false, so traits go unchecked unless you ask, and this is the piece most suites never wire up at all.

Duplicate labels, and the fixture that vanishes

YAML has no duplicate key error, and neither does the fixture loader. Given this posts.yml:

welcome:
  user: mehdi
  title: One

welcome:
  user: mehdi
  title: Two

the load succeeds and Post.pluck(:title) returns ["Two"]. One row, no warning, and the label you thought you defined twice now names something you did not write. In a 300 line fixture file maintained by four people this is a genuinely hard afternoon, because the test that fails is not the test that added the second welcome.

Factory files have the opposite behaviour and it is worth naming as the contrast: defining factory :post twice raises FactoryBot::DuplicateDefinitionError at load, and asking for something that was never defined gives KeyError: Factory not registered: "admin_user" or KeyError: Trait not registered: "superuser". Typos in factory land are exceptions. Typos in fixture land are labels, and a label that resolves to a crc32 of a typo is a foreign key pointing at a row that is not there.

build_stubbed, and why it costs more than build

build_stubbed is the factory strategy that answers the speed argument, since it never touches the database at all: it builds the object, assigns a fake id, and replaces the persistence methods with raisers. FactoryBot::Strategy::Stub starts its counter at 1000, so the first stub in a process is id 1001, and u.save! gives RuntimeError: stubbed models are not allowed to access the database - User#save!(). Its association is runner.run(:build_stubbed), so the whole graph stays in memory.

The surprise is the cost:

build_stubbed x1000: 46.7 ms
build        x1000: 17.5 ms

Nearly three times slower than plain build, and the reason is in the source. DISABLED_PERSISTENCE_METHODS holds 18 names, and stub_database_interaction_on_result runs define_singleton_method for each one, on every instance. Eighteen singleton methods plus three instance_eval definitions means a singleton class per object, which is exactly what Ruby is slowest at. build_stubbed is still far cheaper than create, because 0.047 ms beats a round trip to PostgreSQL by a wide margin, but "stubbed is faster than built" is false and the list is right there in factory_bot/strategy/stub.rb.

What the generators hand you

Rails has not been neutral about this. rails new writes a test_helper.rb containing parallelize(workers: :number_of_processors) and fixtures :all, rails generate model writes a fixture file with two records named one and two, and rails generate authentication writes test/fixtures/users.yml with the BCrypt digest computed in ERB. Nothing in the framework generates a factory, and factory_bot has never been a default.

What the framework does not do is make fixtures good. The generated one: and two: are the reason so many Rails codebases carry a fixture file full of column defaults that no test reads, which then has to be edited every time the schema moves. A fixture file nobody uses is pure ripple and zero value, and deleting those is usually the first real decision a suite makes about test data.

For the record on maintenance, since naming a gem is a claim: factory_bot 6.6.0 shipped on 2026-05-04, factory_bot_rails 6.5.1 on 2025-09-05, and thoughtbot merged pull requests to the main branch through 2026-08-21, including one expanding the trait error message. It is maintained, it is not going anywhere, and the version in this project's Gemfile.lock is the current one.

The verdict, and what would flip it

Factories by default. The reason is not ergonomics and not speed, which is 2.5 seconds at a normal object count. The reason is that a factory runs your model and a fixture does not, so a factory suite cannot drift away from the schema in silence while a fixture suite can, and does, and did in the counter cache example above where post.comments.size returned 0 with two comments in the table. Test data that your application could not have produced is not test data, it is a second implementation of your database that has to be maintained alongside the first.

Fixtures for one specific job, and it is a real job: the immutable reference world that every test reads and no test writes. Plan tiers, country lists, a currency table, the seeded catalog a marketplace needs before any test means anything. That data has no callbacks worth running, changes once a quarter, and is genuinely shared, so all three fixture costs go to zero and the flat load time is pure profit. Nothing stops a suite doing both, and the suites that do are the ones that thought about it.

What would flip the default: a measured suite where object creation is more than about 20 percent of wall clock, after the obvious fix of creating fewer objects has been tried. At 50 rows per test the factory side was 29.4 s of a 3000 test run, and 29 seconds every push is a different conversation from 2.5. The other flip is team shape rather than code: a suite where most tests are read only integration tests against a stable world is describing fixtures, and forcing it to build that world 3000 times is paying a large bill for an isolation nobody is using.

What this post does not cover

Scope, so the numbers above are not read as more than they are. Every measurement here is activerecord 8.1.3.1 and factory_bot 6.6.0 against PostgreSQL 17.7 over a local TCP connection, single threaded, on one laptop. MySQL's disable_referential_integrity works differently, and SQLite differently again.

Not covered: parallelize and how fixture loading interacts with per worker databases, fixture accessors for has_many through and polymorphic rows, database_cleaner-active_record 2.2.2 and the truncation strategies you need once a test opens a real browser, and the third option nobody in this argument mentions, which is a plain Ruby method in a support file that calls Model.create! and needs no gem at all. The N+1 that makes a 50 row test world necessary in the first place is N+1 queries in Rails, and it is usually the better thing to fix.

#rails #testing

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.