Back to blog

Sunday, October 25, 2026

Why Do Go Modules Break When A Repo Moves?

Title card for 'Why Do Go Modules Break When A Repo Moves?' with a short code example

Introduction

Go modules break when a repo moves because a move changes the module path, and the module path is the identity Go uses to find, version, and import the code. The code can be byte for byte identical in the new repo, and consumers still break, because Go does not match modules by code content or by repo history. It matches them by path string, and the old path string no longer names the new location.

Before You Start

  • Module: a collection of packages that are released, versioned, and distributed together, with a go.mod file at the module root.
  • Module path: the canonical name of a module, declared with the module directive in go.mod. It is the prefix for every package path inside the module.
  • Package path: the module path joined with the subdirectory containing the package, relative to the module root. For example, the module golang.org/x/net contains a package in the directory html, and that package path is golang.org/x/net/html.
  • Main module: the module containing the directory where the go command is invoked. Replace and exclude directives in other modules are ignored when building the main module.
  • Require: a go.mod directive that names a dependency module path and a version. A version is a snapshot, written as v followed by a semantic version, for example v1.2.0.
  • Semantic version tag: a Git tag in the module repository that names a version, for example v1.2.0. For a module in the repository root, the tag name equals the version. The tag must live in the repository that the module path names.
  • Replace: a go.mod directive that substitutes the contents of a module version with contents found elsewhere, either a local directory or another module path and version. Replace directives apply only in the main module.
  • Module proxy: a server that serves module versions over the module download protocol, configured through GOPROXY. Downloaded module files are stored in the module cache, by default under $GOPATH/pkg/mod.
  • Module cache: the local directory where the go command stores downloaded module files, keyed by module path and version.
  • Vanity import path: a module path under a domain the author controls, where the domain serves a go-import meta tag that points the go command at the current repository URL.
  • Major version suffix: the /v2, /v3, and similar suffix at the end of a module path, required starting at major version 2, and not allowed at major versions 0 or 1.

Prerequisites

  • Go 1.20 or later, because the article uses module, require, replace, and version rules that are stable in that version.
  • A terminal, a text editor, and a Git repository you can rename, fork, or move in a test account, if you want to reproduce the move.
  • No third party packages are required for the complete example.
go version

Requires: Go 1.20 or later. Verified on 2026-10-25 against the official sources in References.

Project Layout

Two small projects carry the whole article. The first is the library that moves. The second is a consumer that imports it.

slugify/
├── go.mod        # module line names the library, before and after the move
├── slug.go       # root package, imports the words package by module path
├── slug_test.go  # test for the root package
└── words/
    └── words.go  # helper package, imported by slug.go

shop/
├── go.mod        # consumer module, with a require for the library
└── main.go       # consumer program, imports the library by module path
  • slugify/go.mod: the library identity. Changing this one line changes every import path in the library.
  • slugify/slug.go: the root package. It imports the words package through the full module path, so a move forces an edit here too.
  • slugify/words/words.go: a subpackage. Its package path is the module path plus words.
  • shop/go.mod: the consumer require. It names the library by module path and version.
  • shop/main.go: the consumer import. It names the library by package path.

A third folder appears only in the bridge section: a local copy of the moved library, kept under its old module path, used as a temporary local replacement. It is not a third published module.

The Module Path Is The Identity

A module path is not a label that sits beside the code. It is the name the go command uses to locate the repository, to select a version, to verify checksums, and to resolve imports. The Go Modules Reference states the rule directly: a module is identified by a module path, declared in go.mod, and a module path is the prefix for package paths within the module.

That single rule explains the whole break. Move a repo from github.com/olduser/slugify to github.com/neworg/slugify, and three different strings now disagree:

  • The library go.mod still says github.com/olduser/slugify, unless you edit it.
  • The library internal imports still say github.com/olduser/slugify/words, unless you edit them.
  • Consumer go.mod files and consumer imports still say github.com/olduser/slugify, unless every consumer edits them.

Nothing in that list is repaired by moving files, by keeping the same commit history, or by keeping the same code. History and content are not part of the identity. The path string is.

