Skip to main content
Real-World Go Migrations

Pistach.top’s Advanced Go Migration Techniques: Three Real-World Community Stories

Database migrations are a routine part of Go backend development—until they aren't. The moment your application handles real traffic with strict uptime requirements, a simple ALTER TABLE can become a production incident. This guide distills patterns from three composite community stories, each highlighting a distinct challenge: zero-downtime schema changes, large-scale data backfills, and robust rollback strategies. We'll explore the core mechanisms that make migrations safe, compare popular tools, and walk through actionable workflows. Why Migrations Fail in Production Migrations that work flawlessly in staging often break in production because of concurrency, locking, and data volume. One team we encountered added a non-null column with a default value on a table with 50 million rows. The ALTER TABLE locked the table for over an hour, causing a full outage. The root cause was a missing understanding of MySQL's online DDL limitations.

Database migrations are a routine part of Go backend development—until they aren't. The moment your application handles real traffic with strict uptime requirements, a simple ALTER TABLE can become a production incident. This guide distills patterns from three composite community stories, each highlighting a distinct challenge: zero-downtime schema changes, large-scale data backfills, and robust rollback strategies. We'll explore the core mechanisms that make migrations safe, compare popular tools, and walk through actionable workflows.

Why Migrations Fail in Production

Migrations that work flawlessly in staging often break in production because of concurrency, locking, and data volume. One team we encountered added a non-null column with a default value on a table with 50 million rows. The ALTER TABLE locked the table for over an hour, causing a full outage. The root cause was a missing understanding of MySQL's online DDL limitations. In PostgreSQL, adding a column with a default value in older versions also rewrites the entire table. These details matter.

The Locking Trap

Most databases acquire a schema lock during DDL statements. In MySQL, even ALTER TABLE ... ALGORITHM=INPLACE can block concurrent DML in certain cases. The common mistake is assuming all changes are online. For example, adding a foreign key constraint in MySQL requires a shared lock that blocks writes. The fix is to use tools like pt-online-schema-change or gh-ost, but integrating them into a Go migration pipeline adds complexity. Another team used a two-phase approach: first add the column as nullable, backfill data in batches, then add the NOT NULL constraint. This avoided long locks but required careful coordination.

Data Volume Surprises

Backfilling 10 million rows with a default value can overwhelm the database if done in a single transaction. One story involved a migration that used UPDATE ... WHERE id BETWEEN ? AND ? with a batch size of 1000. The migration ran for hours because the batch size was too small and the transaction log grew excessively. The solution was to use a cursor-based approach with FOR UPDATE SKIP LOCKED to avoid contention. Another team used PostgreSQL's pg_batch extension, but that required additional infrastructure. The key is to test backfill performance on a production-sized copy.

Core Migration Frameworks and How They Work

Three popular Go migration tools dominate the ecosystem: golang-migrate/migrate, pressly/goose, and a custom approach using GORM AutoMigrate with manual versioning. Each has different trade-offs for locking, rollback, and schema evolution.

golang-migrate/migrate

This tool uses a version-based file system with up/down SQL files. It supports multiple databases and has a built-in locking mechanism using advisory locks (PostgreSQL) or a lock table (MySQL). However, the locking is per-migration, not per-statement. If a migration contains multiple statements and one fails, the tool does not automatically roll back the entire migration—it marks it as dirty. Teams often forget to implement a manual cleanup. One composite team learned this the hard way when a migration that added two columns succeeded on the first column but failed on the second due to a syntax error. The database was left in an inconsistent state.

pressly/goose

Goose also uses versioned SQL files but supports embedding migrations in the binary. It provides a goose up-by-one command for gradual rollouts. A key difference is that goose does not lock the database by default; it relies on the user to handle concurrency. This can be dangerous in CI/CD pipelines where multiple instances might run migrations simultaneously. One team solved this by wrapping goose in a Kubernetes init container with a lease-based lock using a ConfigMap. Another pattern is to run migrations as a separate job before scaling the application.

Custom GORM Approach

