The Migration That Shouldn’t Have Happened

A team I know spent three weeks migrating 120 Go services from one Git host to another. The code moved in a day. The remaining two-and-a-half weeks? Updating import paths across every repo, every CI pipeline, every Dockerfile, and every downstream consumer.

The worst part: they’d been through this before. Two years earlier, they migrated from SVN to Git and rewrote every import path then, too.

This keeps happening because most teams treat import paths as incidental — whatever the Git host gives them, they use. But import paths are identity. They’re how every downstream project finds your code. Change the identity, and you break everyone who depends on it.

This post is about making import paths stable — so your code can move, your infrastructure can change, and your import paths never have to.


Why Import Paths Break

Let’s catalog the situations that force import path changes:

TriggerWhat happensBlast radius
Git host migrationGitHub → GitLab, or self-hosted moveEvery module, every consumer
Org renameCompany rebrands, GitHub org name changesEvery module, every consumer
Repo transferMove a repo to a different orgThat module + all consumers
Monorepo extractionSplit a monorepo into separate reposExtracted modules + consumers
Monorepo adoptionMerge separate repos into a monorepoEvery merged module + consumers
Server relocationSelf-hosted Git server changes IP/hostnameEvery module, every consumer

Notice a pattern? Every one of these is an infrastructure change, not a code change. Your code didn’t change. Your APIs didn’t change. But every downstream project needs to update go.mod, run go mod tidy, and do a full rebuild — just because the address changed.

This is the opposite of stable identity.


The Principle: Code Moves, Imports Don’t

The fix is an old idea from distributed systems: decouple identity from location.

In Go, this is done via vanity URLs — custom import paths that you control, pointing to wherever your code actually lives:

// The import path is YOURS. You control it.
import "go.yourcompany.com/auth"

// Behind the scenes, it resolves to wherever the code is today:
// Today:    github.com/yourcompany/auth
// Next year: gitlab.yourcompany.com/auth
// Year after: gitea.yourcompany.com/auth

The import path never changes. The resolution target changes — and that’s a one-line config update on your vanity URL server, not a mass rewrite across your codebase.


Migration Playbook

When you do need to migrate Go modules (with or without vanity URLs), here’s a battle-tested checklist.

Phase 1: Before migration

1. Inventory your dependency graph

Before touching anything, understand who depends on what:

# Find all repos that import your module
# (Check your Git host's "used by" / "dependents" page, or search go.mod files)
grep -r "github.com/yourorg/auth" --include="go.mod" .

Know your blast radius. If 50 services depend on module A, you need to coordinate the update for all 50.

2. Tag the current version

Tag the last version before migration so consumers can pin to it:

git tag v1.5.0  # last version under the old import path
git push --tags

3. Set up redirect (if possible)

Some Git hosts support repo redirects. GitHub, for example, redirects old repo URLs to new ones after a rename — but only temporarily, and only for git operations. The Go module proxy (proxy.golang.org) may cache the old path, so don’t rely on redirects long-term.

4. Plan the vanity URL (if not already using one)

If your import paths are tied to a Git host, now is the time to decouple:

# Set up vanity URL before migration
gvu add go.yourcompany.com/auth https://github.com/yourcompany/auth.git

# Update the module's go.mod to use the vanity path
# (This is a one-time breaking change — but the LAST one you'll ever make)

Phase 2: During migration

5. Mirror, don’t move

During the transition, keep both the old and new repos available:

# Clone with full history
git clone --mirror https://github.com/yourcompany/auth.git
cd auth.git

# Push to new host
git push --mirror https://gitlab.yourcompany.com/yourcompany/auth.git

This preserves all tags, branches, and history.

6. Update the vanity URL target

If you’re using vanity URLs, this is the easy part:

# Point to the new host — one command
gvu add go.yourcompany.com/auth https://gitlab.yourcompany.com/yourcompany/auth.git

No downstream code changes needed. The import path go.yourcompany.com/auth stays the same.

7. If not using vanity URLs: coordinate the rewrite

Without vanity URLs, every consumer needs to update:

# In each downstream project:
# 1. Update go.mod
go mod edit -replace github.com/yourcompany/auth=gitlab.yourcompany.com/yourcompany/auth

# 2. Or do a full import path rewrite
find . -name "*.go" -exec sed -i 's|github.com/yourcompany/auth|gitlab.yourcompany.com/yourcompany/auth|g' {} +
go mod tidy

