Go modules: what they are, how they work and why you should stop learning GOPATH

Practical guide to go modules: go.mod, go.sum, dependencies, versioning and common errors. Forget GOPATH.

Cover for Go modules: what they are, how they work and why you should stop learning GOPATH

Many Go tutorials still explain GOPATH as if it were 2017. Don’t do it. Since Go 1.16, modules are the default system for managing dependencies, and since Go 1.22 GOPATH as a development workspace is basically a historical artifact. If you’re learning Go now, learning with GOPATH at the center is learning from an old photo. Modules solve the dependency problem in an explicit, reproducible way and without directory magic.

If you come from other ecosystems, think of go.mod as your package.json, Cargo.toml or build.gradle.kts, but simpler and without a separate package manager. Everything is integrated into the go command itself.

If you’re still deciding whether Go is worth your time, take a look at getting started with Go before continuing here.


What is a module in Go

A module is a collection of Go packages that are versioned and distributed together. In practice, a module is a directory that contains a go.mod file at its root. That file defines three fundamental things:

  1. The module path: the identity of the module, which is also the base path for importing its packages.
  2. The minimum Go version it requires.
  3. The dependencies: which other modules it needs and at which exact version.

Each repository usually contains a single module. You can have multiple, but don’t do it unless you have a very clear reason (monorepos with independent components, for example).

The key difference from GOPATH: before, all your code and all your dependencies lived in a single directory tree under $GOPATH/src. There was no real versioning, no reproducibility, and two projects that needed different versions of the same library… tough luck. Modules eliminate all of that.


Anatomy of go.mod

A typical go.mod looks like this:

module github.com/your-user/my-project

go 1.22

require (
    github.com/gin-gonic/gin v1.10.0
    github.com/jackc/pgx/v5 v5.6.0
    go.uber.org/zap v1.27.0
)

require (
    // indirect dependencies
    github.com/bytedance/sonic v1.11.8 // indirect
    github.com/gabriel-vasile/mimetype v1.4.4 // indirect
    // ... more indirect dependencies
)

Breakdown:

  • module github.com/your-user/my-project: the module path. If your code is going to be imported by others, this must match the repository URL. If it’s a binary that nobody will import, you can put whatever you want, but the convention is still to use the repo URL.
  • go 1.22: the minimum Go version. It’s not decorative. Go uses this directive to decide which language features are available and how dependencies are resolved.
  • require: the direct and indirect dependencies. Go separates direct from indirect ones with an // indirect comment.

Indirect dependencies are modules that your direct dependencies need, but that you don’t import directly. Go records them in go.mod to guarantee reproducible builds.


Creating your first module: go mod init

Creating a module is a single command:

mkdir my-project
cd my-project
go mod init github.com/your-user/my-project

That generates a minimal go.mod:

module github.com/your-user/my-project

go 1.22

From here you can already create .go files, import standard packages and compile. When you add an import from an external package, Go will download the dependency automatically when compiling or when running go mod tidy.

A detail that confuses people coming from Node or Python: you don’t need a command like npm install or pip install before writing code. You write the import, run go build or go run, and Go resolves the dependency. That straightforward.

package main

import (
    "fmt"
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{"message": "pong"})
    })
    fmt.Println("Server starting on :8080")
    r.Run()
}

When you run go run . for the first time, Go downloads Gin and all its transitive dependencies, updates go.mod and generates go.sum.

If you need a broader view of how to organize the code within the module, look at project structure.


Adding dependencies: go get and go mod tidy

There are two main ways to add dependencies.

go get

go get downloads a module and adds it to your go.mod:

go get github.com/jackc/pgx/v5@latest

You can specify specific versions:

go get github.com/jackc/pgx/v5@v5.6.0
go get github.com/jackc/pgx/v5@v5.5.0   // downgrade
go get github.com/jackc/pgx/v5@abc1234   // specific commit

Since Go 1.18, go get no longer compiles or installs binaries. It only modifies go.mod and go.sum. If you want to install a CLI tool, use go install:

go install golang.org/x/tools/gopls@latest

This distinction is important. go get manages your project’s dependencies. go install installs executables.

go mod tidy

go mod tidy is the command you’ll use most day to day. It does two things:

  1. Adds the dependencies your code imports but that aren’t in go.mod.
  2. Removes the dependencies that are in go.mod but that your code no longer uses.
go mod tidy

My recommendation: run it always before committing. It’s the most reliable way to keep go.mod clean and in sync with your actual code. Don’t trust that go build has already done everything; go build adds missing dependencies, but doesn’t remove the ones that are no longer needed.


go.sum: what it is and why you shouldn’t add it to .gitignore

When Go downloads a module, it calculates a cryptographic hash of its content and saves it in go.sum. The file looks like this:

github.com/gin-gonic/gin v1.10.0 h1:ABC123...=
github.com/gin-gonic/gin v1.10.0/go.mod h1:DEF456...=

