Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

finally on go modules, and saying goodbye to dep

Migrating an old Go project from dep and GOPATH to modules, the gotchas that bit me, and why the new dependency story is genuinely better.

A code editor showing Go source files

I have a Go service that has been sat on dep and a Gopkg.toml since before modules were a thing, and I finally migrated it. I had been putting this off for years, partly out of inertia and partly because the project worked and nobody likes touching the thing that works. But dep has been effectively in maintenance mode for a long while, the wider ecosystem moved on, and pretending I still lived inside GOPATH was getting harder to justify in 2022.

The short version: the migration was far less painful than the dread I had built up around it, and the result is plainly better. The long version has some sharp edges worth writing down before I forget them.

the actual migration

The starting point was the usual ancient layout: source under $GOPATH/src/..., a vendor/ directory, and Gopkg.toml and Gopkg.lock describing the world. The first step is genuinely one command:

$ go mod init github.com/jmylchreest/theservice
go: creating new go.mod: module github.com/jmylchreest/theservice
go: copying requirements from Gopkg.lock

go mod init is polite enough to read your existing Gopkg.lock and seed go.mod from it. So your pinned versions come across rather than resolving to whatever is newest, which is exactly what you want for a first pass. Then:

$ go mod tidy

tidy is the one that does the real work. It walks every import in the code, adds anything missing to go.mod, removes anything you no longer use, and writes go.sum with the cryptographic checksums. This is also where the migration tells you the truth about your dependency graph, including the bits you forgot were there.

Go module output in a terminal

the bits that bit me

A few things caught me out, none fatal, all annoying.

Pseudo-versions for unreleased commits. Some of my dependencies were pinned in Gopkg.lock to a bare commit hash, because the upstream had never tagged a release. Modules represents these as pseudo-versions, the v0.0.0-20211130120000-abcdef123456 form: a base version, a timestamp, and the commit. They look ugly and they are entirely correct. I had a small panic that something was wrong before I remembered this is just how modules name an untagged commit.

The +incompatible suffix. A couple of libraries had moved to a v2 or higher without adopting the /v2 import path convention that modules expect for major versions. Go tags these with +incompatible and carries on. It is not an error, it is Go telling you the library is not playing by the module versioning rules, and that it has handled it for you anyway.

One genuinely renamed import path. A dependency had moved hosts and the old path was a stale redirect that dep had been quietly following. Modules was stricter and wanted the real path. Fixing it was a find-and-replace across imports plus a go mod tidy, but it was the one change that touched actual source.

I deleted vendor/, Gopkg.toml and Gopkg.lock. For CI I considered keeping vendoring with go mod vendor, and for a service with a locked-down build environment that is still a reasonable call. In the end I left it un-vendored and leaned on the module cache and go.sum for integrity, because the build environment has network access and GOFLAGS=-mod=readonly keeps it honest about not silently changing dependencies on me.

why it is actually better

Two things make this worth the afternoon. First, go.sum gives you verifiable integrity on every dependency, checked on every build, which dep never did to the same standard. Second, and this is the one I underrated, escaping GOPATH means the project can live anywhere on disk. No more symlink gymnastics to get a checkout into the one true directory the toolchain insisted on. I can clone the repo wherever I like and go build just works.

$ go build ./...
$ go test ./...
ok      github.com/jmylchreest/theservice/internal/agg   0.184s
ok      github.com/jmylchreest/theservice/internal/api   0.412s

Green on the first try, which after the build-up I had given this in my head felt almost anticlimactic.

The thing I keep coming back to is how long I let the fear of a migration outweigh the fact that the tooling had genuinely improved. dep did good service in its day and got Go through an awkward adolescence. But modules are the supported path now, they solve real problems dep could not, and the move took an afternoon for a project I had been quietly dreading for years. If you are still on dep, or worse still living inside GOPATH out of habit, the water is fine. Run go mod init, run go mod tidy, read what it tells you, and delete the vendor directory with the small thrill of throwing out something you no longer need.