Some teams prefer to use GORM's AutoMigrate for schema changes and write custom backfill scripts. This gives more control but requires manual version tracking. A common failure mode is forgetting to add a new column's index in the same deployment. One team used a migration table with a version column and a status enum (pending, running, done, failed). Each migration is a Go function that checks the current version and applies changes. This approach allowed them to add retry logic and skip already-applied steps. However, it increased code complexity and required careful testing.

Execution Workflows for Zero-Downtime Migrations

Regardless of the framework, the execution pattern for zero-downtime migrations follows a few proven steps. We'll outline a repeatable process that minimizes risk.

Step 1: Expand-Contract Pattern

For non-backward-compatible changes (e.g., renaming a column), use the expand-contract pattern. First, add the new column and write to both old and new columns in the application code. Deploy this change. Then, backfill historical data. Next, stop writing to the old column and remove reads from it. Finally, drop the old column in a separate migration. This pattern avoids downtime but requires multiple deployments and careful code coordination. One team used feature flags to toggle between old and new code paths during the transition.

Step 2: Batch Backfill with Progress Tracking

When backfilling millions of rows, use a cursor-based approach with a small batch size (e.g., 1000 rows). Track progress in a separate table to allow resumption on failure. For example, create a migration_progress table with columns table_name, last_processed_id, and batch_size. The migration script reads the last processed ID, processes the next batch, updates the progress, and commits. This prevents long-running transactions and allows pausing. One story involved a backfill that took three days; the team used this method and was able to stop and resume after a database failover.

Step 3: Test Rollbacks on a Clone

Before running a migration in production, test the rollback on a production clone. This is often skipped because clones are expensive, but the cost of a failed migration is higher. Use tools like pg_dump or cloud snapshot services to create a read replica, run the migration, then run the down migration. Verify that the schema and data match the original. One team found that their down migration dropped a column that was still referenced by a view, causing subsequent queries to fail. Testing caught this.

Tools, Stack, and Maintenance Realities

Choosing the right migration tool depends on your database, deployment model, and team expertise. Below is a comparison of the three main approaches.

ToolLockingRollbackBest ForCommon Pitfall
golang-migrateAdvisory lock / lock tableDown files (manual)Simple versioning, CI/CDDirty state on partial failure
gooseNone by defaultDown files (manual)Embedded migrations, gradual rolloutConcurrent runs without lock
Custom GORMApplication-levelCustom Go functionsComplex business logic within migrationIncreased code complexity

Infrastructure Considerations

Migrations often run in CI/CD pipelines. If your database is behind a private network, ensure the runner has network access. Use a dedicated migration job that runs before the application starts. One team used a Kubernetes init container that ran goose and exited; if it failed, the pod would not start. This prevented the application from running with an outdated schema. Another team used AWS Lambda to run migrations, but cold starts caused timeouts on large backfills. They switched to ECS tasks with a longer timeout.

Versioning and File Organization

Name migration files with a timestamp or sequential number and a description (e.g., 20250601120000_add_users_status.up.sql). Avoid using only a number because merge conflicts become harder. Keep up and down files in the same directory. Some teams store migrations in a separate repository to enforce review processes. This works but adds overhead when coordinating with application releases.

Growth Mechanics: Scaling Migrations with Traffic

As your application grows, migration strategies must evolve. What works for 100 requests per second may break at 10,000 RPS. We'll explore how teams adapt.

Shadow Writes and Canary Deployments

Before applying a schema change that modifies write patterns, use shadow writes: write to both old and new columns, but only read from the old one. Monitor for errors or performance degradation. This is common when adding an index or changing a column type. One team added a jsonb column alongside an existing text column to test a new query pattern. After two weeks of shadow writes, they switched reads and dropped the old column. This required application changes but gave confidence.

Blue-Green Database Deployments

For critical systems, some teams use blue-green database deployments: maintain two separate database instances, run migrations on the standby, then switch traffic. This is expensive but eliminates downtime. Tools like pglogical or MySQL Group Replication can keep the standby in sync. One story involved a financial services company that used this pattern for quarterly schema changes. They automated the switchover with a health check that verified data consistency.

Load Testing Migrations

Before a major migration, simulate production traffic on a staging environment with similar data volume. Use tools like k6 or locust to generate load while the migration runs. Measure query latency and error rates. One team discovered that adding a foreign key constraint caused a 30% increase in write latency because of the index overhead. They decided to add the constraint during a maintenance window instead.

