Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a first of its kind tool for helping you automatically index API endpoints across all your repositories. LiveAPI helps you discover, understand and use APIs in large tech infrastructures with ease.
Database schema changes can be a pain. You’re building a Go app, the code’s flowing, but then you need to tweak your database—add a column, drop a table, or rename something. Doing this manually is error-prone and doesn’t scale. That’s where golang-migrate, a Go library for database migrations, saves the day. It lets you version your schema, apply changes systematically, and roll back if things go south.
This post dives into using golang-migrate to manage your database schema. We’ll cover setup, writing migrations, running them, and handling real-world scenarios. Expect practical examples, code you can actually run, and tips to avoid common pitfalls. Let’s get your database evolving smoothly.
Why Database Migrations Matter
If you’ve ever manually altered a database schema in production, you know it’s like defusing a bomb. One wrong move, and your app crashes or data gets corrupted. Migrations solve this by providing a structured, repeatable way to update your schema. With golang-migrate, you define changes as versioned scripts, apply them in order, and track what’s been done.
Key benefits:
- Version control for your schema, just like your code.
- Consistency across environments (dev, staging, prod).
- Rollbacks for when things go wrong.
- Support for multiple databases (Postgres, MySQL, SQLite, etc.).
This post uses Postgres for examples, but golang-migrate supports many databases. Check the full list here.
Setting Up Golang-Migrate
To start, you need the golang-migrate CLI and library. The CLI helps create and run migrations, while the library integrates migrations into your Go app.
Install the CLI
Run this to install the CLI:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
The postgres
tag ensures support for Postgres. Swap it for mysql
, sqlite
, or others based on your database.
Add the Library
In your Go project, add the library:
go get -u github.com/golang-migrate/migrate/v4
Project Structure
Create a migrations
folder in your project to store migration files. Each file follows the naming convention VERSION_description.up.sql
(for applying changes) and VERSION_description.down.sql
(for rollbacks). Example:
project/
├── main.go
├── migrations/
│ ├── 202505310001_create_users_table.up.sql
│ ├── 202505310001_create_users_table.down.sql
Pro tip: Use timestamps for VERSION
(e.g., 202505310001
) to avoid conflicts and keep migrations chronological.
Writing Your First Migration
Let’s create a migration to set up a users
table. Run this CLI command to generate migration files:
migrate create -ext sql -dir migrations -seq create_users_table
This creates two files in migrations/
:
202505310001_create_users_table.up.sql
202505310001_create_users_table.down.sql
Up Migration
Edit 202505310001_create_users_table.up.sql
:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Output: Creates a users table with id, username, email, and created_at columns.
Down Migration
Edit 202505310001_create_users_table.down.sql
:
DROP TABLE users;
-- Output: Drops the users table if migration is rolled back.
Key point: The down
migration should reverse the up
migration exactly. Test both to ensure they work as expected.
Running Migrations in Your Go App
Now, let’s integrate migrations into your Go app. Below is a complete example that connects to Postgres and applies migrations.
package main
import (
"database/sql"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
)
func main() {
// Connect to Postgres
db, err := sql.Open("postgres", "postgres://user:password@localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Initialize migration driver
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
log.Fatal(err)
}
// Set up migrator
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"postgres",
driver,
)
if err != nil {
log.Fatal(err)
}
// Apply migrations
err = m.Up()
if err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
log.Println("Migrations applied successfully")
}
// Output: Applies all pending migrations or logs "Migrations applied successfully" if no changes.
How It Works
-
Connects to your Postgres database using
lib/pq
. - Initializes a migration driver for Postgres.
-
Points to the
migrations
folder usingfile://migrations
. -
Runs
m.Up()
to apply all pending migrations.
Note: migrate.ErrNoChange
means no new migrations were applied (e.g., already up-to-date). Always handle this error to avoid false positives.
Handling Schema Changes Like a Pro
Let’s say your app evolves, and you need to add a last_login
column to the users
table. Create a new migration:
migrate create -ext sql -dir migrations -seq add_last_login_to_users
Up Migration
202505310002_add_last_login_to_users.up.sql
:
ALTER TABLE users ADD COLUMN last_login TIMESTAMP;
-- Output: Adds last_login column to users table.
Down Migration
202505310002_add_last_login_to_users.down.sql
:
ALTER TABLE users DROP COLUMN last_login;
-- Output: Removes last_login column from users table.
Applying the Change
Run the migrations again using the Go code above or the CLI:
migrate -path migrations -database "postgres://user:password@localhost:5432/mydb?sslmode=disable" up
Pro tip: Test migrations in a dev environment first. Use a tool like pgAdmin to inspect the schema after applying.
Rolling Back When Things Go Wrong
Mistakes happen. Maybe you added a column with the wrong type. Golang-migrate makes rollbacks easy with the down
migrations.
To roll back the last migration:
err = m.Down()
if err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
Or via CLI:
migrate -path migrations -database "postgres://user:password@localhost:5432/mydb?sslmode=disable" down
Example Rollback
If you applied the last_login
migration but realize it should be NOT NULL
, roll it back, edit the up
migration:
ALTER TABLE users ADD COLUMN last_login TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- Output: Adds last_login column with NOT NULL and default value.
Then reapply the migration.
Key point: Always write down
migrations, even if you think you won’t need them. They’re your safety net.
Managing Migrations in a Team
In a team, multiple developers might create migrations simultaneously, leading to conflicts. Here’s how to avoid chaos:
Practice | Why It Helps |
---|---|
Use timestamped versions | Prevents naming collisions (e.g., 202505310001 vs 202505310002 ). |
Lock the database | Golang-migrate locks the database during migrations to prevent concurrent runs. |
Test migrations locally | Catch errors before they hit production. |
Document migration intent | Add comments in SQL files to explain complex changes. |
Example Comment in an up
migration:
-- Adds index to improve query performance on username lookups
CREATE INDEX idx_users_username ON users (username);
-- Output: Creates an index on the username column.
Pro tip: Use a CI/CD pipeline to run migrations automatically on deployment, but ensure only one process runs migrations at a time.
Handling Complex Migrations
Sometimes, you need more than simple CREATE
or ALTER
statements. For example, you might need to migrate data when adding a new column.
Scenario: Splitting Full Name into First/Last
Suppose your users
table has a full_name
column, and you want to split it into first_name
and last_name
. Create a migration:
migrate create -ext sql -dir migrations -seq split_user_names
Up Migration
202505310003_split_user_names.up.sql
:
ALTER TABLE users
ADD COLUMN first_name VARCHAR(50),
ADD COLUMN last_name VARCHAR(50);
UPDATE users
SET
first_name = SPLIT_PART(full_name, ' ', 1),
last_name = SPLIT_PART(full_name, ' ', 2)
WHERE full_name IS NOT NULL;
ALTER TABLE users
DROP COLUMN full_name;
-- Output: Adds first_name and last_name, populates them from full_name, then drops full_name.
Down Migration
202505310003_split_user_names.down.sql
:
ALTER TABLE users
ADD COLUMN full_name VARCHAR(100);
UPDATE users
SET full_name = CONCAT(first_name, ' ', last_name)
WHERE first_name IS NOT NULL OR last_name IS NOT NULL;
ALTER TABLE users
DROP COLUMN first_name,
DROP COLUMN last_name;
-- Output: Restores full_name, populates it from first_name and last_name, then drops them.
Note: This assumes names are space-separated. Real-world data might need more complex logic (e.g., handling missing spaces).
Best Practices for Smoother Migrations
Here’s a quick checklist to keep your migrations headache-free:
Best Practice | Details |
---|---|
Keep migrations small | One change per migration (e.g., add column, then index in separate files). |
Test both up and down | Run up and down locally to verify reversibility. |
Backup before production | Always back up your database before applying migrations in production. |
Use transactions when possible | Wrap complex migrations in BEGIN /COMMIT to ensure atomicity. |
Monitor migration performance | Large data migrations can be slow; test and optimize (e.g., batch updates). |
For advanced tips, check the golang-migrate docs.
What’s Next for Your Database
Using golang-migrate transforms schema management from a risky chore to a controlled, repeatable process. Start by setting up the CLI and library, writing small, focused migrations, and integrating them into your Go app. Test thoroughly in development, handle rollbacks gracefully, and follow best practices to keep your team in sync.
As your app grows, you’ll face more complex migrations—data transformations, index optimizations, or even database refactoring. Golang-migrate handles these with ease, letting you focus on building features. Experiment with it in a side project, and you’ll see how it simplifies schema evolution.
Got a tricky migration scenario? Drop a comment on Dev.to, and let’s figure it out together.
Top comments (0)