Skip to content

Modules Deep Dive

If you know npm you already understand 90% of Go modules. Both systems use semantic versioning, lock files, and a manifest file. The mental model maps cleanly — but the mechanics differ in important ways.

TypeScript
// package.json
{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"lodash": "^4.17.21"
},
"devDependencies": {
"jest": "^29.0.0"
}
}
Go
// go.mod
module github.com/myorg/my-app
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/stretchr/testify v1.8.4
)
// No separate devDependencies in Go.
// Test-only deps are included in require; the
// compiler excludes them from production binaries.

go.mod is the equivalent of package.json. It has three required fields:

module github.com/myorg/my-app ← module path (import prefix)
go 1.21 ← minimum Go version
require (...) ← direct dependencies

Key differences from package.json:

  • The module path is a URL-like string, usually matching the VCS repo path.
  • There is no devDependencies. All dependencies live in require. The Go linker only includes packages actually imported by the binary being built.
  • ^ range syntax does not exist. You pin an exact version (e.g., v1.9.1).

go.sum is the equivalent of package-lock.json. It records cryptographic hashes for every dependency version:

github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BerypkHKpy7LtzLuviaCYD2h8zebo=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=

Two hash lines per dependency:

  • h1:... — hash of the module zip content
  • /go.mod h1:... — hash of just the go.mod file

Both must match on every machine and in CI — go mod verify checks this. You commit go.sum to version control (just like package-lock.json).

Terminal window
# Initialise a new module
go mod init github.com/myorg/my-app
# Add a dependency (updates go.mod + go.sum)
go get github.com/gin-gonic/[email protected]
# Upgrade to latest patch/minor
go get github.com/gin-gonic/gin@latest
# Remove unused dependencies
go mod tidy
# Verify all downloaded modules match go.sum
go mod verify
# List all direct + indirect dependencies
go list -m all
# Show available versions of a module
go list -m -versions github.com/gin-gonic/gin

Go modules encode the major version in the import path for v2+. This is the most surprising difference from npm:

TypeScript
// npm — same package name, just a new version
// package.json
"dependencies": {
"some-lib": "^3.0.0" // v3, same import
}
// import someLib from 'some-lib'; ← unchanged
Go
// Go — v2+ changes the import path
// go.mod
require github.com/some-lib/core v2.0.0+incompatible
// If the library has a proper /v2 module:
require github.com/some-lib/core/v2 v2.0.0
// Your imports change:
import "github.com/some-lib/core/v2"
// v1: import "github.com/some-lib/core"
// v2: import "github.com/some-lib/core/v2"

When you are developing a dependency alongside your main module, replace maps a module path to a local directory — equivalent to npm link or file: paths in package.json:

go.mod
require github.com/myorg/shared-lib v1.2.3
replace github.com/myorg/shared-lib => ../shared-lib

Remove replace before committing to production. Use go mod tidy afterward to clean up.

Vendoring copies all dependencies into a vendor/ directory inside your repo — useful for air-gapped builds or reproducibility guarantees without relying on the module proxy:

Terminal window
# Create/update vendor directory
go mod vendor
# Build using only vendored deps (ignores module cache)
go build -mod=vendor ./...
# Verify vendor matches go.sum
go mod verify

Workspaces (added in Go 1.18) let you develop multiple inter-dependent modules simultaneously without replace directives — equivalent to npm workspaces or yarn workspaces:

TypeScript
// npm workspaces — package.json at root
{
"workspaces": [
"packages/api",
"packages/shared"
]
}
Go
// Go workspaces — go.work at repo root
go 1.21
use (
./api // module: github.com/myorg/api
./shared // module: github.com/myorg/shared
)
// Now api can import shared directly from disk,
// no replace directive needed.
Terminal window
# Initialise a workspace (creates go.work)
go work init ./api ./shared
# Add another module to the workspace
go work use ./cli
# Sync go.work.sum (lock file for workspaces)
go work sync

go.work is typically git-ignored (it is a local developer convenience, not a deployment artefact). Use GOWORK=off to disable it in CI if needed.

What is the Go equivalent of package-lock.json?
How does Go handle v2+ breaking changes in module imports?
Which command removes unused dependencies from go.mod and go.sum?
What is the purpose of the replace directive in go.mod?