How to Run Database Migrations in Go (golang-migrate, goose)

Install golang-migrate or goose and run the up command with your database URL to apply schema changes.

When the database falls behind

You add a status column to your users table in your local database. You push the code. The CI pipeline runs tests against a fresh database instance. Every test that touches the users table panics because the column is missing. You fix the tests by adding the column manually in the CI script, but now you have two ways to manage schema changes, and one of them is a ticking time bomb. Database migrations solve this by treating schema changes as versioned code that runs automatically.

Migrations as versioned code

Go treats the database as an external resource. The standard library gives you database/sql to talk to the database, but it doesn't manage the schema. That's a deliberate design choice. Go keeps the standard library small and lets the ecosystem handle domain-specific tools. You need a migration library to bridge the gap between your Go code and the evolving database structure.

Think of migrations as a versioned log of schema changes. Each migration has a number and a direction. You apply them in order to move the database forward, or roll them back to move backward. The library tracks which migrations have run so it doesn't repeat work. This turns schema management into a deterministic process. You can reproduce the exact database state for any version of your code.

Minimal example with goose

Here's the simplest way to run a migration using goose, which lets you write migrations as Go functions.

package main

import (
	"log"
	"os"

	"github.com/pressly/goose/v3"
	"github.com/pressly/goose/v3/dialect"
)

// main initializes the database connection and runs pending migrations.
func main() {
	// Open a connection to the database.
	// The driver must be imported separately, usually in an init function.
	db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Register the dialect so goose knows how to generate SQL for PostgreSQL.
	// Different databases have different syntax for schema operations.
	dialect.SetDialect("postgres", dialect.Postgres)

	// Run all pending migrations from the current directory.
	// Goose looks for Go files with specific naming conventions.
	if err := goose.Up(db, "./migrations"); err != nil {
		log.Fatal(err)
	}
}

The goose.Up call scans the directory, finds migrations that haven't run, and executes them in order. If the database is already up to date, the function returns immediately. This pattern makes it safe to call migrations on application startup. The application waits for the database to catch up before handling requests.

Migrations are code. Treat them with the same discipline as your application logic.

How the library tracks state

When you run the program, goose connects to the database and checks for a table named goose_db_version. If the table doesn't exist, goose creates it automatically. This table stores the current migration version. goose scans the ./migrations directory for files matching the pattern *_*.go. It sorts them by the timestamp prefix.

For each file, goose checks the version against the database. If the version is higher than what's in the database, goose calls the Up function defined in that file. The function executes SQL or schema changes. Once the function returns successfully, goose records the new version in the goose_db_version table. If any step fails, the process stops and the database stays in a consistent state.

The version table is the source of truth. You can query it to see which migrations have run. You can also use the library to roll back migrations by calling goose.Down. This decrements the version and runs the Down function. The library ensures that rollbacks happen in reverse order.

Realistic migration file

Here's what a migration file looks like. Each file defines the changes for one version.

package migrations

import (
	"context"
	"log"

	"github.com/pressly/goose/v3"
)

// Up creates the users table with a unique email constraint.
func init() {
	goose.AddMigrationContext(20240101120000, "create_users", Up, Down)
}

// Up creates the users table with a unique email constraint.
func Up(ctx context.Context, tx *sql.Tx) error {
	// Use the transaction to execute schema changes.
	// This ensures atomicity: either the whole migration succeeds or rolls back.
	_, err := tx.ExecContext(ctx, `
		CREATE TABLE users (
			id SERIAL PRIMARY KEY,
			email VARCHAR(255) UNIQUE NOT NULL,
			created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
		)
	`)
	return err
}

// Down drops the users table to revert the migration.
func Down(ctx context.Context, tx *sql.Tx) error {
	// Always implement Down so you can roll back during development.
	// Production rollbacks are rare, but the safety net matters.
	_, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS users`)
	return err
}

The init function registers the migration with goose. The version number 20240101120000 follows a timestamp format. This makes it easy to sort migrations chronologically. The Up and Down functions receive a context.Context and a transaction. Using a transaction is important. If the migration fails halfway through, the database rolls back the changes. You don't end up with a partially created table.

The receiver name convention applies to methods, but here the function signature follows a strict pattern. context.Context always goes as the first parameter. This is a Go convention that carries over to migration libraries. Functions that take a context should respect cancellation and deadlines. If the migration takes too long, the context can signal the function to stop.

Write your Down migrations first. If you can't roll back, you can't recover from a bad deploy.

Pitfalls and compiler errors

Migrations run sequentially. You cannot parallelize them. If you spawn a goroutine for each migration, you risk race conditions on the schema. The database engine itself serializes DDL operations, but your code must respect the order. If you try to run migrations concurrently, you'll hit race conditions or deadlocks.

If you pass a function with the wrong signature to goose.AddMigration, the compiler rejects the program with cannot use Up (value of type func(*sql.DB) error) as func(context.Context, *sql.Tx) error value in argument. The signature requires context.Context as the first parameter and a transaction as the second. This error catches mistakes early. You don't need to run the code to see the problem.

Some databases like MySQL don't support DDL in transactions. goose handles this by checking the dialect, but you need to know your database limits. If you run a migration that modifies a table while another goroutine is writing to it, you might hit a lock timeout. Always run migrations during a maintenance window or use zero-downtime patterns for production databases.

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. In migrations, error handling is even more important. If a migration fails, you need to know exactly why. Log the error and stop. Don't swallow errors or retry blindly.

Migrations are the critical path for your database. Test them against a real database, not just in memory.

Zero-downtime migration patterns

Changing a schema while the application is live requires care. If you rename a column, the application crashes until the migration finishes. The expand-contract pattern solves this. First, add the new column. Deploy the code to write to both columns. Backfill the data. Deploy the code to read from the new column. Drop the old column. This keeps the application available throughout the change.

Here's how the pattern works in practice. You want to rename email to user_email. You can't just rename the column. Instead, you create a migration that adds user_email. You deploy the application code to write to both email and user_email. You run a backfill migration to copy data from email to user_email. You deploy the application code to read from user_email. Finally, you run a migration to drop email.

This pattern takes multiple deployments and migrations. It's more work, but it prevents downtime. For small projects, you might skip this and accept a brief outage. For production systems with high availability requirements, zero-downtime migrations are essential.

Zero-downtime is a discipline. Plan your schema changes ahead of time and coordinate with your deployment pipeline.

Choosing a migration tool

Use goose when you want migrations written in Go code and tight integration with your application's build process. Use golang-migrate when you prefer a standalone CLI tool and want to keep migrations as plain SQL files separate from your Go code. Use sqlc when you need type-safe queries generated from your schema and want migrations to drive code generation. Use raw SQL scripts with a custom runner when you have strict compliance requirements and cannot introduce third-party migration libraries. Use a single migration tool across your organization when consistency matters more than feature differences.

golang-migrate is popular for its CLI. You can run migrations from the command line without writing Go code. This is useful for database administrators who don't want to compile Go binaries. goose is popular for its Go-centric approach. You can use Go types and functions in your migrations. This makes it easier to write complex logic.

Both tools support multiple databases. Both tools track versions in a table. The choice comes down to workflow. If your team prefers SQL files and a CLI, pick golang-migrate. If your team prefers Go code and library integration, pick goose.

Pick one tool and stick with it. Switching migration tools is harder than switching database drivers.

Where to go next