Third-Party Modules and go get
Go’s standard library covers a lot of ground, but no real project is built from the standard library alone — sooner or later you need an HTTP router, a database driver, or a well-tested utility that someone else already wrote and maintains. In Go, that outside code arrives as a module: a versioned, checksummed unit of packages that your project depends on explicitly. This lesson covers how Go modules and the go get command work end to end — the go.mod and go.sum files, how versions get chosen, how dependencies are fetched and verified, and the workflow for adding, upgrading, downgrading, and removing them safely.
Overview / How it works
A module is a collection of Go packages that are released, versioned, and depended upon together, rooted at a directory containing a go.mod file. The module’s identity is its module path — usually the location where its source is hosted, such as github.com/gorilla/mux — and that same path is what you write in an import statement to use any package inside it. Before modules existed (roughly before Go 1.11), all Go code lived inside one global workspace defined by the GOPATH environment variable, and there was no built-in way to record which exact version of a dependency your project needed — everyone just got whatever was currently checked out on disk. Modules fixed that by making dependency versions an explicit, recorded, and verifiable part of the project itself.
Two files do the work. go.mod is the manifest at the root of your module. It declares the module’s own path, the minimum Go version it needs, and a list of require directives — one per dependency, each pinned to an exact version. go.sum is a lock file: for every module version reachable from your build, it stores a cryptographic hash of that module’s source and of its go.mod file. Together they mean that you, your teammates, and your CI server all build against byte-for-byte identical dependency code, and Go will refuse to silently substitute something different.
go get is the command that edits these files for you. Since Go 1.17, go get only manages module requirements — it does not build or install binaries anymore. If you want to install a command-line tool, use go install module/path@version instead; go get is purely about what versions your go.mod requires.
Under the hood, when you run go get example.com/mod@version, Go does not go straight to the source repository. By default it asks a module proxy (the public proxy.golang.org, configurable via the GOPROXY environment variable) for the list of available versions and for a zip of the requested version’s source. It then checks the hash of that zip against a global, append-only checksum database (via GOSUMDB, default sum.golang.org) — unless the module path matches your GOPRIVATE setting, in which case Go talks to your private source directly and skips the public proxy and sumdb entirely. Once verified, the module is unpacked into a local, read-only module cache at $GOPATH/pkg/mod, shared by every project on the machine, so the same version is only ever downloaded and verified once.
When several dependencies each require different versions of the same underlying module, Go must pick one version to actually build with. It does this with an algorithm called Minimal Version Selection (MVS): for every module in the dependency graph, Go selects the highest version requested by anything in the build — never higher, never lower than necessary. This keeps builds deterministic and avoids surprise upgrades: adding one new dependency can only raise a shared dependency’s version as far as something actually requires, never further.
Modules are versioned using semantic versioning: vMAJOR.MINOR.PATCH, for example v1.5.2. Go treats major version 2 and above specially. Because a major version bump signals a breaking change, the import path itself must include the major version suffix, such as github.com/foo/bar/v2. This is called semantic import versioning, and it lets v1 and v2 of the same module coexist as completely different packages in the same build without conflicting. You’ll also encounter pseudo-versions like v0.0.0-20230101000000-abcdef123456 — these are auto-generated version strings Go creates when you depend on a specific commit that was never given a proper release tag.
Syntax
The general form of the command is:
go get [-u] [-t] [-v] module/path[@version]
| Form | Meaning |
|---|---|
go get module/path |
Add the dependency, or upgrade it to the latest version compatible with existing requirements. |
go get module/path@v1.5.2 |
Require an exact version, tag, branch name, or commit hash. |
go get module/path@latest |
Require the latest available tagged release. |
go get module/path@none |
Remove the requirement on that module entirely. |
go get -u ./... |
Upgrade every dependency used by the current module to its latest minor/patch release. |
go get -u=patch module/path |
Upgrade only to the latest patch release, not a new minor version. |
go mod tidy |
Add missing requirements and drop unused ones so go.mod/go.sum exactly match the code. |
go mod download |
Populate the local module cache and go.sum without changing go.mod. |
go mod verify |
Re-check that cached modules on disk still match the hashes recorded in go.sum. |
go list -m all |
Print every module in the current build list, with its selected version. |
Inside go.mod itself, the directives you’ll see most often are:
module— declares this module’s own import path.go— the minimum Go language version the module requires.require— one dependency and its exact version; a trailing// indirectcomment marks a dependency your code doesn’t import directly, but some direct dependency needs.replace— substitutes one module (or version) for another at build time, commonly used to point at a local checkout during development.exclude— forbids a specific version of a dependency from ever being selected.
Examples
Example 1 — Creating a module
Every module starts life as a directory with a go.mod file. Running go mod init creates it:
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
That produces a minimal manifest:
module example.com/hello
go 1.21
With that in place, a plain program using only the standard library already builds and runs as a module — no third-party code needed yet:
package main
import "fmt"
func main() {
fmt.Println("Hello from a Go module!")
}
Output:
Hello from a Go module!
Nothing about this program required a dependency, but the presence of go.mod is what makes it a module — every subsequent command, including go get, operates relative to this file.
Example 2 — Adding a dependency with go get
Now suppose the program needs code from outside the standard library. Running go get with a module path and version fetches it and records it:
$ go get rsc.io/quote@v1.5.2
go: added golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: added rsc.io/quote v1.5.2
go.mod now lists both the module we asked for and one it needed internally, marked // indirect because our own code never imports it directly:
module example.com/hello
go 1.21
require rsc.io/quote v1.5.2
require golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
go.sum gains matching checksum lines (hash values abbreviated here for illustration; real ones are full-length base64):
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c h1:qhNJDaVUbAoXqXPBaZC0gY5jc6t72yjeUFZeTQfXe0T=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:Nq7d3XkDZq5v0y+2wcYlrLpb1mZLNuXhb1RwWQrqM4A=
rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b5eR8dJAeY=
rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjM30qNGZzmzrM4/eaZ8=
With the requirement recorded, the import becomes usable in code:
package main
import (
"fmt"
"rsc.io/quote"
)
func main() {
fmt.Println(quote.Go())
}
Output:
Don't communicate by sharing memory, share memory by communicating.
This is the well-known example from the official Go tutorials: quote.Go() simply returns a fixed string, but the important part is everything that happened before main ran — resolution, download, checksum verification, and recording — all triggered by that single go get command.
Example 3 — Upgrading, downgrading, and removing
go get handles the full lifecycle of a dependency, not just adding it:
$ go get rsc.io/quote@v1.5.1
go: downgraded rsc.io/quote v1.5.2 => v1.5.1
$ go get -u ./...
go: upgraded rsc.io/quote v1.5.1 => v1.5.2
$ go get rsc.io/quote@none
go: removed rsc.io/quote v1.5.2
$ go mod tidy
The first command pins an older release. The second sweeps every dependency the module actually uses up to its latest compatible release. The third drops the requirement entirely — after this, the import in the source code would no longer resolve, so in practice you’d remove the import first. go mod tidy at the end reconciles go.mod and go.sum with whatever the code currently imports, adding anything missing and deleting anything no longer used.
How it works step by step
Here is what actually happens when you run go get rsc.io/quote@v1.5.2 inside a module:
- Go parses the module path and the requested version (a tag, branch, commit, or
latest/none). - It queries the configured
GOPROXYfor the module’s available versions and metadata for the chosen one. - It downloads the module’s source as a zip file from the proxy.
- It hashes that zip and checks the hash against the checksum database configured by
GOSUMDB, unless the module is covered byGOPRIVATEor already trusted via an existinggo.sumentry. - The verified module is extracted into the shared, read-only local module cache at
$GOPATH/pkg/mod. - Go recomputes the whole build list using Minimal Version Selection, which may add new indirect requirements or bump existing ones to satisfy the new dependency’s own
go.mod. go.modis rewritten with the updatedrequiredirectives, andgo.sumgets new checksum lines for every newly reachable module and itsgo.modfile.- Future
go build,go run, andgo testcommands reuse the cached, already-verified copy — no network access is needed again unless the cache is cleared or a new version is requested.
Common Mistakes
Mistake 1: Hand-editing go.sum
Deleting or editing lines in go.sum by hand breaks the link between what’s recorded and what’s actually in the module cache. The next build fails with a message like this:
$ go build .
go: rsc.io/quote@v1.5.2: missing go.sum entry; to add it:
go mod download rsc.io/quote
$ go mod tidy
$ go build .
go.sum is a machine-generated ledger, not a file meant for manual editing. The fix is always to run go mod download or go mod tidy and let Go regenerate the correct entries.
Mistake 2: Importing a package before fetching it
It’s tempting to write the import line first and run the program afterward, expecting Go to fetch the module automatically the way some other language’s package managers do. Since Go 1.16, go build and go run no longer modify go.mod on your behalf — you get an explicit error instead:
$ go build .
no required module provides package rsc.io/quote; to add it:
go get rsc.io/quote
$ go get rsc.io/quote
$ go build .
The error message even tells you the exact command to run. This is deliberate: it keeps dependency changes explicit and visible in your commit history instead of happening silently as a side effect of building.
Mistake 3: Using go get to install a command-line tool
Before Go 1.17, go get would both add a requirement and build/install a binary if the target was a main package. That dual behavior is gone. Inside a module, go get now only ever edits go.mod/go.sum — it never places a binary in $GOBIN:
$ go get github.com/mitchellh/gox
// adds gox to go.mod as a dependency, but installs no binary
$ go install github.com/mitchellh/gox@latest
// builds gox and installs it to $GOBIN (or $HOME/go/bin by default)
If you want a runnable command-line tool on your PATH, always reach for go install module/path@version, not go get.
Best Practices
- Always commit both
go.modandgo.sumto version control — together they make builds reproducible for every teammate and every CI run. - Run
go mod tidybefore committing so the manifest exactly matches what your code imports, with nothing missing or stale. - Prefer pinning explicit versions in automated workflows rather than
@latest, so a dependency’s new release can’t change your build without you noticing. - Use
go list -m allorgo mod graphto inspect the full dependency tree before pulling in a large or unfamiliar module. - Use
go install module/path@versionfor command-line tools; reservego getfor managing your module’s own requirements. - Set
GOPRIVATEfor internal or private modules so Go skips the public proxy and checksum database for paths that will never be publicly reachable anyway. - Only use
replacedirectives for local development or testing forks; remove them before publishing a library, since they override what consumers actually get. - Treat every new dependency as code you now ship and are responsible for — check its maintenance activity, license, and size before adding it, not after.
- Use
go mod verifyperiodically to confirm the local module cache hasn’t been altered since it was downloaded.
Practice Exercises
- Create a new module with
go mod init, add any small, actively maintained dependency from pkg.go.dev withgo get, write a few lines of code that call one exported function from it, and rungo mod tidy. Inspect the resultinggo.modandgo.sumand identify which lines are direct requirements and which are marked// indirect. - In a module with two dependencies that each require different versions of some shared third module, run
go mod graphand figure out which version Minimal Version Selection actually chose for the shared module, and why it picked that one rather than the other. - Starting from a module that requires
rsc.io/quote@v1.5.2, rungo get rsc.io/quote@v1.5.1to downgrade it, observe the change ingo.mod, then remove the import from your code and rungo mod tidy. Confirm the requirement disappears fromgo.modentirely.
Summary
- A module is a versioned collection of packages identified by an import path;
go.modis its manifest andgo.sumis its checksum lock file. go getmanages module requirements — adding, upgrading, downgrading, or removing them — and since Go 1.17 no longer installs binaries; usego install module/path@versionfor that.- Dependencies are fetched through a module proxy, verified against a checksum database, and cached locally so later builds are fast and mostly offline.
- Go resolves version conflicts with Minimal Version Selection, always choosing the lowest version that still satisfies every requirement in the build.
- Major version 2 and above requires a version suffix in the import path itself (semantic import versioning), letting incompatible major versions coexist.
- Always commit
go.modandgo.sum, rungo mod tidyregularly, and vet every new dependency before adding it.