A move also changes what a version means. A version is a snapshot of a module, and a module is named by its path. github.com/olduser/slugify at v1.2.0 and github.com/neworg/slugify at v1.2.0 are two different modules that happen to share a version number and a code snapshot. A consumer that requires the old path cannot receive new work published under the new path, because the new work belongs to a different module in the build list.

What Breaks, In Order

A repo move breaks in a fixed order, and checking in that order finds every break faster than chasing build errors one at a time.

First, the library itself breaks. Its go.mod module line names the old location, and its internal imports name the old location. Until both change, the library at its new home still imports itself by its old name, and a consumer that fetches it under the new name gets a module whose own imports point elsewhere.

Second, consumers break at the require line. A consumer go.mod that requires github.com/olduser/slugify v1.2.0 asks the proxy, or the repository, for a module at that path. The new repo does not answer to the old path, because its module path is the new path. A fresh machine, a clean cache, or a request for a version that only exists in the new repo has nothing to fetch under the old path.

Third, consumers break at the import line, even if the require line were fixed. An import of github.com/olduser/slugify is a different package from github.com/neworg/slugify, even when the package code is identical. Two packages with different import paths are different packages to the compiler, so types from one cannot be passed where the other is expected, and a program that imports both paths gets two copies of the same code.

Fourth, checksums break. go.sum entries are recorded per module path and version. A consumer that moves to the new path needs new go.sum entries for the new path, and go mod tidy regenerates them from the new repository. Old go.sum entries do not cover the new path, because they name a different module.

Fifth, vendored copies break. A vendor directory and its vendor/modules.txt record module paths. A vendored consumer that changes its require without regenerating its vendor directory still builds from the old vendored copy, or fails the vendor consistency check, depending on the command. Regenerate vendor output after the move, with go mod vendor, rather than editing it by hand.

Why Old Builds Keep Working For A While

A break that appears at once on one machine and not at all on another is the most confusing form of this problem. The reason is caching, and it is worth stating plainly so it is not mistaken for a fix.

Downloaded module files are stored in the module cache, keyed by module path and version. A machine that already downloaded github.com/olduser/slugify at v1.2.0 can keep building from that cache without contacting the old location at all. A module proxy can also keep serving versions that were published under the old path. The Go Modules Reference notes that even after a tag is deleted, its content may remain available on module proxies.

None of that means the move is safe. The cached old version is frozen at the last version published under the old path. It receives no new versions, no bug fixes, and no security fixes published under the new path, because those versions belong to a different module. A new team member, a clean continuous integration runner, or a consumer that needs a version that only exists in the new repo hits the break immediately, while the original author, with a warm cache, sees nothing wrong. Treat a build that works only from cache as a delayed break, not as evidence that the move worked.

Major Version Suffixes And The Move

A move and a major version change are two separate changes, and combining them by accident is a common second break on top of the first.

Starting at major version 2, a module path must end with a major version suffix that matches the major version. A module at v1.0.0 with path example.com/mod must have path example.com/mod/v2 at v2.0.0. Major version suffixes are not allowed at major versions 0 or 1, because v0 versions carry no compatibility guarantee and v1 acts as a compatibility commitment rather than a break.

Applied to a move, the rule is:

  • Moving at the same major version, for example staying at v1.2.0, changes only the host, user, organisation, or repo name in the path. No /v2 suffix is added.
  • Moving and releasing a breaking v2.0.0 at the same time changes the path twice over: the new location, plus the /v2 suffix. Consumers must update to the new path with the suffix, for example github.com/neworg/slugify/v2, and import that path.
  • Tagging v2.0.0 in the new repo without adding /v2 to the module path breaks the version rule, because the tag major version must be consistent with the module path suffix.

Keep the two changes separate when you can. Move first, retag the same versions under the new path, and let consumers migrate. Release a v2 later, as its own change, with its own suffix and its own consumer update.

Tags Belong To The New Repo

Version tags do not follow a repo move on their own. A tag is a name inside one repository, and a module version is a tag inside the repository that the module path names. Moving the code to a new repository, with or without history, leaves the new repository without the tags until someone creates them there.

