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 // indirect comment 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:

  1. Go parses the module path and the requested version (a tag, branch, commit, or latest/none).
  2. It queries the configured GOPROXY for the module’s available versions and metadata for the chosen one.
  3. It downloads the module’s source as a zip file from the proxy.
  4. It hashes that zip and checks the hash against the checksum database configured by GOSUMDB, unless the module is covered by GOPRIVATE or already trusted via an existing go.sum entry.
  5. The verified module is extracted into the shared, read-only local module cache at $GOPATH/pkg/mod.
  6. 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.
  7. go.mod is rewritten with the updated require directives, and go.sum gets new checksum lines for every newly reachable module and its go.mod file.
  8. Future go build, go run, and go test commands 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.mod and go.sum to version control — together they make builds reproducible for every teammate and every CI run.
  • Run go mod tidy before 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 all or go mod graph to inspect the full dependency tree before pulling in a large or unfamiliar module.
  • Use go install module/path@version for command-line tools; reserve go get for managing your module’s own requirements.
  • Set GOPRIVATE for internal or private modules so Go skips the public proxy and checksum database for paths that will never be publicly reachable anyway.
  • Only use replace directives 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 verify periodically to confirm the local module cache hasn’t been altered since it was downloaded.

Practice Exercises

  1. Create a new module with go mod init, add any small, actively maintained dependency from pkg.go.dev with go get, write a few lines of code that call one exported function from it, and run go mod tidy. Inspect the resulting go.mod and go.sum and identify which lines are direct requirements and which are marked // indirect.
  2. In a module with two dependencies that each require different versions of some shared third module, run go mod graph and figure out which version Minimal Version Selection actually chose for the shared module, and why it picked that one rather than the other.
  3. Starting from a module that requires rsc.io/quote@v1.5.2, run go get rsc.io/quote@v1.5.1 to downgrade it, observe the change in go.mod, then remove the import from your code and run go mod tidy. Confirm the requirement disappears from go.mod entirely.

Summary

  • A module is a versioned collection of packages identified by an import path; go.mod is its manifest and go.sum is its checksum lock file.
  • go get manages module requirements — adding, upgrading, downgrading, or removing them — and since Go 1.17 no longer installs binaries; use go install module/path@version for 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.mod and go.sum, run go mod tidy regularly, and vet every new dependency before adding it.