Skip to content

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.

python
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_generation hooks
  • no RelatedFactory / RelatedFactoryList descriptors
  • no SubFactory or 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.

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

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

python
# No server_default → refresh skipped, PK populated from lastrowid
item = await ItemFactory.create(session)
assert item.id is not None  # ✓, no extra SELECT needed

If 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:

Setuprows/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:

python
class ItemFactory(AutoFactory[Item]):
    model = Item
    name = Sequence(lambda n: f"item-{n}")  # bypasses Faker

Or disable smart defaults entirely for the whole factory:

python
class ItemFactory(AutoFactory[Item]):
    model = Item

    class Meta:
        smart_defaults = False

Decision guide ​

ScenarioRecommended path
Seed data, thousands of rows, no hooks neededbulk=True
Test data, no hooks / relations, want real IDsdefault (bulk=False) — auto-fast lane
Test data, @post_generation or RelatedFactory neededdefault — slow path fires automatically
Throughput matters, name column in modeladd name = Sequence(...) override

Running the benchmark ​

bash
# 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 500

See Benchmarks for recorded numbers and methodology notes.