Risks, Pitfalls, and Mitigations

Even with careful planning, migrations can go wrong. Here are common failure modes and how to avoid them.

Missing Transaction Boundaries

Some databases do not support DDL in transactions (e.g., MySQL with certain storage engines). If a migration contains multiple statements and one fails, the database may be left in an inconsistent state. Mitigation: use a migration tool that wraps each migration in a transaction where possible, and test failure scenarios. For MySQL, use START TRANSACTION only for DML; for DDL, rely on the tool's dirty state handling and manual cleanup.

Ignoring Context Deadlines

Go's context.Context is often passed to database operations, but migration scripts sometimes ignore it. If a migration takes too long, the context may cancel mid-operation, leaving partial changes. Mitigation: set a reasonable timeout (e.g., 30 minutes) and break long migrations into smaller steps with progress tracking. One team used a context with a timeout and a goroutine that logs progress every minute.

Forgetting to Test Rollbacks

Down migrations are often written but never tested. When a rollback is needed, the down migration may fail due to data dependencies (e.g., a column is referenced by a view). Mitigation: test down migrations on a staging environment with realistic data. Automate this in CI by running up, then down, then verifying schema.

Data Corruption During Rollback

If a migration adds a column and populates it, the down migration should drop the column. But if the down migration is run while the application is still writing to that column, data loss occurs. Mitigation: coordinate rollbacks with code deployments. Use feature flags to ensure the application no longer references the column before dropping it.

Mini-FAQ: Common Migration Questions

How do I handle enum changes in PostgreSQL?

PostgreSQL does not support removing values from an enum without dropping and recreating the column. The safest approach is to add new values using ALTER TYPE ... ADD VALUE (which is transactional), and avoid removing old values until you are sure no data references them. To remove a value, create a new enum type, update all columns, and drop the old type. This requires a migration with a USING clause.

When should I use shadow writes instead of expand-contract?

Shadow writes are useful when you are unsure about the performance impact of a new column or index. Expand-contract is better for structural changes like renaming columns. Use shadow writes when the change is additive and you want to monitor for regressions. Use expand-contract when the change is destructive (e.g., dropping a column).

Should I version migration files by timestamp or sequence number?

Timestamps avoid merge conflicts when multiple developers add migrations concurrently. Sequence numbers (e.g., 001, 002) are simpler but cause conflicts in large teams. Most tools support both. We recommend timestamps for teams of more than five developers.

Can I run migrations in parallel with application requests?

Yes, but only for additive changes (e.g., adding a column, creating an index). Avoid running destructive changes (e.g., dropping a column) while the application is running. Use online DDL tools or the expand-contract pattern.

Synthesis and Next Actions

Successful Go migrations in production rely on understanding the database's locking behavior, choosing the right tool for your scale, and testing rollbacks as thoroughly as forward migrations. We have covered three real-world stories that highlight common pitfalls: long locks, backfill performance, and rollback failures. The key takeaways are: always test on a production clone, use batch processing for large data changes, and prefer additive patterns over in-place modifications.

Immediate Steps to Improve Your Migration Pipeline

  1. Audit your current migration tool for locking behavior and dirty-state handling.
  2. Add a CI step that runs migrations and rollbacks on a staging database with realistic data volume.
  3. Implement progress tracking for any backfill that affects more than 100,000 rows.
  4. Review your rollback scripts for data dependencies (views, triggers, foreign keys).
  5. Consider using the expand-contract pattern for non-backward-compatible changes.

Migrations are a necessary part of evolving a Go service. By applying these techniques, you can reduce downtime and avoid data loss. Remember that each database engine has quirks; consult the official documentation for your specific version.

About the Author

Prepared by the editorial team at Pistach.top. This guide synthesizes patterns observed in community forums, open-source projects, and practitioner reports. It is intended for Go developers who manage production databases and seek to improve their migration reliability. The advice here is general in nature; always verify against your database vendor's official guidance for your specific version. Last reviewed: June 2026.

Share this article:

Comments (0)

No comments yet. Be the first to comment!