SQL Migrations in a Golang Project
Managing database schemas can feel like walking a tightrope. As your application grows and user demands change, your database needs to evolve too. You'll add tables, modify columns, and refine relationships. The trick is doing this without causing data loss, breaking existing features, or making deployment a nightmare. This is exactly where database migrations save the day.
Migrations provide a structured, version-controlled way to apply incremental and, crucially, reversible changes to your database schema. They ensure that your development, staging, and production environments all have the correct database structure at any given time.
In the Go ecosystem, you have a few excellent choices for migration tools, such as github.com/golang-migrate/migrate
and github.com/pressly/goose
. For this guide, we'll dive into Goose, a robust and straightforward tool for writing, managing, and applying SQL database migrations.
We'll cover everything you need to get started:
- Installing Goose
- Creating migration scripts
- Running migrations against your database
- Embedding migrations directly into your Go application binary
Installation
Getting Goose up and running is as simple as a single Go command. Open your terminal and execute:
go install github.com/pressly/goose/v3/cmd/goose@latest
This command downloads and installs the goose
command-line interface (CLI) tool to your GOPATH/bin
directory, making it accessible from your terminal.
Creating Migration Scripts
Goose uses SQL files to define your database changes. Each migration script has two parts: an "Up" section for applying changes and a "Down" section for reverting them.
To generate a new migration script, use the goose create
command:
goose -dir migrations create initial_schema sql
Let's break down this command:
goose
: Calls the Goose CLI tool.-dir migrations
: Tells Goose to create the migration file inside a directory namedmigrations
. If this directory doesn't exist, Goose will create it for you.create
: The Goose command to generate a new migration file.initial_schema
: This is the descriptive name you're giving to your migration. Goose will prepend a timestamp to this name (e.g.,20240521103000_initial_schema.sql
).sql
: Specifies that you want a.sql
file for your migration. Alternatively, you could usego
to generate a.go
file for programmatic migrations, but we'll stick to SQL here for simplicity.
After running this, you'll find a new SQL file in your migrations
directory with the following structure:
-- +goose Up
-- +goose StatementBegin
SELECT 'up SQL query';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
Now, let's replace the placeholder SELECT
statements with actual SQL queries to define our initial database schema. We'll create a simple users
table:
-- 20240521103000_initial_schema.sql (the actual timestamp will vary)
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(100) NOT NULL UNIQUE
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS users;
-- +goose StatementEnd
Key takeaways for migration scripts:
-- +goose Up
: Contains the SQL commands to apply the migration (e.g.,CREATE TABLE
,ALTER TABLE
,ADD COLUMN
).-- +goose Down
: Contains the SQL commands to revert the migration (e.g.,DROP TABLE
,DROP COLUMN
). It's crucial to define bothUp
andDown
migrations to allow for easy rollbacks and prevent schema inconsistencies.-- +goose StatementBegin
/-- +goose StatementEnd
: These directives wrap individual SQL statements. This is particularly useful when you have multiple statements in one section, ensuring Goose executes them correctly.IF NOT EXISTS
/IF EXISTS
: Using these clauses (CREATE TABLE IF NOT EXISTS
,DROP TABLE IF EXISTS
) makes your migrations more robust, preventing errors if you try to create a table that already exists or drop one that doesn't.
Running Migration Scripts
Once your migration script is ready, you can apply it to your database using the goose
CLI. You'll need to specify the database driver and the connection string.
For our SQLite example, if your database file is named db.sqlite
in the current directory:
goose -dir migrations sqlite3 ./db.sqlite up
Let's break down this command:
-dir migrations
: Again, specifies the directory where your migration files are located.sqlite3
: This is the chosen database driver. Goose supports various drivers. If you were using PostgreSQL, this would bepostgres
../db.sqlite
: This is the connection string to your database. For SQLite, it's the file path. For other databases, it would be a full connection string, such as"host=localhost port=5432 user=postgres password=mysecretpassword dbname=mydb sslmode=disable"
.up
: This is the direction of migration.up
: Migrates your database schema forward to the latest version. Goose tracks which migrations have been applied, so it only runs the new ones.down
: Reverts the last applied migration, effectively moving your schema back by one version.status
: Shows the current status of all migrations (applied or pending).version
: Shows the current database version.
Using Environment Variables
For convenience and security, especially in production environments, you can define the database driver and connection string using environment variables:
export GOOSE_DRIVER=sqlite3
export GOOSE_DBSTRING=./db.sqlite
goose -dir migrations up # Now you don't need to specify driver and dbstring every time
This approach keeps sensitive connection details out of your shell history and allows for easier configuration management.
Embedding Migrations into Your Go Project
A powerful feature of Go (since version 1.16) is the embed
package, which lets you bundle static files directly into your application's binary. This means you don't need to distribute your migration SQL files separately with your compiled Go application. Goose seamlessly integrates with this.
First, within your main.go
file (or any other file where you'll manage migrations), declare an embed.FS
variable:
// main.go
package main
import "embed" // Don't forget to import the embed package
//go:embed migrations/*.sql
var MigrationsFS embed.FS
The //go:embed migrations/*.sql
directive tells the Go compiler to embed all files ending with .sql
inside the migrations
directory into the MigrationsFS
variable, which will act as a virtual file system.
Next, you can use this embedded file system with the goose
package directly in your application's code. First, ensure you've installed the Goose library itself (not just the CLI):
go get -u github.com/pressly/goose/v3
Now, modify your main
function (or a dedicated database initialization function) to apply migrations programmatically:
// main.go (continued)
package main
import (
"database/sql" // Don't forget to import database/sql
"embed"
"log" // For logging fatal errors
"github.com/pressly/goose/v3" // Import the goose library
_ "github.com/mattn/go-sqlite3" // Import your database driver (e.g., for SQLite)
)
//go:embed migrations/*.sql
var MigrationsFS embed.FS
func main() {
// Open your database connection
db, err := sql.Open("sqlite3", "file:./your_app_database.sqlite")
if err != nil {
log.Fatal("failed to open database:", err)
}
defer db.Close() // Ensure the database connection is closed when main exits
// Set the embedded file system as Goose's base file system for migrations
goose.SetBaseFS(MigrationsFS)
// Set the database dialect (e.g., "sqlite3", "postgres", "mysql")
if err := goose.SetDialect("sqlite3"); err != nil {
log.Fatal("failed to set goose dialect:", err)
}
// Run all pending "up" migrations to bring the database schema to the latest version
if err := goose.Up(db, "migrations"); err != nil {
log.Fatal("failed to run migrations:", err)
}
log.Println("Database migrations applied successfully!")
// Your application's main logic goes here, e.g., starting an HTTP server
// http.ListenAndServe(":8080", nil)
}
By embedding and running migrations this way, every time your application starts, it will automatically check for and apply any pending database schema changes. This creates a self-healing and always-up-to-date database, streamlining your deployment process and ensuring consistency across all environments. It's a common and highly recommended pattern for Go applications.
What database drivers are you planning to use with Goose in your projects?