Skip to main content
Go Module Migration Traps

5 Go Module Migration Pitfalls and How to Dodge Them

Go modules changed how the ecosystem manages dependencies, but the migration path from older approaches is riddled with subtle traps. Teams often assume that running go mod init and go mod tidy is enough, only to discover that their import paths no longer resolve, their go.sum file is missing entries, or their CI pipeline suddenly fails on a fresh checkout. This guide identifies five specific pitfalls that repeatedly catch developers during Go module migration and explains how to avoid each one. Whether you're moving a monorepo with dozens of internal packages or a small CLI tool that depends on a handful of public libraries, the same classes of problems emerge. We'll walk through each pitfall with a realistic scenario, the underlying cause, and a concrete fix. Along the way, we'll also compare migration strategies, provide decision criteria, and offer a step-by-step implementation path so you can migrate with confidence. 1.

Go modules changed how the ecosystem manages dependencies, but the migration path from older approaches is riddled with subtle traps. Teams often assume that running go mod init and go mod tidy is enough, only to discover that their import paths no longer resolve, their go.sum file is missing entries, or their CI pipeline suddenly fails on a fresh checkout. This guide identifies five specific pitfalls that repeatedly catch developers during Go module migration and explains how to avoid each one.

Whether you're moving a monorepo with dozens of internal packages or a small CLI tool that depends on a handful of public libraries, the same classes of problems emerge. We'll walk through each pitfall with a realistic scenario, the underlying cause, and a concrete fix. Along the way, we'll also compare migration strategies, provide decision criteria, and offer a step-by-step implementation path so you can migrate with confidence.

1. The Import Path Mismatch Trap

The first pitfall strikes the moment you run go mod init <module-path>. If the module path you choose does not match the import paths used in your source files, the module system will refuse to resolve internal dependencies. This is especially common in projects that were originally structured with a GOPATH-based layout where imports used the full repository path (for example, github.com/user/project/pkg), but the team decides to use a shorter module path (like myproject) for local development.

The mismatch causes go build to fail with errors like could not import github.com/user/project/pkg (no required module provides package ...). Another variant occurs when a module path contains a major version suffix (for example, /v2) but the source files still import the package without the suffix. The Go module system expects the major version to be part of the module path for versions 2 and above.

How to dodge it

