Skip to content

Cookbook

Recipes for common patterns that go beyond the basics.


Cyclic foreign keys

Some schemas have mutually referencing tables — for example, a Company that has a ceo_id FK to Person, and a Person that has an employer_id FK to Company. Standard insertion order cannot satisfy both constraints simultaneously.

Solution: defer FK constraints (PostgreSQL), then break the cycle with a two-step insert.

python
from seedling import Seeder, deferred_constraints, DEV_AND_TEST
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models import Company, Person

class OrgChartSeeder(Seeder):
    environments = DEV_AND_TEST
    models = [Company, Person]

    async def run(self, session: AsyncSession) -> None:
        async with deferred_constraints(session):
            # 1. Insert companies with no CEO yet
            acme = Company(name="Acme Corp", ceo_id=None)
            session.add(acme)
            await session.flush()

            # 2. Insert persons referencing the company
            alice = Person(name="Alice", employer_id=acme.id)
            session.add(alice)
            await session.flush()

            # 3. Update the company's CEO now that Alice has an id
            acme.ceo_id = alice.id
            await session.commit()

deferred_constraints() is a no-op on SQLite and MySQL/MariaDB — for those dialects, break the cycle by inserting with NULL and updating in a second statement (same pattern, no context manager needed).


Polymorphic models

Single-table inheritance (STI)

SQLAlchemy STI uses a type discriminator column to select the concrete class. Create one factory per concrete subclass:

python
from sqlalchemy.orm import DeclarativeBase, mapped_column
from sqlalchemy import String
from seedling import Factory, Faker

class Base(DeclarativeBase):
    pass

class Employee(Base):
    __tablename__ = "employee"
    id: int = mapped_column(primary_key=True)
    name: str = mapped_column(String)
    type: str = mapped_column(String)
    __mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "employee"}

class Manager(Employee):
    reports_count: int = mapped_column(default=0)
    __mapper_args__ = {"polymorphic_identity": "manager"}

class Engineer(Employee):
    stack: str = mapped_column(String, nullable=True)
    __mapper_args__ = {"polymorphic_identity": "engineer"}


class ManagerFactory(Factory[Manager]):
    model = Manager
    name  = Faker("name")

class EngineerFactory(Factory[Engineer]):
    model   = Engineer
    name    = Faker("name")
    stack   = "Python"

Each factory targets the concrete subclass. SQLAlchemy fills in type automatically from __mapper_args__["polymorphic_identity"].

Joined-table inheritance (JTI)

JTI works the same way — declare a factory for each concrete subclass. The base-table columns are inherited through the normal SQLAlchemy mapper chain and you don't need to do anything special in the factory.


JSONB and arrays (PostgreSQL)

PostgreSQL JSONB and ARRAY columns are not covered by AutoFactory smart defaults. Declare them explicitly:

python
from seedling import Factory, Faker, LazyAttribute
from myapp.models import Article

class ArticleFactory(Factory[Article]):
    model    = Article
    title    = Faker("sentence", nb_words=6)

    # JSONB column
    metadata = LazyAttribute(lambda f: {
        "tags":    ["python", "async"],
        "source":  "import",
        "version": 1,
    })

    # ARRAY column
    keywords = LazyAttribute(lambda f: [
        Faker("word").generate({}),
        Faker("word").generate({}),
    ])

For randomised JSONB content, use faker inside LazyAttribute:

python
from seedling import faker

metadata = LazyAttribute(lambda f: {
    "score":   faker.random_int(0, 100),
    "tags":    faker.words(3),
    "created": faker.iso8601(),
})

Time-series data

Seed rows with monotonically increasing or decreasing timestamps using Sequence and the datetime module:

python
from datetime import UTC, datetime, timedelta
from seedling import Factory, Sequence
from myapp.models import Event

_BASE_TIME = datetime(2026, 1, 1, tzinfo=UTC)

class EventFactory(Factory[Event]):
    model      = Event
    name       = Sequence(lambda n: f"event-{n:04d}")
    # Each event is 1 hour after the previous one
    occurred_at = Sequence(lambda n: _BASE_TIME + timedelta(hours=n))

