Learn backend development with my current project: Boot.dev

Go’s Major Versioning Sucks – From a Fanboy

By Lane Wagner on Sep 15, 2020

I’m normally a fan of the opinionated rigidity within the Go toolchain. In fact, we use Go on the front and backend at Boot.dev, and we’ve found that it’s wonderful to have standardized formatting, vetting, and testing across the entire Go ecosystem. The first big criticism I’ve had with Go’s opinionated nature is with the way the Go toolchain handles major versions. It slows down development in a significant number of scenarios and is a detriment to the average developer’s experience.

Refresher on “go mod”

Go modules, and the associated commands go mod and go get can be thought of as Go’s equivalents to NPM and Yarn. The Go toolchain provides a way to manage dependencies and lock the versions that a collection of code (a module) depends on.

One of the most common operations is to update a dependency in an existing module. For example, my typical workflow goes something like this:

# update all dependencies
go get -u ./...

# add missing dependencies and remove unused ones
go mod tidy

# save all dependency code in the local "vendor" folder
go mod vendor

Semantic versioning

Go modules use Git tags and semantic versioning to keep track of the versions of dependencies that are compatible with the module in question. Semantic versioning is a way to format version numbers and it looks like this: v{MAJOR}.{MINOR}.{PATCH}. For example, v1.2.3.

Each number is to be incremented according to the following standards:

MAJOR version when you make incompatible API changes,
MINOR version when you add functionality in a backwards compatible manner, and
PATCH version when you make backwards compatible bug fixes.

So far so good, I love everything about Go’s dependency management up to this point. My favorite part is that the Go toolchain doesn’t have a central repository of builds you need to publish to to share a package. We just use Git repositories! Amazing.

The big problem: major version increments

The Go decided that all versions beyond v0 and v1 are required to use the major version in the module path. Keep in mind, this is redundant because the local go.mod file already has the semantic version of all dependencies tracked.

There are two ways to accomplish this path requirement.

To start development on v2 of github.com/googleapis/gax-go, we’ll create a new v2/ directory and copy our package into it.

The Go blog

In other words, for every major version, we are encouraged to maintain a new copy of the entire codebase. This is also the only way to do it if you want pre-modules users to be able to use your package. I understand why for large projects this makes a ton of sense, it allows the maintainers to continue patching old versions easily while developing the new version.

The second way

The second option is to change the name of your module in go.mod. Fore example, module github.com/wagslane/go-tinydate would become module github.com/wagslane/go-tinydate/v2. Besides this not working for older versions of Go, I also find it problematic because it breaks (in my mind) one of the most useful things about module names - they reflect the file path. I tend to choose the first option as recommended.

Why does this suck?

Some ideas for the way I wish it were

This all comes down to a fundamental issue I have with the “import compatibility rule”.

If an old package and a new package have the same import path, the new package must be backwards compatible with the old package.

Import compatibility rule

I agree with the sentiment that we should only increment major versions when making breaking changes, but more often than not breaking changes are really easy to accommodate. Go is a strongly typed language, and almost all breaking changes will cause compiler errors that are simple to fix. This kind of rule would add a lot more value to a language like Python or JavaScript.

A Caveat - Diamond Imports

Using different paths for different major versions makes more sense in situations where we may require two different versions of the same package, you know, diamond imports and all that. This is the exception, not the rule, and it seems strange to tap dance around a problem that doesn’t exist in most codebases.

Why this is annoying for me personally

I often want to build a package that has domain-specific logic that will only be used only in microservices at the small company I work for. For example, we have a repo that holds the struct{} definitions for common entities used across our system. Occasionally we need to make backward-incompatible changes to those struct definitions. If it were an open-source library we wouldn’t make changes so often, but because it’s internal and we are aware of all the dependencies, we change the names of exported fields regularly. We aren’t changing names because we chose bad ones, to begin with, we are usually changing names because requirements from the product side change rapidly in a startup.

This means major version changes are a fairly regular occurrence. Some say that we should just stay on v0, and that’s a reasonable solution. The problem is these ARE production packages that are being used by a wide number of services. We want to Semver.

Go makes updating major versions so cumbersome that in the majority of cases, we have opted to just increment minor versions when we should increment major versions. We want to follow the proper versioning scheme, we just don’t want to add unnecessary steps to our dev process.

I get why these rules exist, and I think they are great for large open projects

I understand why these decisions were made - and I even think in a lot of cases they were great decisions. For any open-source or public facing module the rules make great sense. The Go toolchain is enforcing strict rules that encourage good API design. In their effort to make public APIs great, they made it unnecessarily hard to have good “local” package design.

There is an open issue on Github that would make new major versions more discoverable from the CLI. Take a look at that if you are interested.

Go still has the best toolchain and ecosystem. NPM and PIP can suck it.

If you disagree, @ me on Twitter.