Before running go mod init, decide on a canonical module path that matches the root import path used by external consumers. If your code is hosted at github.com/team/tool, set the module path to github.com/team/tool. For internal projects, use a path that reflects where your build system will fetch the module (even if it's a private registry). Then, update all internal import statements to use that path as the prefix. Tools like gorename or a simple sed command can automate the replacement. Finally, run go mod tidy to let the module system verify that every import resolves correctly.

Common variation: version suffix

If you are releasing a v2+ version of your module, the module path must include the major version suffix (e.g., github.com/team/tool/v2). All import statements in the source files must also use that suffix. Forgetting this is one of the most frequent causes of build failures after migration.

2. The Indirect Dependency Bloat

After initializing modules and running go mod tidy, you might notice that your go.mod file lists many indirect dependencies—packages that your direct dependencies import but your code never uses directly. This is normal, but the bloat becomes a pitfall when those indirect dependencies introduce conflicts or when the go.sum file grows to include hundreds of entries for transitive dependencies that are never actually needed at runtime.

The real trap is that go mod tidy is conservative: it keeps any transitive dependency that could be required by any build tag or platform combination. If your project uses build tags for different operating systems, the indirect dependency list can balloon. In one composite scenario, a team migrated a CLI tool that used cgo on Linux but not on Windows. The indirect dependencies included Windows-specific DLL bindings that were never compiled, yet they were still recorded in go.sum and fetched during go mod download on every CI run, slowing down builds by 30%.

How to dodge it

Use go mod tidy -e (experimental in Go 1.21+) to remove unused dependencies even across build tags, but test thoroughly because it might remove a dependency that is needed for a less common platform. Alternatively, run go mod tidy only for the specific build tag combination you actually use. For example, on Linux, run GOOS=linux GOARCH=amd64 go mod tidy. On Windows, run the equivalent. Then merge the resulting go.mod and go.sum files carefully, keeping the union of all needed dependencies. Another approach is to review the indirect dependencies manually and use the // indirect comment to mark them, but this is tedious for large dependency graphs.

3. The Version Resolution Deadlock

When two of your direct dependencies require different versions of the same transitive dependency, Go's minimal version selection (MVS) algorithm picks the lowest version that satisfies all constraints—but this can lead to a deadlock if that version is incompatible with one of the dependencies. The classic case is when dependency A requires lib v1.0 and dependency B requires lib v1.2, but lib v1.1 introduced a breaking change that A cannot handle. MVS selects v1.2 (the highest required), and A breaks. Conversely, if A requires >= v1.0 and B requires >= v1.2, MVS selects v1.0 (the lowest that satisfies both), and B breaks because it needs features from v1.2.

The deadlock becomes especially painful when one of the dependencies is a popular library that is slow to update. Teams often end up stuck on an old version of a framework because a transitive dependency hasn't been updated to support the newer version.

How to dodge it

First, use go mod graph to visualize the dependency tree and identify the conflicting requirements. Then consider one of these strategies:

  • Upgrade the offending dependency: Check if a newer version of the dependency that requires the older transitive version exists. Often the maintainer has already fixed the issue.
  • Use a replace directive: In your go.mod, you can force a specific version of the conflicting transitive dependency using replace. For example, replace lib => lib v1.2 overrides MVS and uses v1.2. This is a workaround, not a solution, and should be documented.
  • Vendor the conflicting dependency: If the conflict is unresolvable, consider vendoring the transitive dependency and patching it manually. This is a last resort because it increases maintenance burden.

Avoid the pitfall altogether by keeping your direct dependencies up to date and monitoring their transitive dependencies. Tools like dependabot or renovate can automate version bumping for Go modules.

4. The go.sum Integrity Crisis

The go.sum file is a critical security mechanism: it records the expected cryptographic hashes of each module version. If the file becomes out of sync—for example, after a git merge conflict or when two developers update dependencies independently—the module system will refuse to build, citing a checksum mismatch. This often happens in team environments where developers run go mod tidy on different branches and then merge, causing conflicting go.sum entries.

The crisis escalates when the go.sum file is missing entries for a dependency that is required for a specific build tag. For instance, a developer on macOS may generate a go.sum that lacks entries for Linux-specific dependencies. When the CI server (running Linux) tries to build, it fails because the checksums for the Linux binaries are missing.

How to dodge it

Standardize your team's workflow:

  • Always run go mod tidy on the same platform as your CI server, or run it on multiple platforms and merge the go.sum files. There are tools like modsum that can merge go.sum files automatically.
  • Add a CI step that runs go mod verify to detect any inconsistency between go.mod and go.sum before merging.
  • Use a pre-commit hook that runs go mod tidy and checks that the go.sum file is unchanged if no go.mod changes were made. This prevents accidental modifications.

If a merge conflict occurs in go.sum, the safest resolution is to delete the conflicted file and run go mod tidy again from scratch. This regenerates the correct go.sum based on the merged go.mod.

5. The Multi-Module Workspace Nightmare

When a repository contains multiple Go modules (for example, a main application and a utility library in a subdirectory), the migration becomes more complex. The pitfall is that developers often try to use relative import paths between modules, which Go modules do not support. Instead, each module must be referenced by its module path, and the go.mod of the main module must include a replace directive to point to the local path of the sibling module.

The nightmare deepens when different modules have conflicting dependency versions or when one module is updated but the others are not. For instance, the main module might use logrus v1.8, while the utility library uses logrus v1.6. MVS can resolve this within a single module, but across modules in the same repository, you may end up with two copies of logrus in the binary if the versions are different major versions.

How to dodge it

Decide on a workspace strategy early:

  • Use Go workspaces (Go 1.18+): Create a go.work file in the root of your repository that lists all modules. This allows you to develop locally without replace directives and ensures that all modules share a single dependency graph during development. For CI, you can commit the go.work file or generate it on the fly.
  • Use replace directives consistently: If you cannot use workspaces (e.g., older Go version), add replace directives in each go.mod that points to the local paths of sibling modules. This is tedious to maintain but works.
  • Consider merging modules: If the modules are tightly coupled, it may be simpler to merge them into a single module. This reduces complexity at the cost of a larger module path.

The key is to avoid mixing relative imports with module paths. Once you define the module boundaries, stick to the module path-based imports everywhere.

6. Risks of Skipping Validation After Migration

Even after you successfully run go mod tidy and go build, the migration is not complete. Skipping validation steps can lead to subtle issues that surface weeks later. One risk is that the go.mod file may contain dependencies that are no longer needed but were not removed because go mod tidy was run on a subset of build tags. Another risk is that the module path is incorrect for external consumers—if other teams import your module, they will get a build error because the module path does not match the repository URL.

There is also the risk of version mismatch in private registries. If your CI system uses a private Go module proxy (like Athens or Artifactory), the proxy may cache an older version of a dependency that conflicts with the version in your go.mod. This can cause intermittent build failures that are hard to reproduce locally.

How to dodge it

Implement a validation checklist as part of your migration process:

  • Run go mod verify to check that the go.sum file matches the downloaded modules.
  • Run go list -m all to inspect the final dependency list and verify that no unexpected versions are included.
  • Run tests on multiple platforms (Linux, macOS, Windows) if your project is cross-platform.
  • Check that the module path is correct for external consumers by running go list -m from a separate directory that imports your module.
  • Clear the module proxy cache (if using one) and rebuild from scratch to ensure that the CI environment can fetch all dependencies correctly.

Document these steps in your team's migration guide so that every developer follows the same process.

7. Mini-FAQ: Common Questions About Go Module Migration

What if a dependency does not have a go.mod file?

Go modules can still use legacy dependencies without a go.mod. The module system treats them as if they have a go.mod that declares no dependencies. However, you will not get transitive dependency resolution for that package, and you may need to add replace directives if it imports other packages that are not available. The best practice is to update the dependency to support modules, or use a fork that does.

How do I handle private repositories?

Set the GOPRIVATE environment variable to include your private module path patterns (e.g., GOPRIVATE=github.com/mycompany/*). This tells the module system not to use the public proxy for those modules. Also configure your .netrc or GIT_TERMINAL_PROMPT to authenticate with your private repository.

Should I commit the go.sum file?

Yes. Committing go.sum ensures that all developers and CI systems use the exact same dependency hashes. It is a security measure and should always be committed.

What is the best strategy for a large monorepo with dozens of modules?

Use Go workspaces (Go 1.18+). Create a go.work file that lists all modules. During development, the workspace ensures that local changes are immediately visible. For CI, you can either commit the go.work file or generate it dynamically. If you cannot use workspaces, maintain replace directives in each module's go.mod—but this becomes unwieldy as the number of modules grows.

How do I migrate a project that uses dep or glide?

First, run go mod init to create a go.mod file. Then run go mod tidy to populate it based on the imports in your source files. The tool will ignore the old lock files (Gopkg.lock, glide.lock). After migration, remove the old vendor directory and lock files. Test thoroughly, as the version selection may differ from what dep or glide chose.

Next steps: After reading this guide, create a migration plan for your project that includes a rollback strategy. Run the migration on a feature branch first. Validate with the checklist above. Then merge and monitor your CI for at least a week. If you encounter any of the pitfalls here, you now know how to dodge them.

Share this article:

Comments (0)

No comments yet. Be the first to comment!