Database migrations are essential for maintaining and evolving database schemas over time in a controlled and systematic way.
As applications grow, databases need to adapt to changing requirements, and having a reliable migration system ensures these changes are applied consistently across all environments.
In the Go ecosystem, golang-migrate has emerged as a powerful and flexible tool for managing database migrations.
This article will guide you through the fundamentals of using golang-migrate to manage your database schema changes effectively.
You'll learn how to set up, create, and run migrations, as well as handle common migration scenarios like rollbacks and error recovery. By the end of this article, you'll have a solid foundation for implementing database migrations in your Go projects.
Understanding database migrations
Database migrations represent a sequence of changes applied to a database schema to transition it from one state to another. They enable developers to evolve database structures in a predictable, repeatable manner while preserving existing data.
The concept is similar to version control for your database schema. Instead of making manual changes directly to production databases (a risky and error-prone approach), migrations provide a systematic way to track and apply changes across development, testing, and production environments.
Using migrations offers several key advantages:
Version control for database schema
Each migration represents a specific change to your database schema, with a unique version identifier. This creates a clear history of how your database has evolved over time. When you track migrations alongside your application code in a version control system, you can easily understand when and why particular changes were made.
Rollback capabilities
One of the most valuable features of a proper migration system is the ability to undo changes. Each migration typically includes both "up" operations (to apply changes) and "down" operations (to revert them). If a newly deployed feature causes issues, you can roll back to a previous database state with confidence.
Automation and CI/CD integration
Migrations can be automated and integrated into your continuous integration and deployment pipelines. This ensures that database changes are applied consistently alongside code changes, reducing the risk of deployment failures due to schema mismatches.
Consistency across environments
Migration tools help maintain consistency across various environments, from development machines to staging and production servers. This eliminates the common problem of "it works on my machine" by ensuring everyone is working with the same database structure.
Setting up golang-migrate
Golang-migrate is a database migration tool written in Go that supports multiple database types. It provides both a CLI tool for manual operation and a library that can be embedded in your Go applications.
You can install the golang-migrate CLI tool using several methods. If you already have Go installed, you can use the following command regardless of the operating system:
Ensure to replace postgres with the appropriate database tag(s) for the
desired databases.
Alternatively, you can use the Docker image, which is particularly useful for CI/CD environments:
Once installed, create a dedicated directory to store your migration files. A
common practice is to use a path like ./database/migrations/ within your
project:
This directory will contain all your migration files, organized by version and operation type.
Creating migrations
Creating a new migration involves generating two SQL files: one for applying changes (up) and another for reverting them (down).
To create a new migration, use the create command:
This command creates two files:
./database/migrations/000001_create_users_table.up.sql./database/migrations/000001_create_users_table.down.sql
Let's break down the command:
-ext sql: Specifies the file extension (sql).-dir ./database/migrations: Specifies the directory for migration files.-seq: Generates sequential version numbers.create_users_table: The descriptive name for the migration.
The .up.sql file contains the statements needed to apply the change, while the
.down.sql file contains the statements to revert it. Both files start empty
and need to be populated with the appropriate SQL commands.
For example, to create a users table:
And the corresponding down migration to drop the table:
Let's add another migration to add a phone number column:
This creates:
./database/migrations/000002_add_phone_to_users.up.sql./database/migrations/000002_add_phone_to_users.down.sql
Now populate these files:
When writing migration scripts, follow these practices:
Keep migrations small and focused: Each migration should do one thing and do it well.
Make migrations idempotent when possible: Use conditionals like
IF NOT EXISTSto prevent errors if the migration is run multiple times.Always test migrations before applying them to production: Run the up and down migrations in a test environment to ensure they work as expected.
Include descriptive names: Use clear, descriptive names that indicate what the migration does.
Add comments: Document complex migrations with SQL comments to explain what's happening and why.
Running migrations with the migrate CLI
Once you've created your migration files, you need to run them against your database. To apply all pending migrations, run the command below after updating the connection string with the right credentials for your database:
You should see the following output:
To apply only a specific number of migrations, use:
The command above would apply just the next two pending migrations.
After running migrations, it's important to verify they were applied correctly.
One way is to check the schema_migrations table that golang-migrate creates
automatically:
This should show a list of all applied migrations with their version numbers:
You can also verify the actual table structure to ensure it matches your expectations:
Output might look like:
Managing migrations
Over time, you'll need to manage your migrations beyond just applying them. This includes checking status, rolling back, and handling errors.
To check which migrations have been applied and which are pending, run
This will output the current migration version of your database:
To roll back the most recent migration:
The number 1 indicates how many migrations to roll back. To roll back all
migrations, don't specify a number:
Note that rolling back all migrations will completely revert your database schema, which can lead to data loss. Use this command with extreme care, especially in production environments.
Handling migration errors
If a migration fails, golang-migrate marks the database as "dirty" to prevent further migrations until the issue is resolved. You might see an error like:
To resolve this:
- Fix the issue in your migration files.
- Determine the correct version your database should be at.
- Use the
forcecommand to set the version:
This tells golang-migrate that your database is at version 2, allowing you to continue with migrations.
Migration versioning strategies
Golang-migrate supports two versioning strategies:
Sequential versioning (with
-seqflag): Generates migration files with sequential version numbers like000001,000002, etc.Timestamp-based versioning (default): Uses Unix timestamps as version numbers, like
1611595022.
Sequential versioning makes it easier to understand the order of migrations at a glance, while timestamp-based versioning eliminates version conflicts when multiple developers create migrations simultaneously.
Integration with Go applications
While the CLI is useful for manual operations, you can also integrate migrations
directly into your Go applications using the golang-migrate package:
Note that you need to import the specific database driver you're using
(postgres in this example) and the source type (file in this example).
Automating migrations during application startup
A common pattern is to run migrations automatically when your application starts. This ensures your database schema is always up to date:
Using wrapper libraries
Several wrapper libraries make it easier to use golang-migrate in Go applications. One popular option is Goose, which offers a higher-level API:
CI/CD integration
Integrating migrations into your CI/CD pipeline helps ensure database changes are consistently applied across environments.
Here's a simple example of how you might integrate migrations into a GitHub Actions workflow:
Before deploying to production, it's important to test migrations in a CI environment:
This workflow sets up a PostgreSQL database service, applies all migrations, then rolls them back to ensure both operations work correctly.
Final thoughts
Database migrations are an essential part of modern application development, providing a structured way to evolve your database schema alongside your application code. Golang-migrate offers a robust and flexible solution for managing these migrations, whether you're working with a small project or a large enterprise application.
By implementing proper migration practices, you can ensure database changes are applied consistently across all environments, reduce the risk of errors and data loss, and maintain a clear history of how your database has evolved over time. When combined with CI/CD pipelines, migrations become a powerful tool for maintaining database integrity throughout your development process.
Remember that the best migrations are small, focused, and reversible. Take the time to plan your database changes carefully, and always test migrations thoroughly before applying them to production environments. With these principles in mind, you'll be well-equipped to manage database schema changes with confidence.