You have a Go project that compiles fine today, but the dependency graph is starting to show cracks. A library you depend on has tagged a new major version, your team wants to adopt workspaces, or the vendor directory is bloated with stale modules. Module migration in Go sounds simple—update the go.mod and run go mod tidy—but in practice, versioning traps can stall a migration for days. This guide is for developers and platform engineers who need to move from one module state to another without breaking the build, losing history, or introducing subtle runtime errors. We will walk through the decision framework, compare the main migration strategies, and highlight the traps that trip up experienced teams.
Who Must Choose and By When
Module migration is not a task you schedule for fun. It usually arrives because of an external trigger: a critical security patch in a dependency that requires a new major version, a team policy shift from vendor to proxy-based builds, or a monorepo restructure that splits a single module into multiple ones. The decision to migrate—and the timeline—depends on the nature of the trigger.
If the trigger is a security advisory, the clock is short. You need to update the affected module and its dependents, often within days. In that scenario, the migration scope is narrow: update one dependency, test, and release. The trap here is rushing: teams often run go get -u without checking transitive requirements, leading to unexpected downgrades or broken API calls. A better approach is to pin the new version explicitly, run go mod tidy to clean up, and then verify that all imports still compile.
If the trigger is a planned upgrade—say, moving from Go 1.16 to 1.21 and adopting workspaces—you have more time, but the scope is broader. You might need to restructure module paths, update internal imports, and coordinate across multiple repositories. The trap here is scope creep: teams try to refactor code and migrate modules simultaneously. Keep the migration focused on module structure only; defer code refactoring to a separate phase.
Another common trigger is the need to split a monolith module into smaller, independently versioned modules. This often happens when a project outgrows its original boundaries—for example, a shared utility library that now has separate CLI and API subprojects. The decision point is when the cost of releasing the entire monolith for a small change outweighs the overhead of managing multiple modules. The trap is under-planning the import path changes: every internal package that moves to a new module must update its import paths, and all downstream consumers must be notified. We recommend creating a migration branch, updating all imports in one commit, and tagging the new modules before merging.
Finally, there is the trigger of deprecation: Go itself phases out older module behaviors (for instance, the GO111MODULE environment variable defaults changing). Teams still using GOPATH mode or very old module conventions need to migrate before the toolchain removes support. The decision here is not if but when. The trap is delaying until the last moment, then discovering that some dependencies are incompatible with the new module mode. Start with a trial migration in a CI environment to identify blockers early.
In summary, the urgency of the migration determines your approach. For security patches, act fast but verify each step. For planned upgrades, take time to plan the import path changes and test incrementally. For monolith splits, coordinate with downstream consumers. And for deprecation-driven migrations, start early to avoid a mad rush.
Option Landscape: Three Approaches to Module Migration
There is no single correct way to migrate Go modules. The best approach depends on your project's size, team workflow, and infrastructure constraints. We will cover three common strategies: the vendor directory approach, the proxy mirroring approach, and the multi-module workspace approach. Each has trade-offs in reproducibility, speed, and complexity.
Vendor Directory Approach
This is the oldest and most straightforward method. You run go mod vendor to copy all dependencies into a vendor directory inside your project, then commit that directory to version control. The migration step is simple: update go.mod with the new dependency versions, run go mod vendor again, and commit the updated vendor folder. This approach gives you complete control over the exact code that gets built, because the vendor directory is part of your repository.
The trap here is that the vendor directory can balloon in size, especially if you have many transitive dependencies. Also, if you forget to run go mod vendor after updating go.mod, the build will use the old vendor code, leading to subtle mismatches. We recommend adding a CI check that verifies the vendor directory is in sync with go.mod and go.sum.
Proxy Mirroring Approach
Instead of vendoring, you rely on a Go module proxy (like the official proxy.golang.org or a private corporate proxy) to serve dependencies. The migration involves updating go.mod and then running go mod download to cache the new versions locally. The proxy ensures that all developers and CI systems get the same module versions, as long as the proxy is configured to serve the same set of modules.
The trap here is that if the proxy goes down or is misconfigured, builds can fail. Also, if you use a public proxy, you may accidentally expose private module paths. The solution is to set up a private proxy that caches both public and private modules, and configure GOPROXY accordingly. Another pitfall is that the proxy might serve a cached version that is not the one you intended; always verify the go.sum checksums.
Multi-Module Workspace Approach
Introduced in Go 1.18, workspaces allow you to work on multiple modules simultaneously without publishing them. You create a go.work file that lists the local module paths, and the toolchain resolves dependencies across those modules as if they were part of a single build. This is ideal for monorepos or when you are migrating a set of interdependent modules.
The trap is that workspaces can mask version mismatches that will appear when you publish the modules separately. For example, module A might depend on module B v1.2.0 in the workspace, but when B is published as v1.3.0, A's go.mod still references v1.2.0. Always test the build without the workspace (using GOWORK=off) before releasing. Another common mistake is forgetting to commit the go.work file; it should be committed if all developers need the same workspace layout, but omitted from CI if the build should use published versions.
Each of these approaches can be combined. For instance, you might use workspaces during development and then vendor for deployment. The key is to choose the combination that minimizes surprises during the migration.
Comparison Criteria Readers Should Use
When evaluating which migration strategy to adopt, you need a consistent set of criteria. We have identified four dimensions that matter most in practice: reproducibility, speed, complexity, and debuggability.
Reproducibility measures how likely it is that the same build will produce the same binary across different environments. Vendor directories score high because the exact source code is committed. Proxy mirrors score medium: as long as the proxy serves the same versions, reproducibility is good, but if the proxy changes or goes offline, builds may differ. Workspaces score low for reproducibility in published builds because the workspace file is often not used in CI; developers may have different local module versions.
Speed refers to the time it takes to restore dependencies in a fresh checkout. Vendor directories are fast because no download is needed—the code is already in the repo. Proxy mirrors are slower because each dependency must be downloaded, though caching helps. Workspaces are fast for local development because modules are on disk, but CI builds that do not use workspaces may need to download them.
Complexity is the learning curve and maintenance overhead. Vendor directories are conceptually simple but can lead to large repos and merge conflicts. Proxy mirrors require infrastructure setup (private proxy) and configuration. Workspaces introduce a new file (go.work) and can confuse developers unfamiliar with the feature.
Debuggability is how easy it is to step into dependency code during development. Vendor directories allow you to modify vendor code directly (though you should not commit those changes). Proxy mirrors require downloading the source and may not include all files. Workspaces let you work on the dependency source directly if it is part of the workspace, which is a major advantage for active development.
We recommend scoring each criterion on a scale of 1 to 5 for your specific context. For example, if your team values reproducibility above all else (e.g., for compliance), vendor is the clear winner. If you have a fast CI and a reliable proxy, proxy mirroring may be the best balance. If you are actively developing multiple interdependent modules, workspaces are hard to beat.
A common mistake is to choose a strategy based on a single criterion (e.g., speed) without considering the others. We have seen teams adopt proxy mirroring for speed, only to discover that their CI cannot access the proxy, or that the proxy caches stale versions. Always run a trial migration with the chosen strategy on a non-critical branch before committing.
Trade-Offs Table: Vendor vs. Proxy vs. Workspace
The following table summarizes the key trade-offs across the three approaches. Use it as a quick reference when discussing migration strategy with your team.
| Criterion | Vendor Directory | Proxy Mirror | Workspace |
|---|---|---|---|
| Reproducibility | High (exact code committed) | Medium (depends on proxy) | Low (workspace may not be in CI) |
| Speed (fresh checkout) | Fast (no download) | Slow (download required) | Fast (local modules) / Slow (CI without workspace) |
| Complexity | Low (simple concept) | Medium (infrastructure needed) | Medium (new file, new behavior) |
| Debuggability | Medium (can edit vendor but not recommended) | Low (source may not be local) | High (direct access to dependency source) |
| Repo size | Large (vendor committed) | Small (only go.mod/go.sum) | Small (only go.work) |
| Best for | Compliance, offline builds, small teams | Large teams, CI-heavy workflows | Active development of multiple modules |
The table makes it clear that no single approach dominates. For example, if you are a small team building a compliance-critical application, vendor is your best bet. If you are a large team with a dedicated platform team that can maintain a private proxy, proxy mirroring offers a good balance. If you are developing a set of libraries that evolve together, workspaces will save you from publishing intermediate versions.
A common trap is to assume that workspaces are always better because they are newer. Workspaces are powerful, but they add a layer of indirection that can confuse less experienced developers. We have seen cases where a developer accidentally committed a go.work that pointed to their local machine's absolute path, breaking the build for everyone else. Always use relative paths in go.work and consider adding it to .gitignore if the workspace layout is personal.
Another trap is mixing strategies inconsistently. For example, some teams vendor in production but use proxy mirroring in CI, and the vendor directory drifts out of sync. If you vendor, commit the vendor directory and use it consistently across all environments. If you use a proxy, ensure that all environments (including developer machines) use the same proxy configuration.
Implementation Path After the Choice
Once you have selected a migration strategy, the implementation follows a predictable sequence. We outline the steps here, assuming you are migrating from one module state to another (e.g., updating a dependency or splitting a module).
Step 1: Audit the Current State
Start by examining your go.mod and go.sum. Run go mod verify to ensure the current dependencies are intact. Note any replace or exclude directives, as they will affect the migration. Also check for any indirect dependencies that are no longer needed—these will be cleaned up later.
Step 2: Create a Migration Branch
Work in a dedicated branch to avoid disrupting the main development line. This also allows you to iterate on the migration without pressure. Name the branch something like migrate/upgrade-logrus-v2 so that the intent is clear.
Step 3: Update go.mod
If you are updating a single dependency, use go get example.com/[email protected]. If you are restructuring modules, edit go.mod directly to add new module paths or remove old ones. Be careful with major version bumps: Go requires the module path to include the major version suffix (e.g., /v2). Forgetting this is a common trap—the build will fail with a confusing error about incompatible module paths.
Step 4: Run go mod tidy
This command cleans up the go.mod and go.sum files, removing unused dependencies and adding missing ones. It also verifies that the module graph is consistent. If you get errors about missing or ambiguous imports, resolve them before proceeding. Do not skip this step; it is the safety net of module migration.
Step 5: Update Import Paths in Code
Use gofmt or a simple sed command to update import paths across your codebase. For example, if you moved a package from example.com/old/pkg to example.com/new/pkg, replace all occurrences. Be thorough: missed imports will cause compilation errors that are easy to fix but time-consuming to track down.
Step 6: Build and Test
Run go build ./... and go test ./... to verify that everything compiles and passes. Pay special attention to tests that use go:generate or that depend on specific module versions. If you are using workspaces, also test with GOWORK=off to simulate the published module graph.
Step 7: Commit and Tag
Once the migration is verified, commit the changes. If you are publishing a new major version, tag it with the appropriate version (e.g., v2.0.0). Ensure the tag is on the correct commit and that it follows semantic versioning. A common trap is tagging a commit that does not include the updated go.mod, which will cause downstream users to fetch the wrong code.
Step 8: Update CI and Documentation
If your migration changes the build process (e.g., switching from vendor to proxy), update your CI configuration accordingly. Also update any developer documentation that describes how to set up the project. This step is often overlooked, leading to confusion when new team members join.
Throughout this process, communicate with your team. A migration that touches many files can cause merge conflicts. Coordinate a window where the migration branch is merged and everyone rebases their work.
Risks If You Choose Wrong or Skip Steps
Module migration mistakes can have cascading effects. Here are the most common risks and how they manifest.
Diamond Dependency Conflicts
This occurs when two dependencies require different versions of the same transitive module. For example, module A requires lib v1.2.0 and module B requires lib v1.3.0. Go's module resolution will try to find a common compatible version, but if the major versions differ, it will fail. The risk is that you might not notice the conflict until a downstream consumer tries to build. To mitigate, use go mod graph to visualize the dependency tree before migrating, and consider using replace directives to force a version, though this can cause other issues.
Accidental Downgrades
Running go get -u without specifying versions can downgrade some dependencies if the toolchain finds a version that satisfies all constraints. This is especially dangerous when migrating to a new major version of a dependency: the toolchain might choose an older compatible version instead of the new major version. Always specify the exact version or use go get example.com/[email protected] to force the upgrade.
Stale Cache Issues
The Go module cache ($GOPATH/pkg/mod) can store old versions that interfere with the migration. If you update go.mod but the cache still has the old version, the build might use the cached version instead of the new one. The fix is to clear the cache with go clean -modcache and then download the new dependencies. This is a common trap in CI systems where the cache persists across builds.
Import Path Mismatches
When you change a module path (e.g., from example.com/old to example.com/new/v2), every import statement must be updated. If you miss one, the build fails with a confusing error about an undefined package. The risk is that you might fix the build but leave a stale import that compiles accidentally because the old module path still exists. Always run a full build and test suite to catch these.
Broken go.sum Integrity
If you manually edit go.sum or use go mod tidy incorrectly, the checksums may become invalid. This will cause go mod verify to fail, and downstream users will get errors about mismatched hashes. The solution is to always let the toolchain manage go.sum and never edit it by hand. If you need to recover, delete go.sum and run go mod tidy to regenerate it.
To minimize these risks, we recommend a phased rollout: first migrate a non-critical module, test thoroughly, and then apply the same pattern to the rest of the project. Also, maintain a rollback plan: keep the old go.mod and go.sum files in a backup branch, and be prepared to revert if the migration causes production issues.
Mini-FAQ
Q: How do I handle private modules during migration?
A: Private modules require authentication. Set the GOPRIVATE environment variable to your private module prefix (e.g., GOPRIVATE=github.com/mycompany/*). This tells the Go toolchain to skip the proxy and fetch directly from the source. During migration, ensure all developers have the correct authentication tokens (e.g., SSH keys or personal access tokens) configured. A common trap is that go mod tidy fails because it cannot access a private module; check your .netrc or ~/.gitconfig for credentials.
Q: When should I use replace directives in go.mod?
A: Use replace directives to temporarily substitute a module with a local copy or a different version. This is useful during development when you want to test a change in a dependency without publishing it. However, avoid committing replace directives that point to local paths, as they will break other developers' builds. Use replace sparingly and only for short-lived experiments. For long-term overrides, consider forking the dependency and changing the module path.
Q: What is the safest way to update a major version of a dependency?
A: The safest approach is to first check the dependency's release notes for breaking changes. Then, update the module path in go.mod to include the major version suffix (e.g., /v2). Run go get example.com/lib/v2@latest to fetch the new version. Update all import statements in your code to use the new path. Finally, run the full test suite. If the dependency has a migration guide, follow it closely. Avoid mixing old and new major versions in the same module, as this can cause conflicts.
Q: How do I migrate a monorepo with multiple modules?
A: For a monorepo, workspaces are the recommended approach. Create a go.work file at the root that lists all module directories. Then, update each module's go.mod independently. Use the workspace to test cross-module changes before publishing. When you are ready to publish, tag each module separately. A common trap is forgetting to update the go.mod of dependent modules within the monorepo; use go mod tidy in each module to ensure consistency.
Q: What should I do if go mod tidy removes a dependency I need?
A: go mod tidy removes dependencies that are not used in any import statement. If a dependency is removed, it means your code does not import it directly or indirectly. If you need it for a side effect (e.g., an init function), add a blank import (import _ "example.com/lib") in a file that is always compiled. Alternatively, add a require directive manually in go.mod and run go mod tidy again—the toolchain will keep it if it is explicitly required.
Recommendation Recap Without Hype
Module migration in Go is a mechanical process, but the traps are in the details. Based on the patterns we have seen across teams, here are the specific next moves you should take after reading this guide:
- Audit your current go.mod. Run
go mod verifyandgo mod graphto understand your dependency tree. Identify any replace directives, indirect dependencies, and potential conflicts. - Choose a migration strategy based on your criteria. Use the comparison table in this guide to score reproducibility, speed, complexity, and debuggability for your context. Do not default to the approach you used last time; reassess each migration.
- Set a migration window. Coordinate with your team to minimize disruptions. For complex migrations, allocate at least a week for testing and rollback.
- Create a migration branch and follow the implementation steps. Do not skip
go mod tidyor the full test suite. If you encounter errors, resolve them in the branch, not in the mainline. - Validate with a staging build. Before merging to production, run the build in an environment that mirrors production. Verify that the binary behaves as expected, especially if the migration changed dependency versions.
- Document the migration. Write down what changed, why, and any manual steps that were required. This will help future migrations and onboarding.
Remember that no migration is risk-free, but the risks are manageable if you follow a structured process. Avoid the temptation to rush or to combine a module migration with a code refactoring. Keep the scope narrow, test thoroughly, and communicate with your team. The goal is not to have a perfect migration on the first try, but to have a predictable one that you can revert if needed.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!