Multiply this by the number of consumers. This is where the weeks of work come from.

Phase 3: After migration

8. Verify all consumers build

# In each downstream project
go build ./...
go test ./...

9. Update CI/CD

Don’t forget:

  • CI pipeline configurations (checkout paths, Go module caches)
  • Dockerfiles (if they reference the old import path)
  • Docker Compose / Kubernetes configs (if they reference image names derived from import paths)
  • Documentation and README files

10. Clean up the old repo

After all consumers have migrated:

  • Archive (don’t delete) the old repo for at least 3 months
  • Add a README pointing to the new location
  • Keep the old vanity URL redirect active if applicable

The Stability Checklist

Once your import paths are stable, keep them that way. Here are rules to live by:

Rule 1: Own your import path

// ✅ You control the domain
import "go.yourcompany.com/auth"

// ❌ Someone else controls the path
import "github.com/yourcompany/auth"

If you don’t own the domain in your import path, you don’t own the path.

Rule 2: Never expose infrastructure in import paths

// ❌ IP addresses change
import "192.168.1.50/auth"

// ❌ Internal hostnames change
import "gitlab.internal.corp/auth"

// ✅ Domain names are stable
import "go.yourcompany.com/auth"

Rule 3: Never embed the Git host in your path

// ❌ Ties you to GitHub
import "github.com/yourcompany/auth"

// ❌ Ties you to GitLab
import "gitlab.yourcompany.com/auth"

// ✅ Host-agnostic
import "go.yourcompany.com/auth"

Rule 4: Keep paths shallow and semantic

// ✅ Short, meaningful, stable
import "go.yourcompany.com/auth"
import "go.yourcompany.com/db"

// ❌ Deeply nested, reflects org chart (which changes)
import "go.yourcompany.com/platform/team-alpha/auth-service"

Org restructurings happen. Your import paths shouldn’t reflect which VP owns which team.

Rule 5: Version through tags, not paths

// ✅ Use semantic version tags
git tag v2.0.0

// ✅ Major version suffix for breaking changes (Go convention)
import "go.yourcompany.com/auth/v2"

// ❌ Version in the repo name
import "go.yourcompany.com/auth-v2"

Rule 6: Test your vanity URL resolution

# Verify the meta tag is served correctly
curl -s 'https://go.yourcompany.com/auth?go-get=1' | grep go-import

# Expected output:
# <meta name="go-import" content="go.yourcompany.com/auth git https://gitlab.yourcompany.com/yourcompany/auth">

# Verify go get works
GOPROXY=direct go get go.yourcompany.com/auth@latest

Vanity URL Migration: The One-Minute Version

If you’re already using vanity URLs, migration is trivial:

# Before: code lives on GitHub
gvu add go.yourcompany.com/auth https://github.com/yourcompany/auth.git

# ... migration happens ...

# After: point to new host. Import paths unchanged.
gvu add go.yourcompany.com/auth https://gitlab.yourcompany.com/yourcompany/auth.git

That’s it. Every downstream go get go.yourcompany.com/auth now resolves to the new host. No code changes in any consumer project.

For organizations with many modules, a single wildcard rule handles everything:

# One rule covers all repos under the org
gvu add 'go.yourcompany.com/*' 'https://github.com/yourcompany/*.git'

# Migration? Update the rule target
gvu remove <route-id>
gvu add 'go.yourcompany.com/*' 'https://gitlab.yourcompany.com/yourcompany/*.git'

One command to migrate 50 modules. This is what “code moves, imports don’t” looks like in practice.


Cost of Instability vs. Cost of Stability

ApproachSetup costMigration cost (per event)Frequency
Raw Git host pathsZeroWeeks of engineering timeEvery host change
Vanity URLs~30 minutesMinutes (update route target)Once, then never
Vanity URLs + wildcard~5 minutesMinutes (update one rule)Once, then never

The setup cost of vanity URLs is a rounding error compared to a single migration event. And you only set it up once.


Summary

Import path stability is an engineering discipline, not a nice-to-have. The rules are simple:

  1. Own your import path — use a domain you control
  2. Never expose infrastructure — no IPs, no internal hostnames
  3. Never embed the Git host — decouple identity from location
  4. Keep paths shallow — don’t reflect org structure
  5. Version through tags — not repo names
  6. Verify regularly — test your vanity URL resolution

Follow these rules, and your next Git host migration takes five minutes instead of five weeks.

Set up stable import paths with gvu — one command per module, or one wildcard rule per org.