FactoryBot: the factory that builds too much
A factory is a constructor with a memory. The autocomplete for rails factory bot fills up with three words, sequence, trait and association, and all three are ten minutes of documentation. The part that costs a team a year is none of them: it is what the third one does when you are not looking, because an association is a recursive call, and a recursive call with no base case is how a suite gets slow without anybody choosing that.
Everything below was reproduced against factory_bot 6.6.0 and activerecord 8.1.3.1 on Ruby 4.0.5,
with statements counted through an sql.active_record subscriber and timings taken against a local
PostgreSQL 17.7. The schema is four tables in a chain: an account has authors, an author has
posts, a post has comments.
Where the gem stands in 2026
factory_bot 6.6.0 was released on 2026-05-04, MIT licensed, required_ruby_version >= 3.0.0, with
355 million downloads on RubyGems. The 6.6.0 changelog lists eleven merged pull requests, including
a new factory_bot.before_run_factory instrumentation event and a fix so that linting transactions
stop interfering with the Active Record lifecycle. The release before it, 6.5.6 on 2025-10-24, fixed
association override precedence against trait foreign keys. That is a gem people are still finding
real bugs in and closing them, not one in maintenance mode.
The Rails integration is a separate gem, factory_bot_rails, at 6.5.1, which exists to load
spec/factories/**/*.rb at the right point in boot and to reload definitions when you edit them.
The product this site sells has both in its Gemfile.lock, and 11 files under spec/factories/.
What a sequence actually is
A factory_bot sequence is a counter with a block, and the counter belongs to the process, not to the
test. FactoryBot::Sequence#next runs the block with the current value and then increments, with no
knowledge of what example is running:
def next(scope = nil)
if @proc && scope
scope.instance_exec(value, &@proc)
elsif @proc
@proc.call(value)
else
value
end
ensure
increment_value
end
Two consequences. The first is the good one, and it is the whole reason to use a sequence rather
than Faker: a sequence cannot collide with itself, so sequence(:email_address) { |n|
"user#{n}@example.com" } satisfies a unique index without a retry loop and without a random
generator that eventually repeats.
The second is that the number never goes back. Transactional tests roll the rows back; they do not
roll the counter back. Three calls in one process gave author3@example.com, author4@example.com
and then, after an explicit FactoryBot.rewind_sequences, author1@example.com again. So any
assertion on a literal generated value is an assertion about how many examples ran before this one,
which is an assertion about your seed order.
A sequence works inside a trait, and keeps the same global counter:
build(:t_post, :counting) twice returned post-1 then post-2.
Traits, and which one wins
A factory_bot trait is a named bundle of attribute overrides, applied in the order you pass them,
with the last one winning on any attribute two of them both set. Given a factory with a :published trait setting
title and a :featured trait setting title:
build(:t_post, :published, :featured).title => "featured"
build(:t_post, :featured, :published).title => "published"
build(:t_post, :published, title: "override") => "override"
Left to right, then the explicit hash on top of all of it. That ordering is the entire mental model, and it is worth knowing because the bug it produces is a trait that reads as applied and is not.
Traits compose with transients, which is the pattern worth stealing. A transient is an attribute the factory can read and the model never sees, so a trait can turn one into real state:
transient { word { "hi" } }
trait :shouty do
title { word.upcase }
end
build(:t_post, :shouty, word: "careful").title returned "CAREFUL".
Typos land in two very different places, and the difference matters when you are reading a failure.
A bad trait name raises KeyError: Trait not registered: "publshed" from factory_bot itself. A bad
attribute name goes straight through to the model and raises NoMethodError: undefined method
'titel=' for an instance of Post. Both are exceptions, which is the advantage factories have over
fixtures and which Rails fixtures vs factories takes apart at
length.
The four traits on the product's :user factory are a fair example of what traits are for:
:confirmed sets confirmed_at, :unclaimed nils out claimed_at, :admin sets the role enum,
:onboarding nils out onboarded_at. Each one names a state the application actually has. None of
them adds an association.
Associations inherit the parent's strategy
Most blog posts about this gem are older than the fact in this section.
FactoryBot.use_parent_strategy has defaulted to true since factory_bot 5, and
Evaluator#association reads it on every call:
strategy_override = overrides.fetch(:strategy) {
FactoryBot.use_parent_strategy ? @build_strategy.to_sym : :create
}
So build builds the whole chain and create creates the whole chain. build(:comment) on the
four-level schema emitted zero SQL statements, and comment.post.author.persisted? was false.
Under the pre-5 behaviour the same call would have INSERTed an account, an author and a post to hand
you an unsaved comment, which is where the folklore that "build still hits the database" comes from.
It is no longer true unless somebody set use_parent_strategy = false in your rails_helper.rb.
A factory_bot association can be declared three ways and the product's factories use all three. The
bare user line in :subscription is the shortest. The explicit association :user in
:support_ticket is the same thing spelled out. The redirected association :recipient, factory:
:user in :notification is the form you need when the association name and the factory name differ,
which here is because recipient is a polymorphic belongs_to. The :referral factory needs that
third form twice, since it has two belongs_to pointing at the same table:
association :referrer, factory: :user
association :referred, factory: :user
Two declarations, two users, every time. That is correct here, since a referral between one user and themselves is not a thing the application models. It is also two rows, and the next section is about what happens when that pattern is four levels deep.
The graph nobody asked for
One call, one object in your hand, four rows in the database:
create(:comment)
INSERT INTO "accounts" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"
INSERT INTO "authors" ("email", "account_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id"
INSERT INTO "posts" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id"
INSERT INTO "comments" ("body", "post_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id"
Nothing there is a bug. Every INSERT is required by a belongs_to, the factories are three lines
each, and each one only knows about its own parent. The cost is emergent: it is the depth of the
chain, and no single file contains it.
Now add the other half of the pattern, a factory that creates children in an after(:create):
factory :fat_post, parent: :post do
after(:create) { |post| create_list(:comment, 5, post: post) }
end
create_list(:fat_post, 10) emitted 80 statements and left 10 accounts, 10 authors, 10 posts and 50
comments behind. The spec asked for ten posts. It got eighty rows, and ten distinct accounts that no
assertion in it will ever mention.
The multiplier is what makes this the slow-suite trap rather than a curiosity. A factory with a
callback that creates children, invoked by create_list, inside a chain that already creates
ancestors, is three independent decisions nobody made together. Each one is reasonable in its own
file. The product's own factories have seven create_list calls across 141 spec files and no
after(:create) callbacks at all, which is the reason this has not happened there.
The fix is not a setting. It is passing the parent you already have:
author = create(:author)
3.times { create(:post, author: author) }
That emitted 3 statements rather than 9, because an association you override is not an association factory_bot builds. Every shared parent you hoist out of the loop removes its whole subtree from every iteration.
build, create and build_stubbed, priced
Per object, best of three runs of 2000, against PostgreSQL 17.7 on localhost, on the four-level graph:
create 1.209 ms per object (14.7x build)
build_stubbed 0.205 ms per object (2.5x build)
build 0.082 ms per object (1.0x build)
And on a single object with no associations at all, to separate the strategy cost from the graph cost:
create 0.289 ms per object (18.4x build)
build_stubbed 0.042 ms per object (2.7x build)
build 0.016 ms per object (1.0x build)
Read the two tables together and the interesting number is not the ratio, it is the difference
between the tables. create went from 0.289 ms to 1.209 ms, four times more expensive, because the
graph is four objects deep. build went from 0.016 to 0.082 ms. The strategy multiplies; the graph
multiplies; they multiply each other.
build_stubbed being slower than build surprises people every time, and the reason is
FactoryBot::Strategy::Stub#stub_database_interaction_on_result, which calls
define_singleton_method once for each of the 18 names in DISABLED_PERSISTENCE_METHODS, on every
instance in the graph. A singleton class per object is one of the few things Ruby is genuinely slow
at. The stub strategy is still six times cheaper than create here, so it wins where it applies, but
"stubbed is the fast one" is only true against create.
The read that build_stubbed does not stub
Here is the part that does not work, and the reason this post does not end with "use build_stubbed everywhere".
DISABLED_PERSISTENCE_METHODS is a list of writes: save, save!, update, destroy, reload,
touch, increment! and eleven more. Call one and you get a clear failure:
stub.save! => RuntimeError: stubbed models are not allowed to access the database - Comment#save!()
stub.update => RuntimeError: stubbed models are not allowed to access the database - Comment#update({body: "x"})
Reads are not on the list. So a has_many on a stubbed record does exactly what it always does,
which is run a query:
stub.post.comments.to_a => []
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?
stub.post.comments.count => 0
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ?
The id in that bind is 1003, a number the stub strategy invented, and no row in comments has it.
The query succeeds. The answer is empty. Nothing raises, nothing logs, and the view under test
renders its blank state while the assertion says the collection is empty and passes.
That is the worst failure shape a test helper can have, because the test is green and the thing it
proves is false. build has the mirror version and it is safer by accident: build(:post).comments
returns [] with zero statements, because Active Record short-circuits a collection read on a
new_record? owner. Same empty array, no query, and at least no false impression that the database
was consulted.
The rule that falls out: build_stubbed is for an object you are going to read attributes off and
pass to a presenter, a serializer or a view. The moment a has_many is involved, it is lying to you
cheaply instead of telling you the truth slowly.
The escape hatch that buys nothing
GETTING_STARTED.md documents strategy: :build on an explicit association as the way to avoid
saving the associated object. On a create it does not, and this is worth showing because the
documentation's own example is written against use_parent_strategy = false.
factory :lean_post, class: "Post" do
association :author, strategy: :build
title { "x" }
end
create(:lean_post) emitted three INSERTs, into accounts, authors and posts, and left
Author.count at 1. factory_bot did build the author rather than create it, and then Active
Record's autosave on belongs_to saved the unsaved parent when the post was saved, because a post
with an unsaved author has no author_id to write. The option did its job and the framework undid
it.
build(:lean_post) emitted zero statements, but so does build(:post) under the default
use_parent_strategy, so the option changed nothing there either. strategy: :build is a legacy
affordance for suites that turned use_parent_strategy off. On a default Rails 8.1 setup it is a
line that reads like an optimisation and is not one.
attributes_for forgets the association
attributes_for is the fourth strategy and the one with the sharpest edge:
attributes_for(:post) => {title: "A post"}
attributes_for(:comment) => {body: "Nice"}
No author_id, no post_id, no association of any kind, and the subtraction is literal.
AttributeAssigner#attributes_to_set_on_hash is one line:
attribute_names_to_assign - association_names
Associations are removed from the hash before it is built, and separately
Strategy::AttributesFor#association is runner.run(:null) against a Strategy::Null whose every
method has an empty body. Two mechanisms agreeing that an association has no place in a Hash, which
is fair: an association is an object, not a column value.
Which makes the obvious use, posting a factory's attributes at a controller, wrong in exactly the way that is hard to see:
post posts_path, params: { post: attributes_for(:post) }
That request arrives with a title and no author, and what happens next depends on the controller. A
controller that sets author from Current.user is fine. One that expects author_id in the
params gets a validation failure that reads like a controller bug and is a factory fact.
Stubbed ids are global and ordered
FactoryBot::Strategy::Stub keeps one class variable, @@next_id = 1000, incremented before use
and shared by every model in the process. Four alternating pairs of build_stubbed(:account) and
build_stubbed(:post):
[[1001, 1004], [1005, 1008], [1009, 1012], [1013, 1016]]
The gaps are the graph. Each :post stub pulls an author and an account with it, so it consumes
three ids and reports the last. Two facts follow. No two stubbed objects anywhere in a process share
an id, even across classes, which makes expect(stub.post_id).to eq(post.id) meaningful. And the
value of any particular id depends on every stub built before it, so a spec asserting a literal
1001 passes alone and fails in a suite, or in a different random seed.
The other half of the id story is a uuid primary key, which the strategy handles: uuid_primary_key?
checks column_for_attribute(primary_key).sql_type == "uuid" and returns SecureRandom.uuid
instead of the counter. That is version 4, the same value
UUID primary keys in Rails traces back to gen_random_uuid(), so a
stubbed id is unsorted where a real one on a v7 column would not be.
What FactoryBot.lint checks, and what it skips
FactoryBot.lint runs every factory you hand it and collects what raises. It is the answer to the
one real weakness of factories, which is that adding a required column to a table breaks factories
silently until some unrelated spec fails:
FactoryBot::InvalidFactoryError
The following factories are invalid:
* orphan_post - Validation failed: Author must exist (ActiveRecord::RecordInvalid)
Two things about it are easy to miss. The default is traits: false, so a factory whose traits are
broken lints clean: the :post factory with a :broken trait that nils the author reported no
errors until the call became FactoryBot.lint([factory], traits: true), which then reported
post+broken.
And lint_traits iterates factory.definition.defined_traits.map(&:name) one at a time. Every trait
alone, never two together. A pair of traits that conflict, which is precisely the failure the
left-to-right override order makes possible, is outside what lint can see. The rows are cleaned up
either way: in_transaction wraps each attempt, and Post.count was 0 after a lint run that created
posts.
Callbacks fire per strategy, which is the point
Each strategy runs a different set of callbacks, and the list is short enough to memorise:
build: after_build
create: after_build before_create after_create
build_stubbed: after_stub
after(:create) never fires on a build, which is why moving a spec from create to build can
silently drop the children that callback was creating. In the other direction, it is why an
after(:create) that creates five comments is invisible to anybody reading the call site: the
create_list(:fat_post, 10) in the spec contains no hint that fifty rows are about to exist.
after(:stub) is its own hook, not an alias for after(:build), so a stub strategy skips
after_build entirely. Anything a factory sets up in after(:build) is missing from every
build_stubbed object, which is a second reason the two strategies are not interchangeable.
The call, and what would change it
Default to create. A Rails test that does not touch the database is testing a plain Ruby object,
and most of them are not. The product's specs are 200 create calls against 39 build and zero
build_stubbed, which is a codebase that made this choice and has a suite that finishes.
Reach for build when the example never queries and never saves: a validation spec, a
presenter, a serializer, a method that reads attributes. That is 0.082 ms against 1.209 ms on the
graph measured here, and the reason to reach for it is not the milliseconds, it is that a build
spec cannot accidentally depend on a row it did not mean to create.
Reach for build_stubbed only for an object that has to answer persisted? and id without
existing: a view, a component, a URL helper. Not for anything with a has_many, for the reason two
sections up.
The position has a cost and it is the one everybody notices last. create everywhere means the
graph trap is always one careless association away, and nothing in the gem warns you. A factory that
grows a third belongs_to makes every spec that touches it slower, in a file none of those specs
mention.
What would change the recommendation: a build_stubbed that raised on association reads the way it
raises on writes would make it the sane default for most specs, and it is a change the existing
DISABLED_PERSISTENCE_METHODS list could carry. Short of that, a lint mode that reported the row
count each factory produces would turn the graph trap from something you discover at minute nine of
CI into a number in a build log.
What this post does not cover
The comparison against fixtures, which is a different argument and has its own post at
Rails fixtures vs factories, including the fixture loader's
DISABLE TRIGGER ALL and the per-test timings that make the speed argument mostly a red herring.
Also absent: DatabaseCleaner and truncation strategies, since transactional tests handle the
rollback for everything above; faker, which the product depends on and which is orthogonal to
every mechanism here; parallel test workers, where sequence counters are per process and therefore
per worker; and traits_for_enum, which generates one trait per enum value and is a convenience
rather than a behaviour.
The reproductions above ran against an in-memory SQLite database where only statement counts mattered, and against a local PostgreSQL 17.7 for every timing, because a count is adapter independent and a millisecond is not.
Comments
No comments yet. Be the first.