# gormigrate, minimal schema versioning for Gorm

> Gormigrate is a small Go library that adds the two things Gorm's migration helpers leave out, a record of which migrations have run and a way back. Migrations are ordinary Go functions with an identifier and a rollback, there is an init hook for bootstrapping a clean database, and the readme is unusually candid about the tool's limits, including the absence of any locking mechanism.

**go-gormigrate/gormigrate** — Minimalistic database migration helper for Gorm ORM

- Repository: https://github.com/go-gormigrate/gormigrate
- Website: https://pkg.go.dev/github.com/go-gormigrate/gormigrate/v2
- Stars: 1,179 · Forks: 101
- Language: Go
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/go-gormigrate-gormigrate

## The two things Gorm's migrator does not do

Gormigrate is described as a minimalistic migration helper for Gorm, and the readme states the gap in one sentence. Gorm already has useful migrate functions, it just misses proper schema versioning and migration rollback support.

That is a precise positioning, and it is worth being clear about what it means. Gorm's Migrator can create a table, add a column and drop a column, and you can call it from your own code. What it does not give you is a table that records which changes have already been applied, so that the same change is not applied twice, and a way to undo a change when something goes wrong. Gormigrate supplies exactly those two things and stops there.

The consequence is that the library has a very small surface. The whole implementation lives in a single file, gormigrate.go at the root of the repository, next to the go.mod, a Taskfile.yml, a CHANGELOG.md and an integration-test directory. There is no plugin system, no migration generator, no DSL, and no separate command line tool. The migrations are Go code you write yourself, in the same process, using the same *gorm.DB you already have.

That design is the reason the library fits Gorm projects so easily. There is nothing to learn that is not already Gorm, and the migration is a function that receives a transaction handle, so the same code you would write in a service can be written in a migration.

## A migration is an identifier, a function and a rollback

The usage example in the readme is the clearest description of the model. You build a slice of migrations and hand it to gormigrate.New together with the database and the options:

```go
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{{
	ID: "201608301400",
	Migrate: func(tx *gorm.DB) error {
		type user struct {
			ID   uuid.UUID `gorm:"type:uuid;primaryKey;uniqueIndex"`
			Name string
		}
		return tx.Migrator().CreateTable(&user{})
	},
	Rollback: func(tx *gorm.DB) error {
		return tx.Migrator().DropTable("users")
	},
}})

if err := m.Migrate(); err != nil {
	log.Fatalf("Migration failed: %v", err)
}
```

Each migration has an ID string, which is what gets recorded in the migrations table, and two functions that take a *gorm.DB. The Migrate function does the work and the Rollback function undoes it.

Two conventions in the example are worth copying. The first is the comment about copying the struct inside the function, so side effects are prevented if the original struct changes during the time. The struct is declared inside the closure, not referenced from the package level, which means a later change to the application's model does not silently change what a historical migration does.

The second is the note that when a table already exists, you should define only the columns that are about to change. The second migration in the example adds an Age column and declares a struct with only that field, because Migrator().AddColumn is given a model and a column name.

The IDs in the example are timestamps in a fixed format, which is a simple way to get an ordering that matches the order you wrote the migrations in. The library records the ID and applies the migrations that are not yet recorded, so the ID scheme is yours to choose, and a scheme that sorts in the right order is worth picking deliberately.

## InitSchema for the database that has never been migrated

There is a second entry point, and it exists for a specific operational problem. If you have a lot of migrations, running all of them is a pain, for example when you are deploying a new instance of the app into a clean database.

The solution is to set a function that runs if no migration was run before. The readme is emphatic that you must create everything there, all tables, foreign keys and whatever else the application needs:

```go
m.InitSchema(func(tx *gorm.DB) error {
	err := tx.AutoMigrate(
		&Organization{},
		&User{},
		// all other tables of you app
	)
	if err != nil {
		return err
	}

	if err := tx.Exec("ALTER TABLE users ADD CONSTRAINT fk_users_organizations FOREIGN KEY (organization_id) REFERENCES organizations (id)").Error; err != nil {
		return err
	}
	// all other constraints, indexes, etc...
	return nil
})
```

