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

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.

#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.