4 Ways of Bumping Major Versions in Your Go Project

I've recently found myself in a rabbit hole of Go major version bumping to v2. What started as a simple task quickly turned into hours of sifting through conflicting information. Should I use a v2 directory? Create a new v2 branch? What about creating a new repository altogether?

The more I read, the more confused I became. To save future me from this headache, I've decided to jot down the differences that I've learned. I think this will be especially useful if you're maintaining a library that other developers depend on.

What I'll Cover

  • 4 different approaches to major version bumping in Go
  • Show real-world examples from popular Go libraries using each approach
  • Tradeoffs of each approach

Approach 1: Major Version Subdirectory

Let’s start with the official recommendation from the Go team (reference). In this approach, we create a new subdirectory for each major version in your repository while the root directory maintains the previous version's code (v0 or v1).

How it works

  1. Make a new folder named after the new major version (e.g. v2/, v3/, etc.)
  2. Copy your code into this new folder
  3. Update the go.mod file in the new folder to include the new major version suffix in the module path
  4. Git tagging the commit that represents the new major version release

Here's a simple visual:

github.com/user/project (main branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project
β”œβ”€β”€ main.go
β”œβ”€β”€ utils.go
β”‚
β”œβ”€β”€ v2/
β”‚   β”œβ”€β”€ go.mod     # module github.com/user/project/v2
β”‚   β”œβ”€β”€ main.go
β”‚   └── utils.go
β”‚
└── v3/
    β”œβ”€β”€ go.mod     # module github.com/user/project/v3
    β”œβ”€β”€ main.go
    └── utils.go

In essence, we have a separate copy of the entire codebase for each major version in a separate subdirectory.

πŸ’‘
Real-world example: github.com/googleapis/gax-go

When to use

β€œThis approach is compatible with tools that aren’t aware of modules: file paths within the repository match the paths expected by go get in GOPATH mode. This strategy also allows all major versions to be developed together in different directories … We recommend that module authors follow this strategy as long as they have users developing in GOPATH mode.” β€” Go Modules: v2 and Beyond
β€œ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.” β€” Go’s Major Versioning Sucks – From a Fanboy

Tradeoffs

Pros:

  1. Your code will work with older Go versions (pre-Go 1.11). This is especially useful in organizations that adopted Go before the pre-Go-module days who are still relying on GOPATH development mode (now legacy) as it relies on this specific directory structure
  2. Allows concurrent development of multiple major versions

Cons:

  1. Code duplication (also commonly shared code in the repository needs to be more carefully managed)
  2. More complex repository structure

Approach 2: Major Version Branch

The second commonly known strategy for bumping major versions in Go is the branch-based approach (reference). Instead of using directories, we maintain different major versions in separate Git branches.

How it works

  1. Create a new branch for the new major version (e.g., git checkout -b v2)
  2. In each branch, update the go.mod file to include the new major version suffix in the module path
  3. Git tag releases in the new branch (e.g., v2.0.0)

Here's a simple example:

github.com/user/project (v3 branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project/v3
β”œβ”€β”€ main.go
└── utils.go

github.com/user/project (v2 branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project/v2
β”œβ”€β”€ main.go
└── utils.go

github.com/user/project (v0 or v1 branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project
β”œβ”€β”€ main.go
└── utils.go
πŸ’‘
Real-world example: github.com/go-yaml/yaml

Tradeoffs

Pros:

  1. Clearer separation in version control
  2. Cleaner repository structure without duplicated directories

Cons

  1. May complicate CI/CD pipelines
  2. Potential confusion between branches and tags

Approach 3: Major Version Suffix

This approach offers a much simpler alternative to the previous approaches. Instead of creating new directories or branches, we simply increment the major version number suffix in the module path of your go.mod file and that’s it!

How It Works

  1. Keep your existing directory structure
  2. Update the go.mod file’s module path to include the new major version suffix
  3. Tagging the commit that represents the new major version release

Here's an example:

github.com/user/project (main branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project/v2 (Changes with each major version)
β”œβ”€β”€ main.go
└── utils.go
πŸ’‘
Real-world example: github.com/google/go-github

Tradeoffs

Pros:

  1. Simple and straightforward
  2. No code duplication
  3. No need to create new directories or branches

Cons:

  1. Potentially breaks compatibility with users who are still using GOPATH development mode

Approach 4: New Repository for Each Major Version

While not officially recommended by Go, some teams opt to create an entirely new repository for each major version. This way, they get a clear separation between versions, although it does make things a bit trickier to handle.

It might seem a little strange at first, but when you think about it, a major version (like v2) of a Go module really is like starting a whole new Go module.

How it works

  1. Create a new repository for each major version of your module
  2. Start fresh with the new version in the new repository
  3. Ensure that the go.mod file module path suffix correctly reflects the new major version

Some example:

github.com/user/project (main branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project
β”œβ”€β”€ main.go
└── utils.go

github.com/user/project-v2 (main branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project-v2
β”œβ”€β”€ main.go
└── utils.go

github.com/user/project-v3 (main branch)
β”‚
β”œβ”€β”€ go.mod         # module github.com/user/project-v3
β”œβ”€β”€ main.go
└── utils.go
πŸ’‘
Real-world example: see AWS SDK for Go: github.com/aws/aws-sdk-go (v1) and github.com/aws/aws-sdk-go-v2 (v2)

Tradeoffs

Pros:

  1. Complete isolation between versions

Cons:

  1. Increased overhead in managing multiple repositories
  2. Cannot reuse existing CI/CD pipelines

Summary

Consider the size of your project and whether you still have users working in GOPATH mode. Think about how your team likes to work and go with the approach that best fits your unique situation.

Closing Thoughts

Unfortunately, the complexity of major version bumping in Go has an unintended side effect. It seems to sometimes make developers hesitant to take the leap for necessary major version updates. As a result, there’s a chance breaking changes may sneak in with minor updates, which goes against the spirit of semantic versioning.

Written by human. Hosted on Digital Ocean.