The migration therefore includes a retag step, in the new repo, for every version consumers should be able to fetch under the new path. If consumers were on v1.2.0 under the old path, the new repo needs a v1.2.0 tag pointing at the corresponding commit, so consumers can require github.com/neworg/slugify v1.2.0. If the new repo has only a v1.3.0 tag, consumers cannot require v1.2.0 under the new path, because no such version exists for that module.

Retagging the same version numbers is a publishing decision, not a trick. The version number under the new path should point at the same code snapshot consumers expect for that version, or at a clearly announced replacement. Do not reuse a version number for different code under the new path while the old path still serves the old code for that number, because two modules with the same version number and different code is exactly the confusion versioning exists to prevent.

Pseudo-versions follow the same rule. A pseudo-version encodes a commit and a timestamp from a repository, and it is addressed by module path. A pseudo-version under the old path does not become a pseudo-version under the new path by moving the repo. Consumers that pinned a pseudo-version must re-pin from the new repo, with go get against the new path.

Vanity Import Paths: The Move You Prepare Beforehand

A vanity import path is the one mechanism in this article that lets a later repo move happen without changing consumer imports, and it only works if it was set up before the move.

The mechanism is part of the module reference. When the go command downloads a module in direct mode, it sends an HTTP request derived from the module path, with a ?go-get=1 query, and looks for a go-import meta tag in the response. That tag carries three parts: a root path, a version control system, and a repository URL. For example:

<!-- go-import example, served by the vanity domain -->
<meta name="go-import" content="example.com/slugify git https://github.com/neworg/slugify">

With that tag in place, the module path is example.com/slugify, and the repository URL is whatever the tag currently says. Moving the repo from one host, user, or organisation to another then means updating the tag, not updating every consumer import, because the module path itself never changed.

The limit is equally direct, and it matters for this article. A vanity path protects a module path that was published under the vanity domain. It does not rescue a module path that was already published under a host domain, because github.com/olduser/slugify and example.com/slugify are different module paths, and different module paths are different modules. A team that wants this protection for the next move must publish under the vanity path first, and accept one consumer migration to that path, before the protection exists.

This article makes no claim about any hosting service redirecting Go downloads after a repo move. The Go command does follow redirects on its ?go-get=1 discovery request, but a redirect does not rename a module, does not update a consumer require line, does not update a consumer import, and does not update go.sum. Plan as if the old path is dead for new consumers, and treat any redirect you observe as untested help, not as a migration.

Replace Is A Bridge, Not A Migration

A replace directive is the most misunderstood tool in a repo move, because it looks like a rename and it is not one.

A replace directive substitutes the contents of a module version with contents found elsewhere. In a consumer go.mod, a local directory replacement looks like this:

// consumer go.mod, temporary bridge only
module example.com/shop

go 1.20

require github.com/olduser/slugify v1.2.0

replace github.com/olduser/slugify => ../slugify-local

That bridge works only under a strict condition from the module reference: if the replacement module has a go.mod file, its module directive must match the module path it replaces. The local copy at ../slugify-local must therefore still declare module github.com/olduser/slugify, even though the code now lives in a new repo. The consumer import still says the old path, the require still says the old path, and the build still uses the old identity, with code fetched from a local directory instead of from the old repository.

A replace that points the old path at the new path, in the form replace github.com/olduser/slugify => github.com/neworg/slugify v1.2.0, does not turn old imports into new imports, because the replacement module declares the new path, not the replaced path. Do not present that form as a migration. It is not a rename, it is a substitution rule that the module directive rule blocks for a renamed module.

Replace has two further limits that make it a bridge, not a fix. Replace directives apply only in the main module, and are ignored in other modules. A replace in a library go.mod does not protect that library consumers. And a replace in one consumer does not help any other consumer. Every consumer that needs the bridge must add it, and every bridge must be removed when the consumer migrates, or the consumer stays pinned to a local directory that no other machine has.

Use replace for exactly one purpose in a move: to keep a single consumer building locally while the real migration, new require plus new imports, is in progress. Remove it as part of that migration, and do not ship a library that depends on a replace to work.

