How to Handle Breaking Changes in Go Modules (v2+)

Use the `godebug` directive in your `go.mod` or `go.work` file to explicitly opt into legacy behavior when upgrading the Go toolchain. Add a `godebug` block to your module file to override defaults for specific settings like `panicnil` or `tarinsecurepath`.

The breaking change problem

You publish a library at github.com/acme/utils. Version 1.0.0 works. Users import it, build their apps, and deploy. Six months later, you realize the API is flawed. You rename a function, remove a struct field, or change a return type. You release version 2.0.0.

Now your users face a choice. They can upgrade and break their code, or they can stay on 1.0.0 and miss the fix. In many ecosystems, this is a coordination nightmare. Go modules solve this with a rule that ties the version number to the import path itself. Major version two is not just a tag. It is a different package.

This mechanism is called Semantic Import Versioning. It allows a single dependency graph to contain multiple major versions of the same library. Project A can use utils v1. Project B can use utils v2. Project C can depend on both A and B without conflict. The toolchain treats them as distinct modules because their import paths differ.

Semantic Import Versioning

The rule is simple. Major versions zero and one live at the base path. Major versions two and higher must include the major version as a suffix in the module path.

If your module is github.com/acme/utils, version 1.5.0 lives at that path. Version 2.0.0 must live at github.com/acme/utils/v2. The suffix is part of the module declaration and every import statement. The toolchain uses the path to resolve dependencies, not just the tag.

This design prevents accidental breaks. If a user imports github.com/acme/utils, they get the v1 API. If they want v2, they must explicitly import github.com/acme/utils/v2. The compiler rejects any mismatch. You cannot accidentally pull in a breaking change by bumping a version number in go.mod while keeping the old import paths.

The convention aside: Go modules are strict by design. The community accepts the verbosity because it guarantees reproducibility. go.mod is the source of truth. go mod tidy enforces consistency. Trust the toolchain to catch path mismatches before they reach production.

Minimal example

Here is the module declaration for a v2 library. The module line includes the version suffix. The go directive sets the minimum toolchain version.

// go.mod
module github.com/acme/utils/v2

go 1.21

// v2 suffix tells the toolchain this is a major version module
// import paths must match the module path exactly
// go directive requires Go 1.21 or later to build

The code inside the module uses the full path for internal imports. If the library has a subpackage, the subpackage import also includes the suffix.

// pkg/parser/parser.go
package parser

import (
    // internal imports use the full v2 module path
    "github.com/acme/utils/v2/internal/types"
)

// Parse reads JSON and returns a structured result
func Parse(data []byte) (*types.Result, error) {
    // implementation details
    return nil, nil
}

The consumer must update their imports to match the new path. A v1 import will not resolve to the v2 module.

// main.go
package main

import (
    // v2 import path matches the module declaration
    // go.mod must require github.com/acme/utils/v2
    "github.com/acme/utils/v2"
)

func main() {
    // Call the updated API
    utils.Parse([]byte(`{"key":"value"}`))
}

Version the path, not just the tag. The import path is the contract.

Walkthrough

When you run go get github.com/acme/utils/v2@v2.0.0, the toolchain performs a sequence of checks. First, it fetches the tag v2.0.0 from the repository. Next, it reads the go.mod file at that revision. It verifies that the module declaration matches the requested path.

If the go.mod says module github.com/acme/utils but you requested .../v2, the toolchain halts. The compiler rejects the build with module declares its path as "github.com/acme/utils" but was required as "github.com/acme/utils/v2". This error protects you from publishing a v2 tag with a v1 module path, which would confuse the resolver.

Once the paths match, the toolchain updates the consumer's go.mod. It adds a requirement for github.com/acme/utils/v2 v2.0.0. It also updates go.sum with the cryptographic hash of the module content. The build then proceeds using the v2 code.

