Migrations
Grit uses GORM's migration system with a smart wrapper that only creates tables that don't exist yet. Migrations run as a separate command — not on server startup — giving you full control over when your database schema changes.
Running Migrations
Before starting the API server for the first time (or after adding new models), run the migrate command:
The migrate command connects to your database, checks which tables already exist, and only creates the ones that are missing. You'll see output like:
Database connected successfullyRunning migrations...✓ models.User — already exists, skipping✓ models.Upload — already exists, skipping✓ models.Category — createdMigrated 1 table(s).Migrations completed successfully.
How It Works
The migration system is built on two functions in internal/models/user.go: a Models() registry and a Migrate() runner.
// Models returns the ordered list of all models for migration.// Models with no foreign key dependencies come first.func Models() []interface{} {return []interface{}{&User{},&Upload{},// grit:models}}// Migrate runs database migrations only for tables that don't exist yet.func Migrate(db *gorm.DB) error {models := Models()migrated := 0for _, model := range models {if db.Migrator().HasTable(model) {log.Printf(" ✓ %T — already exists, skipping", model)continue}if err := db.AutoMigrate(model); err != nil {return fmt.Errorf("migrating %T: %w", model, err)}log.Printf(" ✓ %T — created", model)migrated++}if migrated == 0 {log.Println("All tables are up to date — nothing to migrate.")} else {log.Printf("Migrated %d table(s).", migrated)}return nil}
For each model, Migrate() calls db.Migrator().HasTable() first. If the table already exists, it skips it entirely. Only new tables get created via AutoMigrate.
The Migrate Entrypoint
The migrate command lives at cmd/migrate/main.go. It loads your config, connects to the database, and runs the migration:
package mainimport ("flag""fmt""log""os""myapp/apps/api/internal/config""myapp/apps/api/internal/database""myapp/apps/api/internal/models")func main() {fresh := flag.Bool("fresh", false, "Drop all tables before migrating")flag.Parse()cfg, err := config.Load()if err != nil {log.Fatalf("Failed to load config: %v", err)}db, err := database.Connect(cfg.DatabaseURL)if err != nil {log.Fatalf("Failed to connect to database: %v", err)}if *fresh {fmt.Println("Dropping all tables...")if err := database.DropAll(db); err != nil {log.Fatalf("Failed to drop tables: %v", err)}fmt.Println("All tables dropped.")}fmt.Println("Running migrations...")if err := models.Migrate(db); err != nil {log.Fatalf("Migration failed: %v", err)}fmt.Println("Migrations completed successfully.")os.Exit(0)}
Fresh Migrations
When you need to start from scratch — during development or testing — use the --fresh flag. This drops all tables before re-running migrations:
Warning: The --fresh flag permanently deletes all data. Never use it in production.
Fresh migrations are useful when:
- ✓You changed column types or removed fields from a model
- ✓You need to reset your local development database
- ✓You want to re-seed with fresh test data
The DropAll helper uses raw SQL to drop all public tables:
// DropAll drops all tables in the database.// Used by the migrate --fresh command.func DropAll(db *gorm.DB) error {var tables []stringif err := db.Raw("SELECT tablename FROM pg_tables WHERE schemaname = 'public'").Scan(&tables).Error; err != nil {return fmt.Errorf("failed to list tables: %w", err)}if len(tables) == 0 {return nil}for _, table := range tables {if err := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %q CASCADE", table)).Error; err != nil {return fmt.Errorf("failed to drop table %s: %w", table, err)}}return nil}
Adding New Models
When you generate a new resource with grit generate resource, the model is automatically registered in the Models() function via the // grit:models marker.
This adds &Category{} to the Models() list:
func Models() []interface{} {return []interface{}{&User{},&Upload{},&Category{},// grit:models}}
After generating the resource, run migrations to create the new table:
The output confirms that only the new table was created:
Running migrations...✓ models.User — already exists, skipping✓ models.Upload — already exists, skipping✓ models.Category — createdMigrated 1 table(s).
Foreign Key Ordering
When models have foreign key relationships, the order in Models() matters. Parent tables must come before child tables so foreign key constraints can be created.
func Models() []interface{} {return []interface{}{&User{}, // ← No dependencies (parent)&Upload{}, // ← Depends on User (has UserID FK)&Category{}, // ← No dependencies&Product{}, // ← Depends on Category (has CategoryID FK)&Order{}, // ← Depends on User (has UserID FK)// grit:models}}
The grit generate resource command always appends new models at the end (before the marker). If a new model depends on another table, make sure the parent model is listed first. You can safely reorder the entries in Models() — just keep the // grit:models marker as the last line.
Tip: If you see a "foreign key constraint" error during migration, check that the parent model appears before the child model in Models().
Typical Workflow
Here's the recommended workflow when starting or extending a Grit project:
Start infrastructure
Run migrations
Seed the database (optional)
Start the server
What GORM AutoMigrate Does
Under the hood, Grit's Migrate() function calls GORM's AutoMigrate for each missing table. AutoMigrate will:
- ✓Create the table with columns matching your struct fields
- ✓Add indexes and constraints from struct tags (index, uniqueIndex)
- ✓Create foreign key constraints from relationship fields
- ✓Never delete existing columns or tables (safe by design)
- ✓Never change existing column types automatically
Note: AutoMigrate is great for development and simple schemas. For production systems that need column renaming, type changes, or data migrations, consider using a dedicated migration tool like golang-migrate or goose alongside GORM. Use --fresh during development if you need to change column types.