Seeders
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/:
func adminUser(deps Deps) seed.Seeder {
return seed.Seeder{
Name: "admin_user",
Run: func(ctx context.Context) error {
return nil
},
}
}
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.
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.