Skip to content
gohacks.dev
Databases

The Best Go Migration Tools Compared Side by Side Fairly

Compare Go migration tools: goose, golang-migrate, tern and Atlas, with 200 migrations applied by each and timed, plus file formats, rollback and CI fit.

9 min read
Summarize in AI
Flat illustration of a staircase from one version to the next.

Every service with a database needs a way to change its schema in a controlled order across environments. The Go tools for the job look alike in outline, a folder of numbered files and a table that records what has run, and differ in the details that decide whether they fit your pipeline: how a migration is written, whether it can be Go code, how two instances starting together are kept from racing, what databases it supports and whether it detects a file that was edited after it ran. This roundup compares the tools on those points and times them on the same job: 200 migrations against PostgreSQL 18.6 on Go 1.25.6.

Tested with Go 1.25.6, goose v3.26.0, golang-migrate v4.19.0, tern v2.4.3, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64). Atlas and dbmate were not run.

It belongs to the database cornerstone. Each tool has its own article: goose, golang-migrate and Atlas. Safety on a live table is in zero downtime migrations.

The same job on three tools

main.goGo
package main

import (
	"context"
	"database/sql"
	"embed"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"time"

	"github.com/golang-migrate/migrate/v4"
	_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
	"github.com/golang-migrate/migrate/v4/source/iofs"
	"github.com/jackc/pgx/v5"
	_ "github.com/jackc/pgx/v5/stdlib"
	tern "github.com/jackc/tern/v2/migrate"
	"github.com/pressly/goose/v3"
)

var _ embed.FS

const (
	count = 200
	dsn   = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
)

func must(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

func write(dir, name, body string) {
	must(os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600))
}

func main() {
	ctx := context.Background()
	root, err := os.MkdirTemp("", "tools652")
	must(err)
	defer os.RemoveAll(root)
	gooseDir, migDir, ternDir := filepath.Join(root, "goose"), filepath.Join(root, "migrate"), filepath.Join(root, "tern")
	for _, d := range []string{gooseDir, migDir, ternDir} {
		must(os.MkdirAll(d, 0o755))
	}
	for i := 1; i <= count; i++ {
		up := fmt.Sprintf("create table m652_%%s_%d (id int primary key, v text);", i)
		drop := fmt.Sprintf("drop table m652_%%s_%d;", i)
		write(gooseDir, fmt.Sprintf("%05d_t.sql", i), "-- +goose Up\n"+fmt.Sprintf(up, "goose")+"\n-- +goose Down\n"+fmt.Sprintf(drop, "goose")+"\n")
		write(migDir, fmt.Sprintf("%06d_t.up.sql", i), fmt.Sprintf(up, "migrate")+"\n")
		write(migDir, fmt.Sprintf("%06d_t.down.sql", i), fmt.Sprintf(drop, "migrate")+"\n")
		write(ternDir, fmt.Sprintf("%03d_t.sql", i), fmt.Sprintf(up, "tern")+"\n\n---- create above / drop below ----\n\n"+fmt.Sprintf(drop, "tern")+"\n")
	}

	db, err := sql.Open("pgx", dsn)
	must(err)
	defer db.Close()
	for _, tool := range []string{"goose", "migrate", "tern"} {
		for i := 1; i <= count; i++ {
			_, _ = db.Exec(fmt.Sprintf("drop table if exists m652_%s_%d", tool, i))
		}
		_, _ = db.Exec("drop table if exists schema_652_" + tool)
	}

	report := func(tool, prefix string, apply, again time.Duration) {
		var n int
		must(db.QueryRow("select count(*) from information_schema.tables where table_name like $1", "m652\\_"+prefix+"\\_%").Scan(&n))
		fmt.Printf("%-14s applied %d migrations in %-8v tables created %d, second run with nothing to do %v\n", tool, count, apply.Round(time.Millisecond), n, again.Round(100*time.Microsecond))
	}

	// goose
	goose.SetLogger(log.New(io.Discard, "", 0))
	goose.SetTableName("schema_652_goose")
	_ = goose.SetDialect("postgres")
	start := time.Now()
	must(goose.UpContext(ctx, db, gooseDir))
	apply := time.Since(start)
	start = time.Now()
	must(goose.UpContext(ctx, db, gooseDir))
	report("goose", "goose", apply, time.Since(start))

	// golang-migrate
	src, err := iofs.New(os.DirFS(root), "migrate")
	must(err)
	m, err := migrate.NewWithSourceInstance("iofs", src, "pgx5://postgres:[email protected]:55432/lab?sslmode=disable&x-migrations-table=schema_652_migrate")
	must(err)
	start = time.Now()
	must(m.Up())
	apply = time.Since(start)
	start = time.Now()
	_ = m.Up() // ErrNoChange
	report("golang-migrate", "migrate", apply, time.Since(start))
	m.Close()

	// tern
	conn, err := pgx.Connect(ctx, dsn)
	must(err)
	defer conn.Close(ctx)
	mg, err := tern.NewMigrator(ctx, conn, "schema_652_tern")
	must(err)
	must(mg.LoadMigrations(os.DirFS(ternDir)))
	start = time.Now()
	must(mg.Migrate(ctx))
	apply = time.Since(start)
	mg2, err := tern.NewMigrator(ctx, conn, "schema_652_tern")
	must(err)
	must(mg2.LoadMigrations(os.DirFS(ternDir)))
	start = time.Now()
	must(mg2.Migrate(ctx))
	report("tern", "tern", apply, time.Since(start))

	// Clean up.
	for _, tool := range []string{"goose", "migrate", "tern"} {
		for i := 1; i <= count; i++ {
			_, _ = db.Exec(fmt.Sprintf("drop table if exists m652_%s_%d", tool, i))
		}
		_, _ = db.Exec("drop table if exists schema_652_" + tool)
	}
}
Text
goose          applied 200 migrations in 499ms    tables created 200, second run with nothing to do 7.3ms
golang-migrate applied 200 migrations in 1.292s   tables created 200, second run with nothing to do 1.2ms
tern           applied 200 migrations in 605ms    tables created 200, second run with nothing to do 300µs