Design Decisions And Tradeoffs

OptionUse whenCost
Migrate every consumer to the new pathThe move is permanent and consumers can updateEvery consumer edits require, imports, and go.sum; old versions stop receiving updates
Local replace bridgeOne consumer needs to keep building during the migrationMain module only, ignored by dependents, local directory only, must be removed after migration
Module path replace to the new pathNever, for a renamed moduleBlocked by the module directive rule, because the replacement declares the new path, not the replaced path
Vanity import pathFuture moves must not change consumer importsRequires a domain you control, a go-import endpoint, and a prior migration to the vanity path
Keep the old repo as a mirrorConsumers need time and the old path must keep workingTwo repos to maintain, versions must be published in both, and the old path never truly retires
Retag the same versions in the new repoConsumers should fetch the same versions under the new pathA publishing step that must match code snapshots, or version numbers become misleading
Move and release v2 togetherA breaking release is already plannedConsumers face a location change and a suffix change at once, and both must be updated together
Rely on cache or proxy copiesNothing, as a migration planOld versions stay frozen, new versions are unreachable under the old path, and fresh machines break first

A design in this article has a deliberate compromise, stated openly. The complete example uses a small library with one subpackage, not a large module with many packages, so every changed line can be shown. In a large module, the same rules apply to every package, and the migration is a search and replace across every internal import, not a different kind of change. The small example is a teaching compromise, not a claim that large migrations are small.

Complete Example

The complete example shows the library before the move, the library after the move, the consumer before the move, the consumer after the move, and the temporary bridge, in that order. Read them as a migration, not as five separate projects.

// slugify/go.mod, before the move
module github.com/olduser/slugify

go 1.20
// slugify/words/words.go, unchanged by the move
package words

import "strings"

func Split(s string) []string {
    return strings.Fields(s)
}
// slugify/slug.go, before the move
package slugify

import (
    "strings"

    "github.com/olduser/slugify/words"
)

func Slug(s string) string {
    parts := words.Split(s)
    return strings.ToLower(strings.Join(parts, "-"))
}
// slugify/slug_test.go, unchanged by the move except for its package
package slugify

import "testing"

func TestSlug(t *testing.T) {
    got := Slug("Hello World")
    if got != "hello-world" {
        t.Errorf("Slug(%q) = %q, want %q", "Hello World", got, "hello-world")
    }
}
// slugify/go.mod, after the move
module github.com/neworg/slugify

go 1.20
// slugify/slug.go, after the move
package slugify

import (
    "strings"

    "github.com/neworg/slugify/words"
)

func Slug(s string) string {
    parts := words.Split(s)
    return strings.ToLower(strings.Join(parts, "-"))
}

Only two lines changed in the library: the module line, and the internal import in slug.go. The words package and the test file needed no import change, because they import nothing from the library itself. In a library with more internal imports, every one of those lines changes in the same way.

// shop/go.mod, before the move
module example.com/shop

go 1.20

require github.com/olduser/slugify v1.2.0
// shop/main.go, before the move
package main

import (
    "fmt"

    "github.com/olduser/slugify"
)

func main() {
    fmt.Println(slugify.Slug("Hello World"))
}

Commands to run the consumer before the move, from the shop folder:

go mod tidy
go run .

Expected output before the move:

hello-world
// shop/go.mod, after the move
module example.com/shop

go 1.20

require github.com/neworg/slugify v1.2.0
// shop/main.go, after the move
package main

import (
    "fmt"

    "github.com/neworg/slugify"
)

func main() {
    fmt.Println(slugify.Slug("Hello World"))
}

Commands to run the consumer after the move, from the shop folder:

go mod tidy
go run .

Expected output after the move:

hello-world

The output is identical, because the code is identical. The module identity changed, the output did not, and that is the point: a successful migration is invisible in program output and visible in every path string.

The temporary bridge, for one consumer, during the migration, looks like this. The consumer go.mod keeps the old require, and adds a local replacement. The local copy must still declare the old module path.

// shop/go.mod, temporary bridge only, do not ship as the migration
module example.com/shop

