March 19, 2019·5 min read·engineeringgomodulesdependencies

Go modules make incompatible versions explicit in imports

The official modules introduction explains why a new major version has a new import path, how incremental migration works, and why builds cannot remove unused requirements safely.

A dependency upgrade becomes much easier to reason about when incompatible code has a different name. Go's module system puts that distinction into import paths. A program can use rsc.io/quote and rsc.io/quote/v3 together, then migrate its callers incrementally. It cannot select two different minor releases of the same module path for one build.

Tyler Bui-Palsulich and Eno Compton explain this behavior in Using Go Modules, published on March 19. The article introduces the module workflow supported by Go 1.11 and Go 1.12. Its most useful contribution is showing how ordinary build commands interact with explicit dependency requirements, including an upgrade that makes a test fail.

I like that the incompatible version is visible where code consumes it. Go's readability matters most when another programmer has to change a program without first learning a private set of conventions. A major version in an import gives that reader concrete information about the dependency contract.

A module is larger than a package

The guide defines a module as a collection of packages in a file tree rooted at a go.mod file. That file declares the module path and its dependency requirements. A package in a subdirectory takes its import path from the module path followed by the subdirectory path. Creating a package therefore does not normally require creating another module.

The tutorial starts outside GOPATH/src. In the default mode described for these releases, the Go command uses modules when it finds go.mod in the current directory or a parent directory outside that tree. Inside GOPATH/src, compatibility with the existing development model changes the default behavior. A module file's presence alone is consequently insufficient to explain every invocation of the Go command during this transition.

The initial operation is small, assuming the current directory is the new module's root. The test command then identifies the package using its declared module path.

go mod init example.com/hello
go test

The example's go.mod contains the module path and a go 1.12 directive. A subdirectory named world belongs to the same module and has the package path example.com/hello/world. Running go mod init again in that subdirectory would change the structure being described, so the guide explicitly advises against it.

Adding an import also changes dependency metadata

When the tutorial imports rsc.io/quote, go test resolves the previously missing dependency and adds its module requirement. The selected version in the article is v1.5.2. Its dependencies include rsc.io/sampler and an untagged revision of golang.org/x/text expressed as a pseudo-version.

That interaction explains why a command named for testing can modify dependency files. The command needs a complete set of inputs before it can build and run the package's tests. When an import has no provider among the active modules, the command finds the module and records the requirement. Later invocations can use the recorded dependency and the local cache at GOPATH/pkg/mod.

The guide distinguishes requirement metadata from content verification. The go.sum file records expected cryptographic hashes for particular module versions. Subsequent downloads can be checked against those hashes. Both go.mod and go.sum belong in version control according to the tutorial, because they preserve information used to reproduce the dependency inputs.

Russ Cox's earlier Go Modules in 2019 explains a limit of this protection. Adding a dependency or upgrading to a version not yet represented in go.sum supplies no existing local hash against which to authenticate the initial download. That December 2018 article describes a planned public notary service to address this gap. The March workflow should not be described as if the proposed service were already part of the demonstrated commands.

A semantic version is a compatibility claim

The tutorial deliberately upgrades rsc.io/sampler to v1.99.99 and obtains a failing test. Instead of the expected greeting, the function returns a different string. Selecting v1.3.1 explicitly restores the expected result.

The commands make the chosen requirement inspectable. Listing the module dependencies shows the result of the explicit version selection.

go list -m -versions rsc.io/sampler
go get rsc.io/sampler@v1.3.1
go test
go list -m all

The article's example demonstrates that a non-major upgrade can still violate a consumer's expectations. The versioning convention communicates an intended compatibility relationship. It cannot prove that the implementation preserves every behavior on which a caller relies, or that the caller's expectations fit the package's intended contract.

An indirect dependency can also appear explicitly in go.mod. In the guide, upgrading golang.org/x/text records its new version with an indirect comment. That comment describes the absence of a direct import by the main module. It does not mean the version requirement is irrelevant to the build.

I find this representation useful because a dependency decision remains readable after the command that made it has disappeared from shell history. The file tells a reviewer which module version the project requires, including deliberate changes to indirectly used code.

Major upgrades can proceed package by package

For major versions beginning with v2, the guide describes a version suffix in the module path. Its example imports the original quote module and rsc.io/quote/v3 at the same time. An import alias distinguishes the two packages within the source file while the program begins using an operation from the newer module.

The Go command permits at most one selected version per module path. Separate major-version paths allow the older and newer packages to coexist. This permits a gradual migration when all callers cannot change in the same edit. It also means the dependency identity includes the major-version path, so changing only a requirement's version number is not the complete source change for this example.

Once all callers stop importing the original package, its requirement may still remain in go.mod. Building one package cannot establish that every package and build-tag combination has stopped using a dependency. The guide gives that as the reason ordinary build commands add missing dependencies but do not safely remove unused ones.

go mod tidy performs the broader dependency cleanup. In the example it removes the obsolete quote requirement while preserving the requirements still needed by the module. This division of responsibility explains the apparent asymmetry in the workflow, a local build can discover a missing input, while removal requires examining the module more broadly.