Before Go modules, Go projects were tightly connected to GOPATH. Source code lived under $GOPATH/src, installed packages were placed there too, and switching between projects often meant thinking about workspace layout before writing code.
Go modules changed that. A module can live anywhere on your machine, it can declare its dependencies in go.mod, and the Go command can download exact versions when needed.
In this article, we will walk through modules using a small example package called nummanip. We will create a module, publish versions, consume it from another module, upgrade patch versions, handle a v2 release, and look at useful commands such as replace, tidy, and vendor.
Before Modules: go get And GOPATH
Go packages are imported using paths that often look like URLs.
import "github.com/username/packagename"
Before modules, installing that package usually meant:
go get github.com/username/packagename
The Go command downloaded the repository and stored it under $GOPATH/src/github.com/username/packagename.
That worked, but it had two big problems:
- You had to care about
GOPATH. - You could not safely keep multiple versions of the same dependency for different projects.
Modules solve both problems.
How Remote Import Paths Work
Go does not need a central package registry like npm. A package path can point to a repository hosted on GitHub, Bitbucket, or another version control system.
For common hosting providers, Go knows how to resolve paths such as:
github.com/thatisuday/nummanip
For custom domains, Go looks for a go-import meta tag in the HTML response.
<meta name="go-import" content="import-prefix vcs repo-root" />
For example:
<meta
name="go-import"
content="domain.com/some/sub/directory git https://domain.com/repos/name.git"
/>
This tells Go that:
domain.com/some/sub/directoryis the import path.gitis the version control system.https://domain.com/repos/name.gitis the real repository URL.
In normal day-to-day Go work, you will mostly use GitHub-style import paths, but this mechanism is what makes custom module domains possible.
Creating A Module
Let’s create a module named nummanip. This module will expose number-manipulation packages.
mkdir nummanip
cd nummanip
git init
go mod init github.com/thatisuday/nummanip
The go mod init command creates a go.mod file.
module github.com/thatisuday/nummanip
go 1.22
The module path is important. Other projects will import packages from this module using that path.
Now create a package named calc.
nummanip/
├── calc/
│ └── math.go
└── go.mod
Here is the first version of calc/math.go.
package calc
// Add returns the sum of two integers.
func Add(i int, j int) int {
return i + j
}
Because calc is a nested package, consumers will import it with:
import "github.com/thatisuday/nummanip/calc"
If the package code lived directly beside go.mod, consumers would import the module path itself instead.
Publishing Version v1.0.0
A Go module version is normally a Git tag that follows semantic versioning.
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/thatisuday/nummanip.git
git push -u origin main
git tag v1.0.0
git push --tags
Go module tags must start with a lowercase v, such as v1.0.0.
Semantic versioning has three main parts:
vMAJOR.MINOR.PATCH
Use:
PATCHfor bug fixes.MINORfor backward-compatible features.MAJORfor breaking changes.
This matters a lot in Go because major versions after v1 affect the module import path.
Consuming The Module
Now create another module that uses nummanip.
mkdir main
cd main
go mod init main
Create app.go.
package main
import (
"fmt"
"github.com/thatisuday/nummanip/calc"
)
func main() {
result := calc.Add(1, 2)
fmt.Println("calc.Add(1, 2) =>", result)
}
Run the program.
go run app.go
The Go command sees the imported module, downloads it, updates go.mod, and creates go.sum.
module main
go 1.22
require github.com/thatisuday/nummanip v1.0.0
The go.sum file stores checksums for downloaded modules. It is not exactly the same thing as package-lock.json, but you should commit it for reproducible and verified builds.
Downloaded modules are cached under the module cache, usually inside:
$GOPATH/pkg/mod
That cache lets multiple projects reuse the same downloaded module versions.
Publishing A Patch Version
Let’s improve Add so it can accept any number of integers.
package calc
// Add returns the sum of all provided integers.
func Add(numbers ...int) int {
sum := 0
for _, num := range numbers {
sum += num
}
return sum
}
Now commit and tag a patch release.
git add .
git commit -m "Add accepts variadic arguments"
git tag v1.0.1
git push
git push --tags
In the consuming module, update the dependency:
go get github.com/thatisuday/nummanip@v1.0.1
Then use the new behavior:
package main
import (
"fmt"
"github.com/thatisuday/nummanip/calc"
)
func main() {
result := calc.Add(1, 2, 3)
fmt.Println("calc.Add(1, 2, 3) =>", result)
}
The updated go.mod now points to v1.0.1.
module main
go 1.22
require github.com/thatisuday/nummanip v1.0.1
Upgrading To A Major Version
Patch and minor versions should be backward compatible. Major versions are different. If a new version breaks old code, it should become a new major version.
Let’s change Add so it returns an error when fewer than two numbers are provided.
package calc
import "errors"
// Add returns the sum of two or more integers.
func Add(numbers ...int) (error, int) {
sum := 0
if len(numbers) < 2 {
return errors.New("provide more than 2 numbers"), sum
}
for _, num := range numbers {
sum += num
}
return nil, sum
}
This is a breaking change because old code expected one return value.
For v2, the module path must include /v2.
module github.com/thatisuday/nummanip/v2
go 1.22
Now tag and publish the major release.
git checkout -b v2
git add .
git commit -m "Release v2 with error return"
git tag v2.0.0
git push -u origin v2
git push --tags
In the consuming module, install the v2 module path.
go get github.com/thatisuday/nummanip/v2@v2.0.0
Now both versions can be used side by side because they have different module paths.
package main
import (
"fmt"
"github.com/thatisuday/nummanip/calc"
calcNew "github.com/thatisuday/nummanip/v2/calc"
)
func main() {
result := calc.Add(1, 2, 3)
fmt.Println("calc.Add(1, 2, 3) =>", result)
err, newResult := calcNew.Add(1, 2, 3, 4)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("calcNew.Add(1, 2, 3, 4) =>", newResult)
}
Output:
calc.Add(1, 2, 3) => 6
calcNew.Add(1, 2, 3, 4) => 10
The alias calcNew is needed because both packages are named calc.
The consumer’s go.mod can now contain both major versions:
module main
go 1.22
require (
github.com/thatisuday/nummanip v1.0.1
github.com/thatisuday/nummanip/v2 v2.0.0
)
Direct And Indirect Dependencies
A direct dependency is one your code imports. An indirect dependency is required by one of your dependencies.
For example, if nummanip/v2 imports github.com/fatih/color, the module file may look like this:
module github.com/thatisuday/nummanip/v2
go 1.22
require (
github.com/fatih/color v1.7.0
github.com/mattn/go-colorable v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.7 // indirect
)
Here, github.com/fatih/color is direct because the source imports it. The mattn packages are indirect because fatih/color depends on them.
Minimal Version Selection
Go uses minimal version selection to choose dependency versions.
Imagine this dependency graph:
main
├── module-a requires module-x v1.0.1
└── module-b requires module-x v1.0.2
Only one v1 version of module-x can be used in the final build. Go chooses v1.0.2 because it is the minimum version that satisfies all requirements.
This works because versions with the same major version are expected to be backward compatible.
If you need a breaking version, publish a new major path such as /v2.
Running And Building Modules
For executable modules, use:
go run .
go build
go install
For packages and libraries, use:
go test ./...
go build ./...
The ./... pattern means every package under the current module.
Local Development With replace
Sometimes you work on two modules locally, and one depends on the other. You do not need to publish every small local change.
Use a replace directive in the consumer module’s go.mod.
module main
go 1.22
require github.com/thatisuday/nummanip v1.0.1
replace github.com/thatisuday/nummanip => /path/to/local/nummanip
Now imports still use the public module path:
import "github.com/thatisuday/nummanip/calc"
But the Go command reads the source from /path/to/local/nummanip.
You can also add the replacement with the Go command:
go mod edit -replace github.com/thatisuday/nummanip=/path/to/local/nummanip
Remove it with:
go mod edit -dropreplace github.com/thatisuday/nummanip
Do not accidentally publish a local machine path in a library’s go.mod.
Cleaning Up With go mod tidy
Before committing or publishing, run:
go mod tidy
This command adds missing requirements and removes unused ones.
It is one of the simplest habits that keeps module files clean.
Vendoring Dependencies
Normally, Go reads dependencies from the module cache. In isolated build environments, you may want to keep dependency source code inside the repository.
Create a vendor directory:
go mod vendor
Build using vendored dependencies:
go build -mod=vendor
For modules with go 1.14 or newer, Go can automatically use the top-level vendor directory when it is present.
Installing CLI Modules
For command-line tools, use go install with a version suffix.
go install golang.org/x/tools/gopls@latest
This is the current recommended pattern for installing executables. Older go get-based installation is deprecated for this use case.
Useful Module Commands
Here are the commands you will use most often:
go mod init example.com/my/module
go get example.com/dependency@v1.2.3
go get -u ./...
go mod tidy
go mod graph
go mod vendor
go list -m all
go list -m -u all
Modules remove the need to keep projects inside GOPATH, but they do more than that. They make dependencies explicit, versioned, reproducible, and easier to reason about. Once you understand go.mod, semantic import versioning, replace, and tidy, the rest of the workflow becomes predictable.
For deeper reference, use the official Go Modules Reference and go.mod file reference.