The program writes 200 migration files, each creating one table, in the format each tool expects, into temporary folders. Then it applies them with each tool used as a library, against the same database, with a separate version table per tool, and records how long that took and how long a second run takes when there is nothing left to apply. The three formats show the main visible difference between the tools:

ToolFile layoutRollback marker
goose00001_name.sql, one file-- +goose Up and -- +goose Down comments
golang-migrate000001_name.up.sql and 000001_name.down.sqlSeparate down file
tern001_name.sql, one fileA line ---- create above / drop below ----

The timings

Tool200 migrations appliedSecond run, nothing to do
goose499ms7.3ms
tern605ms300µs
golang-migrate1.292s1.2ms

All three created all 200 tables, and the table of the version history held 200 entries afterwards. Read the numbers with care. Each migration is one create table, so the work is a round trip, a catalogue write and the bookkeeping insert: about 2.5 to 6.5 milliseconds each. golang-migrate took about twice as long as the others, which is consistent with how it works: it takes a lock, reads the version and writes the new version and a dirty flag around every file, several statements for each. For real migrations that take seconds to run, this overhead disappears. For a test suite that applies 200 migrations to a fresh database before every package, it is the difference between half a second and a second and a quarter, and it adds up.

The second run is the number that matters at start-up. Checking that nothing needs to run cost 7.3 milliseconds in goose, 1.2 in golang-migrate and 0.3 in tern. All three are small enough that a service can call Up on every start without anyone noticing. None of these figures is a reason to choose a tool.

What actually differs

Go migrations. goose lets a migration be a Go function registered next to the SQL files, for data changes that need logic, and it runs it inside the transaction. golang-migrate and tern are SQL only, which keeps them simple and means data fixes happen elsewhere. If a migration must call an API, compute values or run a loop, only goose does that in place.