For the last N days of data, compute from the current time:

python
from datetime import UTC, datetime, timedelta
from seedling import Factory, Sequence

class MetricFactory(Factory[Metric]):
    model      = Metric
    value      = Sequence(lambda n: float(n % 100))
    recorded_at = Sequence(
        lambda n: datetime.now(UTC) - timedelta(days=30) + timedelta(hours=n)
    )

To seed a full 30-day window in a seeder:

python
class MetricsSeeder(Seeder):
    environments = DEV_AND_TEST
    models = [Metric]

    async def run(self, session: AsyncSession) -> None:
        await MetricFactory.create_batch(session, 30 * 24)  # one per hour

Large tables

Use bulk=True for 10 000+ rows

create_batch(session, n, bulk=True) uses a single INSERT ... RETURNING statement instead of N per-row round trips. It is significantly faster for large batches.

python
await UserFactory.create_batch(session, 100_000, bulk=True)

Limitations: @post_generation hooks and RelatedFactory / RelatedFactoryList do not fire. SubFactory and FK auto-resolve fields are omitted — pass them as explicit overrides:

python
await PostFactory.create_batch(session, 50_000, bulk=True, author_id=fixed_user_id)

Disable state tracking for pure performance runs

State tracking opens extra sessions per seeder to write state rows. Disable it when you only care about speed:

python
runner = SeederRunner(session_factory, env="staging", state_tracking=False)

Or per-project:

toml
[tool.seedling]
state_tracking = false

Chunked seeding for very large datasets

If you need to seed tens of millions of rows, chunk the work to avoid memory pressure:

python
class BigTableSeeder(Seeder):
    environments = DEV_AND_TEST
    models = [Event]

    CHUNK_SIZE = 10_000
    TOTAL      = 1_000_000

    async def run(self, session: AsyncSession) -> None:
        for _ in range(self.TOTAL // self.CHUNK_SIZE):
            await EventFactory.create_batch(session, self.CHUNK_SIZE, bulk=True)

Each create_batch call issues one INSERT statement; the loop keeps peak memory proportional to CHUNK_SIZE, not TOTAL.

Parallel seeders for independent tables

If you have multiple large tables with no FK relationship, register their seeders without depends_on — the runner will execute them in parallel:

python
class UserSeeder(Seeder):
    environments = DEV_AND_TEST
    models = [User]
    async def run(self, session):
        await UserFactory.create_batch(session, 100_000, bulk=True)

class ProductSeeder(Seeder):
    environments = DEV_AND_TEST
    models = [Product]
    async def run(self, session):
        await ProductFactory.create_batch(session, 500_000, bulk=True)

# No depends_on — these run in parallel
runner.register(UserSeeder, ProductSeeder)

Alembic post-upgrade hook

Run seeders automatically after Alembic migrations by hooking into env.py:

alembic/env.py
python
from alembic import context

def run_migrations_online() -> None:
    # ... standard async alembic setup ...
    with connectable.connect() as connection:
        context.configure(connection=connection, target_metadata=target_metadata)
        with context.begin_transaction():
            context.run_migrations()

    # After migrations complete, run seeders in the test environment
    if context.get_x_argument(as_dictionary=True).get("seed"):
        import asyncio
        from myapp.seeders import create_runner
        asyncio.run(create_runner("test").run())

Trigger it via:

bash
alembic -x seed=true upgrade head

Transactional test fixtures

Use seedling_transactional_session to get a session that auto-rolls back after each test, keeping the database clean without truncation:

conftest.py
python
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

@pytest.fixture(scope="session")
def seedling_session_factory():
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    return async_sessionmaker(engine, expire_on_commit=False)


# test_orders.py
from seedling.pytest_plugin import seed

@seed(UserSeeder, OrderSeeder)
async def test_order_total(seedling_transactional_session):
    session = seedling_transactional_session
    # Both seeders ran before this test body.
    # Any changes made in this test are rolled back automatically.
    orders = await session.execute(select(Order))
    ...