The function uses AutoMigrate for the tables and then adds the foreign key constraints by hand. The comment about all other constraints and indexes is a reminder that the init function is a second, parallel description of your schema, and it has to be kept in step with the migrations that build the same schema the long way.

That is a real maintenance cost and it is worth naming. A new deployment gets the schema from InitSchema, an existing database gets it from the migrations, and a mismatch between the two produces a system that works until the day somebody recreates a database.

The benefit is the speed. A fresh environment with a hundred migrations applies one AutoMigrate call instead of a hundred steps.

## The Options struct, and the DDL transaction caveat

The options struct is small enough to read in one go, and each field has a consequence worth understanding before you change it.

TableName is the migration table, IDColumnName is the name of the column where the migration id is stored, and IDColumnSize is the length of that column, which is a detail you only think about when an ID does not fit. UseTransaction makes Gormigrate execute migrations inside a single transaction, and the comment carries the warning: not all databases support DDL commands inside transactions. That caveat is the most important line in the struct, because a transaction that your database silently refuses to use for schema changes is a transaction that gives you atomicity you do not have.

ValidateUnknownMigrations causes migrate to fail if there are unknown migration IDs in the database. That is the setting that catches the most unpleasant class of mistake, a database whose migrations table contains an ID that no longer exists in the code, which happens when a branch is reverted or a deployment rolls back to an older build. Failing loudly is the right behaviour, and it is off by default.

The defaults are exposed as gormigrate.DefaultOptions, so the common case is to pass that and only override what you need.

The module requirements are modest in a good way. The go.mod declares Go 1.18, which is a low floor for a library that most people will consume from a modern service, and the only direct dependency is Gorm itself, at v1.31.2. The indirect dependencies are Gorm's own.

## The database matrix in the integration tests

Gormigrate is expected to support any of the databases Gorm supports, and the readme lists the ones the integration tests actually cover, with the version tested for each: PostgreSQL 18, MySQL 9, MariaDB 12, Microsoft SQL Server 2025, SQLite through the CGo driver mattn/go-sqlite3, and SQLite through the pure Go driver glebarez/sqlite.

The list is a maintenance statement as much as a compatibility one. A migration library that only ever ran against SQLite would have a very different failure profile from one that runs against several engines, and the presence of PostgreSQL 18 and MySQL 9 in the matrix says the SQL dialect assumptions are tested on the current releases rather than on whatever was current when the library was written.

Testing both SQLite drivers is a smaller detail with a good reason behind it. The CGo driver is the traditional choice and the pure Go one removes a C toolchain from the build, which matters in a container build. A migration library whose own test suite only works with the CGo driver would be telling you something about its users.

Running those tests is not a one-liner. The contributing section asks for task and docker to be installed, then says to ensure the target or all databases are available and ready to accept connections, which you can do with `task docker:compose:up`, and to copy the example environment file in the integration-test directory. The Taskfile.yml at the root is what those targets come from.

## What the readme says about who this is for

The section titled who is Gormigrate for is the most honest paragraph in the readme, and it is a recommendation against using the library in some situations.

Gormigrate was born to be a simple and minimalistic migration tool for small projects that use Gorm, and the readme says you may want to look at more advanced solutions like golang-migrate/migrate if you plan to scale. That is the alternative, named explicitly, and the difference in approach is clear from the framing. Gormigrate has a slice of Go functions and a table with the IDs that have run.

The second warning is about locking. Gormigrate has no builtin lock mechanism, so if you are running it automatically and have a distributed setup, with more than one executable running at the same time, you might want to use a distributed lock or mutex mechanism to prevent race conditions while running migrations. The readme links a Redis distributed lock pattern as the example.

That is a genuine operational gap rather than a missing convenience. Two instances of an application starting at the same moment, each running the migrations, will both see that the migration has not been applied, and both will apply it. The window is small, but the failure is a duplicated schema change, and the fix is a piece of infrastructure the library does not provide.

