Performance guide
Seedling exposes three factory code paths with very different cost profiles. Understanding which one runs — and when — lets you tune test-suite speed without changing the API.
The three paths
Bulk path — create_batch(bulk=True)
A single INSERT … RETURNING statement inserts all rows in one database round-trip. Post-generation hooks and related factories do not fire; SubFactory / FK fields are skipped (you must supply them via overrides).
Use this for large seed data where you control all field values and have no need for relational side-effects.
items = await ItemFactory.create_batch(session, 5_000, bulk=True)Throughput (1 000 rows, SQLite): ~90 000 rows/s
Auto-fast path — create_batch() default, plain factory
When create_batch(session, n) is called without bulk=True, Seedling automatically checks whether the factory qualifies for the fast lane:
- no
@post_generationhooks - no
RelatedFactory/RelatedFactoryListdescriptors - no
SubFactoryor FK-auto-resolve fields
If all three conditions hold, Seedling builds all n field dicts, calls session.add_all(instances), and flushes once, matching the throughput of raw session.add() loops. The check result is cached on the factory class so the decision costs nothing after the first call.
# Plain factory, no hooks, no relations → fast lane fires automatically
items = await ItemFactory.create_batch(session, 1_000)Throughput (1 000 rows, SQLite): ~8 000 rows/s — on par with raw session.add() + single flush.
Behaviour note: before_flush / after_flush session events fire once per batch, not once per row, when the fast lane is active. Factories with hooks or related factories are unaffected.
Slow path — per-row loop
Used automatically whenever the fast lane is not eligible: hooks, related factories, or SubFactory fields are declared.
Each row goes through a full create() cycle — field resolution, flush, and conditional refresh — so hooks receive the live, flushed instance before the next row starts.
class UserFactory(Factory[User]):
model = User
name = Sequence(lambda n: f"user-{n}")
@post_generation
async def create_profile(instance, session):
await ProfileFactory.create(session, user_id=instance.id)
# @post_generation declared → slow path, fires per row
users = await UserFactory.create_batch(session, 50)Throughput (1 000 rows, SQLite): ~2 400 rows/s
Refresh skip
After every flush(), Seedling checks whether the model has any server-side column defaults (server_default, server_onupdate, or Computed columns). If none exist, the refresh() SELECT is skipped entirely — SQLAlchemy already populated the primary key from cursor.lastrowid or the RETURNING clause.
The inspection result is cached per factory class on first access.
# No server_default → refresh skipped, PK populated from lastrowid
item = await ItemFactory.create(session)
assert item.id is not None # ✓, no extra SELECT neededIf your model does have a server-side default (e.g. CURRENT_TIMESTAMP, uuid_generate_v4(), or a generated column), the refresh fires automatically — you do not need to configure anything.
Smart defaults and throughput
AutoFactory uses Faker to generate realistic values for columns whose names match common patterns (name, email, city, …). Each Faker call costs roughly 45 µs. For throughput-sensitive code this adds up:
| Setup | rows/s |
|---|---|
AutoFactory with smart defaults (Faker for name column) | ~5 600 |
AutoFactory or Factory with Sequence-based fields | ~8 000 |
If you are inserting thousands of rows in a test and do not need realistic- looking strings, override the slow columns with a Sequence:
class ItemFactory(AutoFactory[Item]):
model = Item
name = Sequence(lambda n: f"item-{n}") # bypasses FakerOr disable smart defaults entirely for the whole factory:
class ItemFactory(AutoFactory[Item]):
model = Item
class Meta:
smart_defaults = FalseDecision guide
| Scenario | Recommended path |
|---|---|
| Seed data, thousands of rows, no hooks needed | bulk=True |
| Test data, no hooks / relations, want real IDs | default (bulk=False) — auto-fast lane |
Test data, @post_generation or RelatedFactory needed | default — slow path fires automatically |
Throughput matters, name column in model | add name = Sequence(...) override |
Running the benchmark
# SQLite (in-memory, fast, no Docker needed)
uv run python benchmarks/bench_vs_alternatives.py --rows 1000
# PostgreSQL via testcontainers (requires Docker)
uv run python benchmarks/bench_vs_alternatives.py --postgres --rows 500See Benchmarks for recorded numbers and methodology notes.