go 1.20

require github.com/olduser/slugify v1.2.0

replace github.com/olduser/slugify => ../slugify-local
// slugify-local/go.mod, the bridge copy, old path on purpose
module github.com/olduser/slugify

go 1.20

Commands to run the bridged consumer, from the shop folder:

go mod tidy
go run .

Expected output with the bridge:

hello-world

Remove the bridge when the consumer migrates. A consumer that keeps the bridge keeps the old identity, keeps a local directory dependency that other machines do not have, and never receives new versions published under the new path.

Testing The Migration

Test a migration by identity, not by output alone. The program output is the same before and after, so output cannot prove the migration happened. The build list, the import search, and the test run together can.

Commands to run in the library after the move, from the slugify folder:

go vet ./...
go test ./...

Commands to run in the consumer after the move, from the shop folder:

go mod tidy
go vet ./...
go test ./...
go list -m all

What to check, in order:

  • go vet ./... and go test ./... pass in the library, proving the internal imports now name the new path.
  • go vet ./... and go test ./... pass in the consumer, proving the consumer imports now name the new path.
  • go list -m all shows github.com/neworg/slugify v1.2.0 and shows no github.com/olduser/slugify entry, proving the build list contains the new module and not the old one.
  • A search for the old path across the consumer repo returns nothing, proving no import, require, replace, vendor entry, or documentation example still names the old path. The command is:
grep -R "github.com/olduser" .

Expected result for that search: no matches.

  • A fresh checkout test, on a machine or in a clean cache, builds the consumer, proving the migration does not depend on a warm module cache holding the old path.

go vet reports suspicious constructs. It does not detect a stale module path that still resolves from cache, so do not treat a clean vet run on a warm machine as proof that the migration is complete. The build list check and the fresh checkout check are the proof for this example.

Common Mistakes

  • Moving the repo and leaving the library go.mod unchanged: the library at its new home still declares the old path, so its internal imports and its published identity disagree with its location. The fix is to change the module line first, then every internal import.
  • Changing the module line and missing an internal import: a library with several packages breaks package by package, because every internal import is a full path. The fix is a search across the whole module for the old path, including test files and example files.
  • Updating the consumer require and missing the consumer import: the require names the new module, but the import still names the old package, so the build still asks for the old path. The fix is to update require and imports together, then run go mod tidy.
  • Updating imports and missing the require: the code imports the new path, but go.mod still requires the old path, so tidy and build disagree about which module provides the package. The fix is the same paired update, require plus imports, in one change.
  • Using a module path replace as the migration: a replace of the old path with the new path does not rename imports, because the replacement module declares the new path, not the replaced path. The fix is the paired require and import update, with replace used only as a temporary local bridge.
  • Shipping a replace in a library: a replace in a library go.mod is ignored by consumers, so a library that only builds with its own replace breaks every consumer. The fix is to remove library replaces before publishing, and to keep replaces in main modules only.
  • Forgetting to retag in the new repo: consumers cannot require a version that has no tag in the new repo, even if that version existed in the old repo. The fix is to create the tags in the new repo, for the same code snapshots, before announcing the move.
  • Adding a v2 suffix for a v1 move: a move at v1.2.0 needs no suffix, and a /v2 suffix at v1.2.0 breaks the version rule. The fix is to keep the path without a suffix at v0 and v1, and to add the suffix only with a v2 or higher release.
  • Releasing v2 and the move in one unannounced change: consumers face a new location and a new suffix at once, and a consumer that updates only one of the two still breaks. The fix is to separate the changes when possible, and to announce both changes when they must ship together.
  • Trusting a warm cache: a build that passes on the author machine because the old path is cached, and fails on a fresh machine, looks like a consumer problem and is a migration problem. The fix is a fresh checkout build as part of the migration check, not an optional extra.
  • Leaving the old path in vendor output, docs, or CI: a vendor directory, a README example, or a CI script that still names the old path reintroduces the old identity after the code migration is done. The fix is the same old path search across the whole repo, plus a regenerated vendor directory.
  • Treating a redirect as a migration: a web redirect, if one exists, does not change a require line, an import line, or a go.sum entry. The fix is the consumer migration itself, with any redirect treated as untested help until verified.