The version history section of the readme also covers the older import path. If you need support for Gorm v1, which used github.com/jinzhu/gorm as its import path, you import Gormigrate through gopkg.in/gormigrate.v1, while Gorm v2 uses github.com/go-gormigrate/gormigrate/v2.

## Where gormigrate is the wrong tool

The first limit is the one the readme names, and it is the one that will bite in production. There is no lock, so any deployment that starts more than one instance at a time, which is the normal case on Kubernetes, has a race window unless you add a distributed lock of your own. A library that is used at application startup, which is the natural place to call m.Migrate(), is exactly the library that needs the lock.

The second is that the schema has two descriptions. The migrations describe how the schema was built, step by step, and InitSchema describes the same schema in one go, and the two have to agree. A project that adds a column in a migration and forgets to add it to the init function will work on every existing database and fail on every new one, which is the worst possible way for that bug to appear.

The third is the shape of a migration. A migration is a function that takes a transaction and returns an error, which means anything conditional has to be written by hand inside that function. There is no branching, no per-environment variation and no declarative description, so a migration that needs to behave differently on two engines is your problem to solve.

The fourth is scale, as the readme says. A hundred migrations in a slice is a hundred functions in one Go file or package, and a project with a large schema and a long history will want the versioned files on disk that a dedicated migration tool provides.

The fifth is the transaction option, which is the one to be careful with. UseTransaction gives you all-or-nothing behaviour for a set of migrations, and the comment says plainly that not all databases support DDL commands inside transactions. If your engine does not, the option is not a safety net, and the readme leaves you to check.

## Conclusion

gormigrate fits a small Go service that uses Gorm and wants versioned migrations with a rollback path, and it is a small dependency rather than a migration framework. It does not fit a distributed deployment that starts several instances at once without an external lock, because there is no built-in lock mechanism and the readme tells you to add one, and it does not fit a project that plans to scale, since the readme itself points you at golang-migrate. Before adopting it, decide whether you will run migrations automatically at startup, because that is where the missing lock bites, and check whether your database supports DDL inside a transaction before enabling UseTransaction. The licence is MIT, version 2.1.7 was published on 2026-09-15, and the module still declares Go 1.18 as its floor.

## FAQ

### What does gormigrate add to Gorm's own migration helpers?

Two things: proper schema versioning, so the applied migrations are recorded in a table and are not applied twice, and rollback support, since each migration carries a Rollback function alongside its Migrate function. Gorm's Migrator already handles creating tables and adding or dropping columns.

### How do I write a migration with gormigrate?

Pass a slice of gormigrate.Migration to gormigrate.New with your database and gormigrate.DefaultOptions. Each migration has an ID string, a Migrate function taking a *gorm.DB and returning an error, and a Rollback function doing the opposite, and you then call m.Migrate() to apply the pending ones.

### How do I bootstrap a brand new database with gormigrate?

Use m.InitSchema with a function that creates everything the application needs, typically tx.AutoMigrate for the tables plus explicit statements for foreign keys, indexes and constraints. That function runs only if no migration has been run before, so a new deployment skips the whole migration history.

### Is it safe to run gormigrate from several instances at once?

Not without help. The readme states that Gormigrate has no builtin lock mechanism, so in a distributed setup with more than one executable running at the same time you should use a distributed lock or mutex to prevent race conditions while migrations run.

### Which databases does gormigrate test against?

The integration tests cover PostgreSQL 18, MySQL 9, MariaDB 12, Microsoft SQL Server 2025, SQLite through the CGo driver mattn/go-sqlite3 and SQLite through the pure Go driver glebarez/sqlite. Running them locally needs task and docker, with the databases started by task docker:compose:up.

## Sources

- [go-gormigrate/gormigrate on GitHub](https://github.com/go-gormigrate/gormigrate)
- [License: MIT](https://github.com/go-gormigrate/gormigrate/blob/master/LICENSE)
- [Project website](https://pkg.go.dev/github.com/go-gormigrate/gormigrate/v2)
- [README](https://github.com/go-gormigrate/gormigrate/blob/master/README.md)
- [Releases](https://github.com/go-gormigrate/gormigrate/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/go-gormigrate-gormigrate