If you forget to update an import in your code, the compiler catches it immediately. The compiler rejects the program with module github.com/acme/utils/v2@v2.0.0 found, but does not contain package github.com/acme/utils if the import path lacks the version suffix. The error message tells you exactly which package is missing and points to the module that was found.

The toolchain enforces the contract. You cannot ship a mismatched module, and you cannot consume one without explicit intent.

Realistic scenario

Consider a web server that uses a configuration library. The library author releases v2 with a breaking change: the Load function now returns a Config struct instead of a map. The server needs to upgrade.

The server's go.mod requires the v2 module. The import path in the handler matches the module path. The code adapts to the new return type.

// server.go
package main

import (
    "net/http"

    // v2 import path matches the module declaration
    "github.com/acme/config/v2"
)

// Handler serves requests using the new config struct
func Handler(cfg config.Config) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Access typed fields from the config struct
        w.Write([]byte(cfg.Hostname))
    }
}

func main() {
    // Load returns a typed struct in v2
    cfg, err := config.Load("config.yaml")
    if err != nil {
        panic(err)
    }

    // Wire the handler with the config
    http.HandleFunc("/", Handler(*cfg))
    http.ListenAndServe(":8080", nil)
}

The go.mod for the server lists the v2 requirement. It also pins the Go version. If the v2 module requires Go 1.21, the server's go.mod must specify at least that version, or the toolchain will upgrade it automatically during go mod tidy.

// go.mod
module github.com/acme/server

go 1.21

require (
    // v2 requirement matches the import path suffix
    // toolchain resolves this to the v2.0.0 tag
    github.com/acme/config/v2 v2.0.0
)

The convention aside: Receiver names in Go are usually one or two letters matching the type. In the config package, the method signature would be func (c *Config) String() string, not func (self *Config) String() string. This convention keeps code concise and readable across the ecosystem.

Pitfalls and errors

The most common mistake is updating the tag but forgetting the module path. You push v2.0.0, but go.mod still says module github.com/acme/utils. Users who try to upgrade get a hard error. The toolchain rejects the module with module declares its path as "github.com/acme/utils" but was required as "github.com/acme/utils/v2". Fix the go.mod module line to include the suffix, then re-tag.

Another pitfall is mixing v1 and v2 imports in the same file. If you import both github.com/acme/utils and github.com/acme/utils/v2, you must use package aliases or fully qualified names to avoid ambiguity. The compiler rejects the program with imported and not used if you import a package but don't reference it, which often happens when you add the v2 import but forget to remove the v1 usage.

The go.mod go directive can also cause issues. If your v2 module uses features from Go 1.22, you must set go 1.22 in go.mod. If a consumer is on Go 1.21, the toolchain will refuse to build the dependency. The compiler rejects the build with go.mod requires go >= 1.22; switching to go1.22 or a similar message depending on the toolchain version. The consumer must upgrade their Go installation or pin the dependency to an older version.

Local development requires a replace directive. If you are working on the v2 module locally, your consumer project won't see your changes until you publish a tag. Add a replace directive to point to the local path.

// go.mod
module github.com/acme/server

go 1.21

require (
    github.com/acme/config/v2 v2.0.0
)

replace (
    // replace directive overrides the remote module with a local path
    // remove this block before committing to version control
    github.com/acme/config/v2 => ../config
)

The worst module bug is the one that works locally but fails in CI. Always run go mod tidy before committing. The toolchain cleans up unused dependencies and ensures the go.mod matches the code.

Decision matrix

Use a v2+ module path when you must break the public API and existing users need to keep using v1. Use a v1 module when your changes are backward compatible: add fields, add methods, or deprecate without removing. Use a replace directive when you are developing a local fork and need to test changes before publishing a new version. Use a major version zero module when the project is experimental and the API is expected to change frequently. Use a go.mod go directive to declare the minimum toolchain version required to build the module. Use go mod tidy to synchronize the module file with the codebase after adding or removing imports.

The module path is the import path. Keep them in sync.

Where to go next