CLI Reference

add seeder

Add a seeder to the project's central seeds/ package, registered automatically.

Reach for a seeder whenever the project needs baseline data to exist after migrations run, but before anyone can use the app: an admin account to log in with, a fixed set of roles or lookup values a foreign key depends on, demo data for a staging environment. Business rows created in response to user action belong in a service, not here; a seeder is for rows the app assumes are already there.

go tool gpsystem add seeder <name>

Writes a seeder stub into the project's central seeds/ package and registers it in seeds/register.go. The seeder returns a seed.Seeder: a name, an optional Guard (decides whether it runs at all), and the Run body. Details and patterns: Seeders.

go tool gpsystem add seeder adminUser

What it generates

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 (in seeds/register.go, present in every project since new project) carries a *pg.DB or *bun.DB field per the project's --db choice, plus a dbx.Transactor. The command expects the seeds/register.go every project has had since new project; if it's missing, it fails and says so.

A worked, multi-row example

Seeders run in registration order (Add preserves it), which matters the moment one seeder's rows are a foreign key for another's: a set of roles has to exist before the admin user that references one. Two seeders, added in that order:

go tool gpsystem add seeder roles
go tool gpsystem add seeder adminUser
seeds/roles.go
func roles(deps Deps) seed.Seeder {
    names := []string{"admin", "editor", "viewer"}
    return seed.Seeder{
        Name: "roles",
        Run: func(ctx context.Context) error {
            for _, name := range names {
                if _, err := deps.DB.NewInsert().
                    Model(&Role{Name: name}).
                    On("CONFLICT (name) DO NOTHING").
                    Exec(ctx); err != nil {
                    return fmt.Errorf("seed role %q: %w", name, err)
                }
            }
            return nil
        },
    }
}
seeds/admin_user.go
func adminUser(deps Deps) seed.Seeder {
    return seed.Seeder{
        Name: "admin_user",
        Guard: func(ctx context.Context) (bool, error) {
            exists, err := deps.DB.NewSelect().Model((*User)(nil)).
                Where("email = ?", "admin@example.com").Exists(ctx)
            return !exists, err
        },
        Run: func(ctx context.Context) error {
            hash, err := bcrypt.GenerateFromPassword([]byte(mustEnv("SEED_ADMIN_PASSWORD")), bcrypt.DefaultCost)
            if err != nil {
                return err
            }
            _, err = deps.DB.NewInsert().Model(&User{
                Email:        "admin@example.com",
                PasswordHash: string(hash),
                Role:         "admin",
            }).Exec(ctx)
            return err
        },
    }
}
seeds/register.go
func Register(reg *seed.Registry, deps Deps) {
    reg.Add(roles(deps))
    reg.Add(adminUser(deps))
    // gpsystem:seeds
}

roles uses ON CONFLICT DO NOTHING to stay idempotent across reruns on its own; adminUser's Guard does the same job by checking for the row first, so the run is logged as a clean skip instead of a duplicate-key error on the second migrate up --seed. Both are valid; pick whichever reads more naturally for the data at hand.

Running it

go run ./cmd/migrate up --seed   # migrate, then seed
go run ./cmd/migrate seed        # seed only
add seeder only writes and registers the stub: the business logic (what to load, under what condition) is yours to write into the generated file. See Seeders for the Guard, ErrSkip and multi-tenant WithTenants patterns.
Copyright © 2026