Database schema migration is the management of incremental, reversible changes and version control to relational database schemas. The migrations basically track granular changes to your database schema which are reflected as separate scripted files.
Golang Goose is a database migration tool. It is both a CLI and a Go library, and it allows you to manage your database schema by creating incremental SQL changes or Go functions. It works with Postgres, MySQL/MariaDB, SQLite, ClickHouse, MSSQL, Spanner and more.
In this guide we will use goose v3 (the module path is github.com/pressly/goose/v3) against a Postgres database. If you need a database to follow along, check out how to run Postgres with Docker and Docker Compose and how to create a Postgres user and database.
Installation
If golang is installed locally, use this command to install goose:
go install github.com/pressly/goose/v3/cmd/goose@latest
This will install the goose binary to your $GOPATH/bin directory (usually ~/go/bin), so make sure that directory is in your $PATH. Recent goose releases require a recent Go toolchain (the current go.mod declares Go 1.26), so update Go first if go install complains. See how to install Go on Fedora and Rocky Linux if you need it.
For a lite version of the binary without the database drivers you don’t need, clone the code then use the exclusion build tags:
git clone https://github.com/pressly/goose.git
cd goose
go build -tags='no_postgres no_mysql no_sqlite3 no_ydb' -o goose ./cmd/goose
The available tags are no_azuresql, no_clickhouse, no_libsql, no_mssql, no_mysql, no_postgres, no_sqlite3, no_vertica and no_ydb. Leave out the tag for the driver you actually use (no_postgres in this guide).
Older versions of this guide used
go build -i. The-iflag was removed from modern Go, so drop it.
Or go to the releases page here https://github.com/pressly/goose/releases and download the latest binary for your OS then move it to a directory in your $PATH.
For macOS users goose is available as a Homebrew Formulae:
brew install goose
Once installed, verify version
$ goose -version
goose version: v3.29.0
Using Goose
Goose provides functionality to Create migrations, apply them, undo migrations and check the status of the migration. In this guide we are going to focus on SQL migrations
Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND
or
Set environment key
GOOSE_DRIVER=DRIVER
GOOSE_DBSTRING=DBSTRING
GOOSE_MIGRATION_DIR=MIGRATION_DIR
Usage: goose [OPTIONS] COMMAND
Some of the commonly used options:
-dir- directory with migration files (defaults to., can also be set withGOOSE_MIGRATION_DIR).-table- migrations table name (defaults togoose_db_version, can also be set withGOOSE_TABLE).-s- use sequential numbering (00001_...) instead of timestamps for new migrations.-allow-missing- apply missing (out-of-order) migrations.-env- load environment variables from a file (defaults to.env, use-env=noneto disable).
Run goose -h to see the full list of commands and options.
Creating migration
To create new file for SQL migration, use this command
$ goose create add_users_table sql
2021/10/18 21:52:11 Created new file: 20211018215211_add_users_table.sql
This will create migrations in the current directory. If you want to create the migrations in a separate directory please supply the -dir migrations argument (or set GOOSE_MIGRATION_DIR).
The generated file contains placeholder SELECT 'up SQL query'; and SELECT 'down SQL query'; statements. Edit the newly created file and replace them to define the behavior of your migration. Let us add content to create a users table in our case. For the action up, we are going to create a users table and add unique indexes for the email and username fields.
-- +goose Up
create table users (
id bigserial primary key,
username varchar(255) not null,
names varchar(225) not null,
email varchar(255) not null,
password_hash varchar(255) not null,
created_at timestamptz not null default clock_timestamp(),
updated_at timestamptz
);
create unique index users_email_uniq_idx ON users(LOWER(email));
create unique index users_username_uniq_idx ON users(LOWER(username));
-- +goose Down
drop index if exists users_username_uniq_idx;
drop index if exists users_email_uniq_idx;
drop table if exists users;
The unique indexes on LOWER(email) and LOWER(username) are also used for lookups, so a second, non-unique index on the same expression would only add write overhead.
A few rules to keep in mind:
- Each file must have exactly one
-- +goose Upannotation, and it must come before the optional-- +goose Down. - Every statement must end with a semicolon.
- Each migration runs in a transaction by default. Add
-- +goose NO TRANSACTIONat the top of the file for statements that can’t run in one, likeCREATE INDEX CONCURRENTLY. - Wrap statements that contain semicolons themselves (for example PL/pgSQL functions) in
-- +goose StatementBeginand-- +goose StatementEnd.
Let’s create another migration file to add posts
$ goose create add_posts_table sql
2021/10/18 22:04:33 Created new file: 20211018220433_add_posts_table.sql
Then add the content for up and down
-- +goose Up
create table posts (
id bigserial primary key,
user_id bigint not null references users(id),
slug varchar(255) not null,
title varchar(100) not null,
description varchar(255),
content text not null,
created_at timestamptz not null default clock_timestamp(),
updated_at timestamptz
);
create index posts_user_id_idx ON posts(user_id);
-- +goose Down
drop index if exists posts_user_id_idx;
drop table if exists posts;
Now we have two migration files
$ ls
20211018215211_add_users_table.sql
20211018220433_add_posts_table.sql
Applying migrations
Exporting variables
Before proceeding, we have to declare the database to use and the connection string to the database. Goose reads the GOOSE_DRIVER and GOOSE_DBSTRING environment variables for the database driver and database connection string respectively. Use these export commands to define
export GOOSE_DRIVER=postgres
export GOOSE_DBSTRING="user=citizix_user password=pcDFBXo5yX host=10.2.11.10 dbname=citizix_app sslmode=disable"
A URL style connection string such as postgres://citizix_user:[email protected]:5432/citizix_app?sslmode=disable works too.
Alternatively, put the same variables in a .env file in the directory you run goose from. Goose loads it by default:
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://citizix_user:[email protected]:5432/citizix_app?sslmode=disable
GOOSE_MIGRATION_DIR=./migrations
Keep .env out of version control since it contains the database password. Run goose env to print the configuration goose has picked up.
status
Now that we have some files, we can check the state of our migrations using the goose status command:
$ goose status
2021/10/19 21:45:30 Applied At Migration
2021/10/19 21:45:30 =======================================
2021/10/19 21:45:31 Pending -- 20211018215211_add_users_table.sql
2021/10/19 21:45:32 Pending -- 20211018220433_add_posts_table.sql
up
Use the command goose up to apply all available migrations.
$ goose up
2021/10/19 21:50:36 OK 20211018215211_add_users_table.sql
2021/10/19 21:50:40 OK 20211018220433_add_posts_table.sql
2021/10/19 21:50:40 goose: no migrations to run. current version: 20211018220433
Confirm by checking status
$ goose status
2021/10/19 21:51:20 Applied At Migration
2021/10/19 21:51:20 =======================================
2021/10/19 21:51:21 Tue Oct 19 18:50:33 2021 -- 20211018215211_add_users_table.sql
2021/10/19 21:51:22 Tue Oct 19 18:50:37 2021 -- 20211018220433_add_posts_table.sql
You can also print only the current version of the database with goose version.
up-to
Migrate up to a specific version (including that version).
$ goose up-to 20211018220433
2021/10/19 21:56:50 OK 20211018215211_add_users_table.sql
2021/10/19 21:56:54 OK 20211018220433_add_posts_table.sql
2021/10/19 21:56:54 goose: no migrations to run. current version: 20211018220433
up-by-one
Migrate up a single migration from the current version
$ goose up-by-one
2021/10/19 21:58:47 OK 20211018215211_add_users_table.sql
down
Roll back a single migration from the current version.
$ goose down
2021/10/19 21:52:40 OK 20211018220433_add_posts_table.sql
down-to
Roll back migrations to a specific version (will not include that version).
$ goose down-to 20211018215211
2021/10/19 21:53:54 OK 20211018220433_add_posts_table.sql
2021/10/19 21:53:55 goose: no migrations to run. current version: 20211018215211
To roll back all migrations, use goose down-to 0 (careful!).
redo
Roll back the most recently applied migration, then run it again.
$ goose redo
2021/10/19 21:55:04 OK 20211018215211_add_users_table.sql
2021/10/19 21:55:08 OK 20211018215211_add_users_table.sql
reset
Rolls back all migrations
$ goose reset
2021/10/19 21:58:04 OK 20211018220433_add_posts_table.sql
2021/10/19 21:58:08 OK 20211018215211_add_users_table.sql
validate
Check the migration files for errors without running them:
goose validate
Running embedded migrations from Go
Goose is also a Go library, so your application can apply its own migrations on startup. Using Go’s embed package, the SQL files are compiled into the binary and you don’t have to ship the migrations directory alongside it.
Add goose and a Postgres driver to your Go module:
go mod init example.com/myapp
go get github.com/pressly/goose/v3
go get github.com/jackc/pgx/v5
Assuming the SQL migrations are in the migrations directory, main.go looks like this:
package main
import (
"database/sql"
"embed"
"log"
"os"
_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" database/sql driver
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var embedMigrations embed.FS
func main() {
db, err := sql.Open("pgx", os.Getenv("GOOSE_DBSTRING"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
goose.SetBaseFS(embedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
log.Fatal(err)
}
if err := goose.Up(db, "migrations"); err != nil {
log.Fatal(err)
}
// run app
}
We pass "migrations" as the directory to goose.Up because embedding preserves the directory structure. Embedded files are read-only, so commands like create and fix still work on the files on disk with the CLI.
For more control, goose v3 also provides a goose.NewProvider API that takes a dialect, a *sql.DB and an fs.FS and returns a provider with context-aware Up, Down and Status methods. See the goose documentation for details.
Conclusion
In this guide we managed to explore how to use golang goose to manage database migrations: installing the CLI, writing incremental SQL migrations, applying and rolling them back, and embedding them in a Go binary with embed.FS.