Each entry has two hashes: one for the full module content and another for its go.mod. This allows verifying the integrity of dependencies without downloading them entirely.

The question everyone asks: “If I already have the versions in go.mod, why do I need go.sum?”

Because go.mod says which version to use. go.sum verifies that that version hasn’t been tampered with. It’s a security measure. If someone compromises a repository and publishes a v1.10.0 version with different content, the hash won’t match and Go will refuse to compile.

go.sum goes into the repository. Always. Don’t put it in .gitignore. The security of your dependency chain depends on it. Go also consults the Go checksum database to validate hashes against a public registry, but the local go.sum is your first line of defense.

A common mistake: merging branches and having conflicts in go.sum. The solution is simple: accept either of the two versions and run go mod tidy. Go will regenerate the correct hashes.


Semantic versioning in Go: the import path convention

Go adopts semantic versioning (semver), but adds its own rule that is unique in the ecosystem: from v2 onwards, the major version is part of the import path.

This means that:

import "github.com/jackc/pgx"       // v0.x or v1.x
import "github.com/jackc/pgx/v5"    // v5.x

Are, as far as the compiler is concerned, different modules. You can import both in the same project without conflict. This is intentional: Go treats a major version change as a new module, which avoids the incompatible dependency hell.

For library maintainers, this means that when publishing v2 of your module, you have to:

  1. Update the module path in go.mod to include /v2.
  2. Update all internal imports.
  3. Create the v2.0.0 tag.
// go.mod for v2
module github.com/your-user/my-library/v2

go 1.22

It’s more work than in npm or Cargo, where you simply change the version number. But the result is that in Go it’s literally impossible for a major version update to break your build without you having explicitly imported it. There’s no equivalent of npm install breaking everything because a transitive dependency bumped its major version.

Pre-release and pseudo-versions

If you need to use a commit that has no tag, Go generates a pseudo-version:

require github.com/something/something v0.0.0-20240115140000-abc1234def56

The format is vX.Y.Z-YYYYMMDDHHMMSS-commitHash. Don’t write it by hand. Use go get github.com/something/something@abc1234 and Go generates it for you.


The replace directive: replacing dependencies

replace is one of the most useful tools in go.mod and one of the worst documented. It allows redirecting a module to another location or version.

Local development

The most common use case is working with a local fork or a library you’re modifying at the same time as your project:

module github.com/your-user/my-project

go 1.22

require github.com/your-user/my-library v1.3.0

replace github.com/your-user/my-library => ../my-library

With this, Go uses the local code at ../my-library instead of downloading the published version. Perfect for development, but never publish a module with a replace pointing to a local path. It only works on your machine.

Forks and fixes

If you need to use a fork of a dependency because it has a bug fix that hasn’t been published yet:

replace github.com/original-library/something => github.com/your-fork/something v1.3.1-fix

Retract: marking versions as defective

Since Go 1.16, maintainers can mark versions as retracted in their go.mod:

retract (
    v1.2.0 // contains a critical bug in the parser
    [v1.3.0, v1.3.5] // range of defective versions
)

go get will automatically avoid retracted versions and will warn you if you’re already using them.


Private modules: GOPRIVATE and enterprise repos

By default, Go tries to resolve modules through the public proxy (proxy.golang.org) and validate their hashes against the checksum database (sum.golang.org). That doesn’t work with private repositories.

The GOPRIVATE environment variable tells Go which modules should be resolved directly against the repository, bypassing both the proxy and the checksum database:

go env -w GOPRIVATE="github.com/your-company/*,gitlab.company.com/*"

For more complex patterns, there are two additional variables:

  • GONOSUMCHECK: modules that aren’t validated against the checksum database (but do use the proxy).
  • GONOPROXY: modules that don’t go through the proxy (but are validated against the checksum database).

GOPRIVATE is equivalent to setting the same value in both.

Authentication with private repos

Go uses Git under the hood to clone repositories. If your private repo uses HTTPS, you need to configure Git to authenticate:

// In ~/.gitconfig or ~/.config/git/config
[url "https://oauth2:YOUR_TOKEN@gitlab.company.com/"]
    insteadOf = https://gitlab.company.com/

For GitHub, another option is to configure gh auth or use a .netrc:

// ~/.netrc
machine github.com
login your-user
password ghp_YOUR_PERSONAL_TOKEN

If working with SSH:

[url "ssh://git@github.com/"]
    insteadOf = https://github.com/

A recurring problem in CI/CD: the pipeline doesn’t have access to the private repository. The usual solution is to inject a token as an environment variable and configure the insteadOf in the setup step.


Common errors and how to fix them

These are the module errors I’ve seen most, both in my own code and others’.

“cannot find module providing package X”

main.go:5:2: no required module provides package github.com/something/something; to add it:
    go get github.com/something/something

The import exists in your code but the dependency isn’t in go.mod. Solution:

