Go Packages and Modules with go.mod
A practical tour of Go packages and modules: go mod init, imports, internal packages, semantic versioning, and the replace directive for local development.
What you'll learn
- ✓How packages and modules differ in Go
- ✓How to bootstrap a module with go mod init
- ✓How to import internal packages and structure code
- ✓How semantic versioning and module paths interact
- ✓How to use the replace directive for local development
Prerequisites
- •A working Go toolchain from [Go install and first program](/blog/go-install-and-first-program)
- •Familiar with [Go functions](/blog/go-functions)
Every nontrivial Go project lives inside a module. Modules are how Go finds your code, resolves dependencies, and decides which versions to use. Once you understand how go.mod, import paths, and the module cache fit together, structuring a project becomes straightforward.
Packages vs modules
A package is a directory of .go files sharing the same package declaration at the top. A module is a collection of related packages versioned together, defined by a go.mod file at the module’s root. A repository typically contains one module, which contains many packages.
myapp/
go.mod
main.go // package main
internal/
storage/
storage.go // package storage
pkg/
api/
api.go // package api
The directory determines the import path; the package line determines the name used in code.
Creating a module
go mod init writes a go.mod for a new project. The argument is the module path, which by convention is the location where the code will live, even if you do not push it there immediately.
mkdir myapp && cd myapp
go mod init github.com/you/myapp
This creates:
module github.com/you/myapp
go 1.22
The module path becomes the prefix for every package in the project. A file at internal/storage/storage.go would be imported as github.com/you/myapp/internal/storage.
A small example
A minimal two-package module:
// file: internal/greet/greet.go
package greet
import "fmt"
func Hello(name string) string {
return fmt.Sprintf("hello, %s", name)
}
// file: main.go
package main
import (
"fmt"
"github.com/you/myapp/internal/greet"
)
func main() {
fmt.Println(greet.Hello("Ada"))
}
Run it with go run .. The Go tool resolves the import via go.mod, finds the package on disk, and compiles everything together.
Exported names
Identifiers that start with an uppercase letter are exported and visible outside the package; lowercase identifiers are package-private. This is the only access control Go has, and it works at the package boundary rather than per type.
package greet
// Exported: callable from other packages
func Hello(name string) string { return formal(name) }
// Unexported: only visible inside this package
func formal(name string) string { return "hello, " + name }
The internal directory
A directory named internal is special. Packages inside it can be imported only by code rooted in the parent of that internal directory.
github.com/you/myapp/
internal/storage/ // importable only by github.com/you/myapp/...
pkg/api/ // importable by anyone who depends on the module
Use internal/ for implementation details you do not want to commit to as a public API. It is the most effective way in Go to keep your public surface small.
Adding dependencies
go get adds a dependency and updates go.mod and go.sum:
go get github.com/google/uuid
In code:
import "github.com/google/uuid"
func newID() string {
return uuid.NewString()
}
go.mod gains a require line; go.sum records cryptographic hashes used to verify future downloads. Both files should be committed.
go mod tidy synchronizes go.mod with what your code actually uses, removing unused entries and adding missing ones. Run it before commits.
Semantic import versioning
Go modules use semantic versioning. A tag like v1.4.2 is a release; v0.x.y is treated as unstable. For v2 and beyond, Go enforces a different rule: the major version must appear in the import path.
import "github.com/you/lib/v2/sub"
This lets a single project depend on both v1 and v2 of the same module without a conflict. When you tag v2.0.0, you also update your module path in go.mod:
module github.com/you/lib/v2
It is the most surprising rule for newcomers, but it is what makes long-lived ecosystems possible.
The replace directive
While developing, you often need to point at a local checkout of a dependency, or temporarily redirect to a fork. The replace directive does this without changing import paths in code.
// go.mod
module github.com/you/myapp
go 1.22
require github.com/you/lib v1.3.0
replace github.com/you/lib => ../lib
Now import "github.com/you/lib" resolves to ../lib on disk. Use replace for local workflows and emergencies; do not publish a module that relies on replace to build, because consumers cannot inherit those redirects.
For multi-module workspaces, go work init and go.work are usually a cleaner alternative.
Build and run commands
A few commands you will use constantly:
go run .compiles and runs the current package.go build ./...builds every package in the module.go test ./...runs every test in the module.go install ./cmd/myappinstalls a binary under$GOBIN.go mod tidycleans upgo.mod/go.sum.
The ./... pattern means “this directory and everything below,” which is the usual scope for module-wide operations.
A pragmatic layout
For an application with multiple binaries:
myapp/
go.mod
cmd/
api/main.go // binary: myapp-api
worker/main.go // binary: myapp-worker
internal/
config/
storage/
http/
pkg/
sdk/ // optional public Go SDK
This matches what most production Go services look like. It also keeps the import graph easy to reason about: high-level binaries depend on internal/ packages; internal packages do not depend on cmd/.
For data-shaping packages that show up in nearly every project, the patterns in Go slices and maps and the branching styles in Go control flow appear over and over.
Wrap up
Modules are how Go organizes code at scale: one go.mod per repository, packages laid out as directories, and import paths that mirror your VCS location. Use internal/ to enforce boundaries, semantic versioning to communicate change, and replace (sparingly) for local development. With these pieces in place, you can grow a single-file script into a multi-binary service without reorganizing your imports.