How it works
The big picture
your application
│
▼
migro schema · query · orm · migrate · seed · events · cluster · console
│
▼
GORM (a *gorm.DB sits inside every migro.DB, always reachable as db.Gorm)
│
▼
database/sql driver ──► PostgreSQL · MySQL · SQLite · SQL Server · Oracle · …
migro does not replace GORM. It adds the pieces GORM leaves out (a schema builder, versioned migrations, seeders, a CLI) and a Lucid-style layer for everyday queries. Anything GORM can do is still available through db.Gorm, and your GORM models, hooks and relations work unchanged.
The packages
| Package | Role |
|---|---|
migro |
Open, the DB type (a *gorm.DB plus its dialect), pool and replica options, the *Error type |
dialect/* |
One adapter per database: type mapping, identifier quoting, driver wiring. You import the ones you need |
schema |
Describe tables once; compiled to the right SQL for the database in use |
query |
Fluent queries for tables without a model |
orm |
Generic models (orm.New[User](db)), plus the relations GORM lacks |
migrate |
Registering, ordering and running migrations; migrate/diff generates them from models |
seed |
Factories, a fake-data generator and seeders |
events |
An event bus and model lifecycle events |
cluster |
Route keys to separate databases |
console |
The command implementations behind migro |
cmd/migro |
The migro program |
One definition, many databases
A table described with the schema builder is only a description. The dialect decides the SQL:
t.BigIncrements("id")
becomes bigserial primary key on PostgreSQL, bigint unsigned not null auto_increment primary key on MySQL, integer primary key autoincrement on SQLite and an identity column on SQL Server and Oracle. Where a database cannot do something (adding a foreign key to an existing SQLite table, CREATE INDEX on Redshift) you get an UNSUPPORTED_FEATURE error instead of silently different behavior. See Database notes.
Errors
Every failure is a *migro.Error carrying a stable Code such as NOT_FOUND or MIGRATION_LOCKED. Match with errors.Is(err, migro.ErrNotFound) or migro.CodeOf(err). The underlying driver error is kept and reachable with errors.Unwrap. See Errors.
Migrations
A migration is a Go type with Up and Down, in a file named <14-digit timestamp>_<snake_name>.go. Its init() calls migrate.Register, which records it in a package-level registry and takes the migration’s name from the file name. Nothing runs at that point.
When migrations run:
- The registered migrations are sorted by name, so the timestamp decides the order.
- migro reads the
migro_migrationstable (created on demand) to learn which names have already run. - It takes a lock (a row in a lock table) so two runners cannot overlap. A second runner gets
MIGRATION_LOCKED. - Each pending migration runs. On databases with transactional DDL (PostgreSQL, SQLite, SQL Server, Redshift) it runs in a transaction together with the bookkeeping row, so a failure leaves nothing half-done. MySQL, MariaDB, Oracle and CockroachDB commit DDL implicitly, so a failed migration there can leave partial changes.
- The pending migrations from one run share a batch number.
migration:rollbackundoes the last batch,--step=Nundoes the last N migrations,--batch=Nundoes everything after batch N. - The lock is released, even on failure.
Rolling back calls Down on the registered type, so the code for a migration must still be in your project to undo it. Do not rename or delete a migration file after it has run: its name is its identity.
--pretend runs Up or Down against a recording connection that captures write statements instead of executing them, so you see the exact SQL without changing anything. See Migrations.
What migro does when you run a command
File generators (make:*) just write files. Everything else needs your project’s migrations and seeders, and they are Go code, so migro has to compile them:
- It finds your
go.mod, loads.env, and works out which database drivers your configuration needs. - It writes a small
maininto a temporary_migro_run_*folder inside your module. That program imports yourdatabase/migrationsanddatabase/seederspackages (so theirinit()functions register everything), the driver package, andmigro/console. - It builds that program and runs it from your project root, passing your arguments and the environment. The exit code and output come back unchanged.
- It deletes the temporary folder.
That is why a command takes as long as a go build, and why you must run migro inside the module. For migration:diff, the generated program also imports your models package and passes the models to the diff engine.
You do not have to use the migro program at all. console.App is a library: put it behind a subcommand of your own binary and the migrations are linked in the normal way, with no build step at runtime. See Command line.
Models, queries and transactions
orm.New[User](db) returns a thin repository over db.Gorm. Its queries are GORM queries; Query().Where(...) adds to a GORM statement, so soft deletes, hooks, Preload and scopes behave as in GORM. Rows it returns are bound: the model remembers its *migro.DB, a pointer to itself and a snapshot of its columns, which is what powers Save (changed columns only), IsDirty and lazy relations. Relation fields (HasMany[Post], BelongsToMany[Role], MorphTo, …) are resolved by reflection against the GORM schema; With loads each relation with one IN query (two for pivot and through relations) and fills the fields in place. Nothing is rewritten on the GORM side, so plain GORM relations and Preload keep working. db.Transaction(ctx, fn) hands fn a *migro.DB bound to the transaction, and every layer (schema, query, orm, migrate, seed) accepts it.
Events
The event bus attaches to GORM through a plugin that registers create, update, delete and query callbacks. A before:* handler that returns an error aborts the operation and rolls back its transaction. Like GORM hooks, events do not fire for UpdateColumn, raw SQL or the query builder. See Events.
Replicas and sharding
Read replicas use GORM’s dbresolver plugin: reads go to a replica, writes and transactions to the primary, and db.WriteDB() forces the primary. Sharding (cluster) is application-level: a router maps a key to one of several independent databases. There are no cross-shard transactions. See Pool, replicas, sharding.