Locking against concurrent starts. Several instances starting together can race. goose's Provider takes an optional session locker, and embedding migrations measured what that prevents: without it four instances failed in every round on a slow migration. golang-migrate's PostgreSQL driver takes an advisory lock while it works. For tern and any other tool, read what it does about concurrent runs for your database and driver, and test it with a slow migration as that article does.

Database support. golang-migrate supports the longest list of databases and sources (files, embedded, cloud storage, GitHub), because drivers are plugins. goose supports the major SQL databases, and tern is PostgreSQL only, built by the author of pgx. If you must support several databases from one code base, golang-migrate or goose; if it is only PostgreSQL, any.

Failure handling. golang-migrate marks a migration dirty when it fails and refuses to proceed until you fix the cause and force the version, which is explicit and manual; the behaviour is shown in its article. goose and tern run each migration in a transaction on PostgreSQL, so a failure leaves the database at the previous version with nothing to clean up. On databases without transactional DDL, such as MySQL, all of them can leave a half applied change.

Checksums. None of the three records a checksum of each applied file by default, so an edited file goes unnoticed and environments quietly diverge. Atlas does record one, in atlas.sum, and catches edits and reordering. If this has ever bitten you, it is the strongest reason to look at Atlas.

Generating migrations. The three runners do not write migrations for you. Atlas computes the difference between your desired schema and the database and writes it. See declarative migrations with Atlas. Several ORMs generate migrations or sync the schema automatically, with the review problems discussed in the ORM ranking.

Command line and library. All three work as both. goose and golang-migrate have well known CLIs that CI pipelines call; tern has a CLI with templating of variables into migrations; all three can be used as a package, as the lab does.

The other tools

Atlas computes diffs from a desired state, records atlas.sum, lints migrations and supports declarative and versioned workflows. It needs a dev database, and some features depend on the edition and version you use. It was not run in this series; the article linked above describes it from its documentation.

dbmate is a small standalone binary that applies SQL files with -- migrate:up and -- migrate:down sections, with support for several databases and a convenient .env driven interface. It is a good fit for pipelines that do not want a Go dependency. It was not run here.

sql-migrate (rubenv) is a library and CLI with -- +migrate Up and Down markers in SQL files. It is less active than the three above, so check its recent releases before adopting it.

Flyway and Liquibase are the JVM incumbents. They are mature and feature rich, and need a Java runtime; teams with polyglot services sometimes standardise on one of them.

ORM migrators, such as GORM's AutoMigrate, are for prototypes and development databases, and are not a substitute for reviewed migrations on data you care about.

Habits that matter more than the tool

Three practices apply whichever you pick. Run migrations from one place. A pre-deploy job or a dedicated command means one process applies them, with its own database role that may alter the schema, while the application runs with a role that cannot. Test the whole chain. A CI job that applies every migration to an empty database proves they run in order, and one that applies the latest few to a copy of production data proves they run on real rows and how long they take. Never edit an applied file. Write a new migration, since the tools that do not checksum will not warn you, and the tools that do will refuse to continue.

Choosing

Your situationChoose
General purpose, PostgreSQL or MySQL, want Go data migrations and a lockergoose
Many databases, a simple paired file layout, widely used in CIgolang-migrate
PostgreSQL only, small library, you already use pgxtern
You want diffs generated, checksums and lintingAtlas
A pipeline that wants a single binary and plain SQL filesdbmate
A prototypeThe ORM's own migrator, then switch

The weights that matter are not the ones in the benchmark. Prefer the tool your team will run the same way everywhere, check what it does when two instances start at once, decide how you will detect an edited file, and keep every migration small and safe for a live table. Then apply the same file layout and naming in every service. Switching tools later is possible, since all of them are folders of SQL; it is the habits around them that are hard to change. The tools are documented in the goose repository, the golang-migrate repository and the tern repository, and the lab above is the source of the timings.

Also inTooling
Taggedroundup
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.