go get github.com/something/something
// or better:
go mod tidy

“ambiguous import”

Occurs when two modules export the same package path. It usually happens with modules that have migrated to v2 but you have mixed imports. Review your imports and make sure they all point to the same major version.

”checksum mismatch”

verifying github.com/something/something@v1.2.0: checksum mismatch

The hash of the downloaded module doesn’t match what’s recorded in go.sum. Possible causes:

  1. Corrupted cache: go clean -modcache and download again.
  2. The module has been altered: someone republished a version with different content. This shouldn’t happen with public modules thanks to the checksum database, but it can occur with private modules.
  3. Merge conflict in go.sum: resolve the conflict and run go mod tidy.

”module declares its path as X but was required as Y”

The go.mod of the module you’re importing says a different module path than the one you used in your require or import. This happens with forks: you clone a repo, change the code, but don’t update the module line in its go.mod. The module still says it’s github.com/original/repo, but you import it as github.com/your-fork/repo. Use replace to fix it.

”go.mod has post-v0 module path but no major version suffix”

Your go.mod says module github.com/something/something but the tag is v2.0.0 or higher. From v2 onwards, the module path must include /v2. It’s a rule Go enforces strictly.

go.sum out of date

go: updates to go.sum needed, disabled by -mod=readonly

Appears in CI when someone made changes to dependencies but forgot to run go mod tidy before committing. Solution: run go mod tidy locally and commit the changes in go.mod and go.sum.


go mod tidy, go mod vendor, go mod download

Three subcommands that sound similar but do different things.

go mod tidy

We’ve already seen it: synchronizes go.mod and go.sum with the actual imports of your code. Adds what’s missing, removes what’s no longer needed. It’s the command you’ll use most.

go mod tidy

It has a useful option: go mod tidy -v shows you which modules it has added or removed. Use it when go mod tidy makes unexpected changes and you want to understand why.

go mod vendor

Copies all dependencies to a vendor/ directory inside your project:

go mod vendor

With vendor/, your project can be compiled without internet access and without depending on proxy.golang.org. It’s useful in:

  • Environments with restricted internet access (air-gapped).
  • When you want total control over third-party code.
  • Some CI pipelines where you don’t want to download dependencies on every build.

To compile using the vendor directory:

go build -mod=vendor ./...

Since Go 1.22, if a vendor/ directory exists, Go uses it automatically. You don’t need the -mod=vendor flag explicitly.

My opinion: vendor makes sense in projects that deploy in restrictive environments or that need security audits on third-party code. For everything else, the module cache and the proxy are sufficient. A vendor/ directory with thousands of files clutters the repository and makes diffs unreadable when you update dependencies.

go mod download

Downloads all dependencies to the local cache without compiling anything:

go mod download

It’s especially useful in Dockerfiles, where you can take advantage of layer caching:

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o /app ./cmd/server

By copying only go.mod and go.sum first, Docker reuses the go mod download layer as long as dependencies don’t change. This dramatically speeds up builds when you only change source code.

go mod graph and go mod why

Two diagnostic commands that few people know but that are very useful:

go mod graph

Prints the full dependency graph in text format. It’s useful for understanding why a transitive dependency is there.

go mod why github.com/something/something

Tells you exactly which chain of imports causes that module to be a dependency. When you see a module in go.mod that you don’t recognize and want to know who brought it in, go mod why gives you the answer.


Workspace mode: go.work

Since Go 1.18, there’s an additional mode: workspaces. A go.work file in a parent directory lets you work with multiple modules simultaneously without using replace:

go 1.22

use (
    ./my-project
    ./my-library
)

With this, if my-project imports my-library, Go automatically uses the local copy. It’s cleaner than replace for multi-module development, because you don’t modify any project’s go.mod.

go work init ./my-project ./my-library

Don’t commit go.work to the repository (unless your entire team works with the same directory structure). go.work is a local development tool, like a .env. Add it to .gitignore.

For more details on the go command and all its options, check the dedicated article.


Forget GOPATH and don’t look back

If there’s one thing I’d like to have told my past self, it’s to stop fighting with GOPATH and $GOPATH/src/github.com/.... Modules changed the way of working with dependencies in Go from the ground up: go.mod defines your module explicitly, go.sum guarantees the integrity of everything you download, and go mod tidy takes care of keeping both files clean. You don’t need more.

The rest of the system fits together naturally once you internalize those three pieces. go get to add or update dependencies, go install for binaries, replace for local development and forks, GOPRIVATE for private repos, and vendor/ only when you have a concrete reason to use it. v2+ versions with their different import path seem strange at first, but when you understand that it’s a mechanism to avoid silent incompatibilities, it stops being annoying.

It’s not the most flexible dependency system in the world. But that rigidity is precisely what makes it work: reproducible builds, no surprises between machines, no magical directory configuration. After having worked with Maven, pip and npm, I value a lot that go mod tidy does the right thing without me having to think about it.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved