Migrations are plain Go values listed in one registry function. There is no init() registration: vukem make:migration writes the file and appends it to the list.
Creating a migration#
vukem make:migration create_comments_table
vukem make:migration add_slug_to_posts_tableThe stub depends on the name: create_<table>_table creates a table, add_x_to_<table>_table / alter_<table>_table alter one, anything else is blank.
func m20261016120000CreateCommentsTable() schema.Migration {
return schema.Migration{
ID: "20261016120000_create_comments_table",
Up: func(s *schema.Schema) error {
return s.Create("comments", func(t *schema.Table) {
t.ID()
t.ForeignID("post_id").Constrained().CascadeOnDelete()
t.Text("body")
t.Boolean("approved").Default(false)
t.Timestamps()
t.Index("post_id", "approved")
})
},
Down: func(s *schema.Schema) error { return s.DropIfExists("comments") },
}
}func All() []schema.Migration {
return []schema.Migration{
m20260101000000CreatePostsTable(),
m20261016120000CreateCommentsTable(), // appended by make:migration
}
}bootstrap.New passes migrations.All() to vukem.WithMigrations.
Schema builder#
Schema | Table columns | Column modifiers |
|---|---|---|
Create, Table (alter), Drop, DropIfExists, Rename, HasTable, HasColumn, Raw | ID, String(name, len?), Text, Integer, BigInteger, Boolean, Decimal(p, s), Float, Date, Timestamp, Timestamps, SoftDeletes, JSON, UUID, ULID, Binary, Enum(name, values...), ForeignID | Nullable, Default, Unique, Index, Constrained(table?), CascadeOnDelete, NullOnDelete, RestrictOnDelete |
Altering: t.RenameColumn(from, to), t.DropColumn(names...), t.Index(cols...), t.UniqueIndex(cols...), t.DropIndex(name), t.DropCheck(name).
Named CHECK constraints keep integrity rules in the database:
s.Create("raffles", func(t *schema.Table) {
t.ID()
t.Integer("number_count")
t.Check("raffles_number_count_check", "number_count BETWEEN 10 AND 1000")
})t.Check(name, expr) emits CONSTRAINT name CHECK (expr) inside CREATE TABLE on every dialect, and ALTER TABLE … ADD CONSTRAINT in s.Table on PostgreSQL and MySQL. SQLite cannot add or drop constraints on an existing table, so Check and DropCheck inside s.Table fail the migration there instead of skipping the rule. The expression is raw SQL written by you (never user input); prefix names with the table, since MySQL requires them to be unique per database. DropCheck uses ALTER TABLE … DROP CONSTRAINT, which needs MySQL 8.0.19+ or MariaDB 10.2+.
Partial indexes cover only the rows matching an expression, e.g. one live reservation per number:
s.Create("reservations", func(t *schema.Table) {
t.ID()
t.ForeignID("number_id").Constrained()
t.String("status")
t.UniqueIndex("number_id").Where("status = 'live'")
})t.Index(cols...) and t.UniqueIndex(cols...) return an *Index; .Where(expr) emits CREATE [UNIQUE] INDEX … WHERE expr on PostgreSQL and SQLite, in Create and s.Table. MySQL and MariaDB have no partial indexes, so Where fails the migration there instead of creating a full index that would reject valid rows. Like Check, the expression is raw SQL written by you.
DDL is generated per dialect (SQLite, PostgreSQL and MySQL/MariaDB). Table and column names are validated, never taken from user input.
Running migrations#
| Command | Effect |
|---|---|
vukem migrate | Run pending migrations in one batch |
vukem migrate:rollback [--step N] | Roll back the last batch, or the last N migrations |
vukem migrate:reset | Roll back everything |
vukem migrate:fresh | Drop all tables and re-run every migration |
vukem migrate:status [--json] | Which migrations ran, in which batch, and when |
migrate:reset and migrate:fresh refuse to run with APP_ENV=production unless --force is given.
Each applied migration stores a checksum of the SQL it produced. migrate:status flags migrations whose SQL changed after they ran and migrations recorded in the database but missing from code.
In code, app.Migrator() returns a *schema.Migrator with Up, Rollback, Reset, Fresh and Status; vukemtest.New uses it to migrate the test database.