Production Notes

  • Announcement: a repo move is a breaking change for consumers, even when the code is unchanged, because the module identity changes. Announce the old path, the new path, the first version available under the new path, and the date the old path stops receiving new versions, in the same notice.
  • Dual publishing window: if consumers need time, publish new versions under the new path and, for a stated window, mirror the same versions under the old path, with the same code snapshots. A mirror is a maintenance cost, and it must end, or consumers never migrate and the old identity never retires.
  • Shutdown of the old path: when the old path retires, remove it from documentation, examples, and CI, and leave a notice in the old repo pointing at the new path. A notice is documentation, not a Go mechanism, and consumers still must update.
  • Security: a move creates a window where a consumer can be tricked by a lookalike path, for example a similarly named user or organisation. Consumers should update to the exact new path from the author announcement, not from a search result, and go.sum verification applies to the new path entries after tidy.
  • Private modules: a move inside a private host changes the same path strings, and adds access control to the checklist. The new repo must grant the same consumers read access, and private proxy and GOPRIVATE patterns that named the old path must be updated to name the new path, or fetches fail on access rather than on identity.
  • Deployment: the example needs Go 1.20 or later. Pin that version in go.mod, run go vet ./... and go test ./... in continuous integration, and do not deploy a change that fails the build list check. A vet pass alone is not enough for a migration, because vet does not prove that the build list contains the new module.

Decision Guide

  • When the move is permanent and consumers can update: migrate every consumer to the new path, with require, imports, and go.sum updated together, and retag the needed versions in the new repo.
  • When one consumer needs to keep building during the migration: use a local replace bridge in that consumer main module, with the local copy still declaring the old path, and remove the bridge as part of the migration.
  • When future moves must not change consumer imports: publish under a vanity import path you control, with a go-import endpoint, before the next move, and accept one migration to that path first.
  • When the move coincides with a breaking release: release the move and the v2 suffix as two announced changes, or, if they must ship together, announce both the new location and the /v2 suffix in the same notice.
  • When consumers cannot update at once: publish mirrored versions under the old path for a stated window, then retire the old path, and do not let the mirror become permanent.
  • When a build passes only on a warm machine: treat it as an incomplete migration, run a fresh checkout build, and check the build list for the old path before calling the migration done.

Conclusion

A repo move breaks Go modules for a reason that has nothing to do with code quality. The module path is the identity, the import path is built from it, the require line names it, the version tag lives in the repo it names, and the checksum entry records it. Change the path, and every one of those links must change with it, in the library and in every consumer.

Summary Of Key Points

  • Path is identity: a module is identified by its module path, and a different path string is a different module, even with identical code.
  • Three places break together: the library go.mod, the library internal imports, and every consumer require plus import, plus go.sum and vendor output.
  • Old versions do not follow: new versions under the new path belong to the new module, and old path builds that work from cache are frozen, not migrated.
  • Retag in the new repo: a version exists for a module only if its tag exists in the repo the new path names.
  • Suffix follows major version: no suffix at v0 or v1, a matching /v2 or higher suffix at v2 or higher, and never a suffix added for a location move alone.
  • Vanity paths protect future moves: a domain you control, with a go-import tag, lets the repo URL change without changing the module path, but only for paths published under that domain.
  • Replace is a bridge, not a migration: a local bridge must still declare the old path, it applies only in the main module, and a replace to the new path does not rename imports.

Migrate the path everywhere it appears, retag in the new repo, verify the build list on a fresh checkout, and the move becomes a boring rename instead of a week of broken builds.

Additional Resources

References

  1. Go Modules Reference. Go team, Go Documentation. https://go.dev/ref/mod. Accessed 2026-10-25.
  2. Managing Dependencies. Go team, Go Documentation. https://go.dev/doc/modules/managing-dependencies. Accessed 2026-10-25.
  3. Developing and Publishing Modules. Go team, Go Documentation. https://go.dev/doc/modules/developing. Accessed 2026-10-25.