Database

Seeders

Idempotent baseline data loading after migrations, built on first-or-create, with optional guards and multi-tenant support.

Migrations create the schema; seeders create the data: an admin user, roles, lookup tables. gpsystem generates the seed package and a central seeds/ package in every project for this, run through the cmd/migrate binary.

go run ./cmd/migrate up --seed   # migrate, then seed
go run ./cmd/migrate seed        # seed only, no migration

The Registry and Seeder

A seeder is a seed.Seeder value: a name, an optional Guard (decides whether it runs at all), and the Run body. add seeder generates one and registers it in seeds/:

seeds/admin_user.go
func adminUser(deps Deps) seed.Seeder {
    return seed.Seeder{
        Name: "admin_user",
        Run: func(ctx context.Context) error {
            return nil
        },
    }
}
seeds/register.go
func Register(reg *seed.Registry, deps Deps) {
    reg.Add(adminUser(deps))
    // gpsystem:seeds
}

Deps mirrors a module's Dependencies: the query surface (*pg.DB or *bun.DB, per the project's --db choice) and dbx.Transactor. Registry.Run runs seeders in registration order: this matters when one references a row another creates (e.g. roles before the admin user that references one).

The soul of it: first-or-create

Seeders are idempotent: running one twice never duplicates its data. bun-based projects get a generic primitive for this, seed/bunx.FirstOrCreate:

user := &User{Email: "admin@example.com", Name: "Admin"}
created, err := bunx.FirstOrCreate(ctx, deps.DB, user, "email")

It looks up the existing row by the email column; if one exists, it is scanned into user (the fields end up reflecting the real row), otherwise user is inserted as-is. It runs through bunx.From, so it joins a transaction opened by WithinTransaction, if any. For a match condition more complex than a flat column-equals-value check, FirstOrCreateWhere takes an arbitrary *bun.SelectQuery predicate instead.

Native (pgx) projects have no kit-side helper for this: the seeder writes its own INSERT ... ON CONFLICT (column) DO NOTHING SQL against pg.DBTX, the same as any other native repository.

Conditional runs: Guard and ErrSkip

A seeder needs to decide whether it runs at all: that's what Guard is for. It is evaluated before Run; a false result is logged by the runner as a skip, not a failure:

seed.Seeder{
    Name:  "demo_data",
    Guard: seed.OnlyEnv(currentEnv, "dev", "staging"),
    Run:   loadDemoData,
}

When the condition only becomes clear at runtime (e.g. it depends on the tenant's own data), Run itself can bail: returning seed.ErrSkip counts as a skip too, not a failure.

Run: func(ctx context.Context) error {
    t, _ := seed.TenantFromContext(ctx)
    if t.Data.(TenantConfig).Plan != "enterprise" {
        return seed.ErrSkip
    }
    _, err := bunx.FirstOrCreate(ctx, deps.DB, &EnterpriseDefaults{TenantID: t.ID}, "tenant_id")
    return err
},

Multi-tenant: different content per tenant

One seeder can be the same code for every tenant while producing different data for each: Registry.Run with the seed.WithTenants option runs once per tenant, with the tenant placed in the context.

tenants := []seed.Tenant{
    {ID: "acme", Data: TenantConfig{Plan: "enterprise"}},
    {ID: "globex", Data: TenantConfig{Plan: "starter"}},
}
if err := reg.Run(ctx, seed.WithTenants(tenants)); err != nil {
    log.Fatal(err)
}

Tenant.Data is an arbitrary, project-specific payload; a seeder reaches it via a type assertion. seed.OnlyTenants(ids...) is a ready-made Guard for seeders that should only run for specific tenants.

The kit does not ship a tenant source (no tenant table, no search_path switching, no RLS): where the Tenant list comes from (an env var, a table, an admin API) is left to the project. WithTenants is a hook, not a ready-made multi-tenant infrastructure.

Why isn't this part of migrations?

Migrations and seeding often run on different lifecycles: a migration always runs, a seeder is frequently needed only in development or on first deploy. That's why --seed and the seed subcommand are explicit: go run ./cmd/migrate up on its own never writes data, only schema. In production, this means turning seeding on (or leaving it off) is a decision made by the deploy pipeline, not a silent side effect of the migration step.

Copyright © 2026