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.
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.
Comments
No comments yet. Be the first.