13 Packages and Modules
Go’s module system replaces Maven and Gradle with a single tool that is part of the language distribution. Before modules (pre-Go 1.11), Go used a single workspace called GOPATH where all code — yours and every dependency — lived in one directory tree. There were no version numbers: you just got whatever was on main at the time of checkout. Reproducible builds were basically impossible. Modules fixed all of that: a go.mod file defines a self-contained unit with a name, a Go version requirement, and pinned dependency versions. This chapter covers everything you need to organize code into packages, share it across modules, manage dependencies, and control what the compiler sees at build time — from package naming conventions through build tags and embedded files.
13.1 Package Naming
In Java, package names mirror a reversed domain and tend to be long: com.example.music.catalog. In Go, the convention is the opposite: package names are short, lowercase, and match their directory name.
audio/ → package audio
catalog/ → package catalog
httputil/ → package httputilNo underscores, no camelCase, no reversed domains in the name itself (though the module path can contain a domain prefix — that is separate from the package name).
Tip: The package name is what callers type before the dot: audio.Track, catalog.Search. If the name is long or awkward to type repeatedly, shorten it. util is a warning sign — it means you have not found the right abstraction yet.
Trap: By convention the directory name and the package declaration at the top of each file match, with package main as the usual exception. The compiler does not enforce this, but a mismatch is a trap: if the directory is catalog and the file says package catlog (a typo), the import path ends in catalog while every caller must type catlog., and tooling like goimports will guess the wrong qualifier. If two files in the same directory disagree on the package name, the build does break: found packages catalog and catlog.
When you import a package, the last segment of the import path is the package name you use in code:
import "github.com/angoscia/lyrics/emerald"
// package name is "emerald", not "lyrics/emerald"
fmt.Println(emerald.Verse)If two imports have the same last segment, give one an alias:
import (
rbutil "github.com/robertdreamhouse/children/util"
agutil "github.com/angoscia/emerald/util"
)13.2 Exported vs Unexported Symbols
Chapter 1 introduced this briefly: uppercase first letter = exported (visible outside the package); lowercase = unexported (visible only inside the package). This section recaps the rule and covers the edge cases that trip up Java programmers.
package catalog
type Track struct { // exported --- callers can use catalog.Track
Title string // exported field
Artist string // exported field
bpm int // unexported --- callers cannot read or set this
}
func Search(q string) []Track { ... } // exported function
func normalize(s string) string { ... } // unexported helperThere is no protected. Unexported means package-local, period. A sub-package such as catalog/internal is a separate package and cannot see catalog’s unexported names.
Wut: Struct fields are governed by the same rule. A struct literal catalog.Track{Title: "Emerald Triangle 2012", bpm: 78} is a compile error outside catalog because bpm is unexported. This is Go’s equivalent of private fields combined with the rule that there is no Java-style public Track(String title, int bpm) constructor — callers must go through exported fields or a constructor function.
Use an unexported field with an exported accessor function when you want controlled mutation:
func (t *Track) BPM() int { return t.bpm } // pointer receiver: matches SetBPM
func (t *Track) SetBPM(bpm int) { t.bpm = bpm } // pointer receiver: mutates bpm 13.3 go.mod and go.sum
A module is the unit of versioning and distribution — roughly equivalent to a Maven artifact or a Gradle subproject. A package is the unit of code organization within a module — roughly equivalent to a Java package. One module contains many packages. go.mod sits at the root of a module and tells the Go toolchain the module’s name, which version of Go it requires, and what external modules it depends on. Chapter 1 introduced go mod init and the basic shape of go.mod. This section covers the directives you will encounter in real projects.
13.3.1 The module Directive
The first line of every go.mod declares the module path. This is the root import path for every package in the module:
module github.com/darude/sandstorm
go 1.26A package in cmd/server/main.go would belong to github.com/darude/sandstorm/cmd/server.
13.3.2 The require Directive
Each require line pins a direct dependency to an exact version:
require (
github.com/robertdreamhouse/children v1.3.0
github.com/angoscia/emeraldtriangle v2.1.0+incompatible
golang.org/x/text v0.14.0 // indirect
) // indirect marks a transitive dependency — one you do not import directly but that your dependencies need. go mod tidy adds and removes // indirect entries automatically.
Go resolves version conflicts using Minimum Version Selection (MVS): when multiple modules require different minimum versions of the same dependency, Go picks the highest of those minimums — the smallest version that satisfies everyone. Unlike Maven (which picks the nearest version) or npm (which can pull in duplicates), MVS always produces the same build from the same go.mod, with no surprises after a go get on an unrelated package. The trade-off is that MVS never automatically upgrades beyond what someone has explicitly required, so you have to run go get dep@latest intentionally when you want a newer version.
13.3.3 The replace Directive
replace overrides where a module is fetched from. The two common uses are local development with a forked module and pointing at an untagged local directory:
replace (
github.com/robertdreamhouse/children => ../children // local fork
github.com/some/dep v1.2.0 => github.com/myfork/dep v1.2.1 // published fork
)This is the Go equivalent of Maven’s <dependency> with <scope>system</scope> or a Gradle includeBuild composite.
Trap: replace directives are respected only in the main module — the one whose go.mod is at the root of your build. If you publish a library with a replace directive, consumers of that library will not see the replacement.
13.3.4 go.sum
go.sum records the cryptographic hash of every module version ever downloaded into the build. Never edit it by hand. Commit it to source control alongside go.mod. It is not quite a lock file — go.mod already pins versions; go.sum is an integrity check, closer to the integrity hashes inside package-lock.json than to the lock file itself.
13.4 go get, go mod tidy, go mod vendor
The typical dependency workflow is: use go get to add or change a specific version, then go mod tidy to clean up any entries that are now unused or missing. go mod vendor is for teams that want all dependencies checked in to the repo — useful when the build environment has no network access.
| Command | What it does |
|---|---|
go get pkg@v1.2.3 | Adds or upgrades a dependency to the specified version; updates go.mod and go.sum |
go get pkg@latest | Upgrades to the latest tagged release |
go get pkg@none | Removes the dependency |
go mod tidy | Adds missing and removes unused require entries; updates go.sum |
go mod vendor | Copies all dependencies into a vendor/ directory for offline or audited builds |
Tip: Run go mod tidy before every commit. It is the Go equivalent of running mvn dependency:analyze and then actually acting on the unused-declared warnings — except it edits the file for you.
Tip: go mod vendor is useful in environments where the module proxy is not accessible — CI pipelines with restricted network access, for example. Once the vendor/ directory exists, pass -mod=vendor to any go command to use it instead of the module cache.
13.5 Internal Packages
A common problem when publishing a library is that users start importing your private helper packages even though you never intended them to be public. Once that happens you are stuck: changing the helpers is a breaking change. Go solves this with the internal/ directory. Any package whose import path contains internal as a path segment can only be imported by code rooted at the parent of internal.
myapp/
├── go.mod
├── cmd/
│ └── server/
│ └── main.go // can import myapp/internal/db
├── internal/
│ └── db/
│ └── db.go // package db
└── api/
└── handler.go // can import myapp/internal/dbAn external module that tries import "myapp/internal/db" gets a compile error:
use of internal package myapp/internal/db not allowedThis is enforced by the compiler — no workaround exists. It is stronger than Java’s package-private (default access) because it enforces a module-level boundary, not just a package boundary.
Tip: Use internal/ for packages that are implementation details of your module: database helpers, configuration parsers, shared types that are not part of your public API. This lets you refactor freely without worrying about breaking external callers.
13.6 Standard Project Layout
Go does not mandate a directory structure, but a widely adopted layout for applications looks like this:
myapp/
├── go.mod
├── go.sum
├── cmd/
│ ├── server/
│ │ └── main.go // binary: the HTTP server
│ └── worker/
│ └── main.go // binary: the background worker
├── internal/
│ ├── catalog/
│ │ └── catalog.go // private business logic
│ └── db/
│ └── db.go // private database layer
└── pkg/
└── audio/
└── audio.go // public library code other modules may importcmd/ holds one directory per executable, each with its own main.go. internal/ holds packages that must not leak outside this module. pkg/ (optional) holds packages that are intentionally public — libraries other modules can import.
Tip: If you have only one binary, skip cmd/ and put main.go at the root. Add cmd/ only when you have multiple executables. If you never intend to be imported as a library, skip pkg/ too.
Compare this to a Maven multi-module project: cmd/server is like a Maven module with jar packaging and a main class; internal/catalog is like a Maven module that is built as part of the reactor but never deployed to a repository — usable by sibling modules, invisible to the outside world.
13.7 Go Workspaces
Suppose you are developing two modules side by side: the main application myapp and a library mylibrary that it imports. Without workspaces you would add a replace directive to myapp/go.mod pointing at the local mylibrary directory, and remember to remove it before pushing. Go workspaces, introduced in Go 1.18, eliminate this dance.
Create a go.work file at the root of your checkout:
go work init ./myapp ./mylibraryThis generates:
go 1.26
use (
./myapp
./mylibrary
)Depending on your installed toolchain, the go directive may be written with the full patch version (for example go 1.26.3) rather than the bare go 1.26; both go work init and go mod init do this, and either form is valid.
Now any go command run from anywhere inside that directory tree resolves mylibrary from the local disk, with no changes to either module’s go.mod. When you are done, delete or ignore go.work — the individual modules are unaffected.
Tip: Add go.work and go.work.sum to your .gitignore at the repository root. Workspaces are a local developer convenience; they should not be checked into source control for shared repositories.
Trap: go.work takes priority over replace directives. If both exist, the workspace wins. When sharing a repo, make sure go.work is gitignored so collaborators are not surprised.
13.8 Major Version Suffixes
Go follows semantic versioning. Versions v0.x.x and v1.x.x have the same module path. Starting at v2, the module path must end with the major version number:
module github.com/djcobra/betteroffalone/v2
go 1.26Every import of that module must include /v2:
import "github.com/djcobra/betteroffalone/v2/alone"This is intentional: a v2 module is a different module from v1. Your application can import both at the same time if different dependencies require different major versions.
Wut: This surprises Java programmers. In Maven, upgrading from 1.x to 2.x means changing the version number in pom.xml; the artifact ID stays the same. In Go, upgrading from v1 to v2 means updating every import statement in your codebase. The rationale is that v2 is API-incompatible by definition, so the change should be visible everywhere it matters.
Tip: If you are maintaining a library and want to publish a v2, the easiest path is to create a v2/ subdirectory at the module root, copy the code there, update the module line to end in /v2, and maintain both versions side by side. The alternative is to tag the root module at v2.0.0 and update the go.mod there, but the subdirectory approach keeps the history clean.
13.9 Build Tags
A build tag (also called a build constraint) tells the Go compiler to include or exclude a file from a build. The most common uses are platform-specific code, feature flags, and separating integration tests from unit tests.
13.9.1 Syntax
Place the constraint near the top of the file, before the package clause and preceded only by blank lines or other line comments (a license header is fine), with a blank line between the constraint and the package clause:
//go:build linux
package platformThe expression can use &&, ||, and !:
//go:build linux && amd64//go:build !windows13.9.2 Predefined Tags
The Go toolchain defines tags automatically:
| Tag | When true |
|---|---|
linux, darwin, windows | GOOS matches |
amd64, arm64 | GOARCH matches |
go1.21, go1.22, … | Go version is at least that release |
cgo | cgo is enabled |
13.9.3 Custom Tags
Define your own tags by passing -tags to the go command:
//go:build integrationgo test -tags=integration ./...Files with the integration constraint are excluded from ordinary builds and compiled only when you pass -tags=integration. This is how integration tests are kept separate from unit tests without putting them in a different directory.
Tip: Use build tags to separate slow integration tests from fast unit tests. Name the tag integration and document it in your README. Your CI pipeline can run go test ./... for fast tests on every commit and go test -tags=integration ./... on a slower schedule.
Trap: Before Go 1.17 the syntax was // +build linux (a comment, not a directive). You may still encounter this in older code. The old syntax is still accepted for compatibility, but //go:build is the modern form. Do not mix them in the same file.
13.10 //go:embed
Before Go 1.16, embedding static assets — HTML templates, SQL schemas, configuration files — in a binary required third-party code generators or reading files at runtime. //go:embed makes this a first-class language feature.
13.10.1 Embedding a Single File
package lyrics
import _ "embed"
//go:embed emerald.txt
var emeraldLyrics stringAt compile time, the contents of emerald.txt are baked into the binary and assigned to emeraldLyrics. No file I/O at runtime.
13.10.2 Embedding Multiple Files
package web
import "embed"
//go:embed static/*.html static/*.css
var webFiles embed.FSembed.FS is a read-only filesystem rooted at the directory containing the .go file. It satisfies fs.FS, so it works with http.FS, template.ParseFS, and any other function that accepts an fs.FS.
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(webFiles))))
Tip: The //go:embed directive must immediately precede the variable’s declaration; only blank lines and // line comments may sit between them. An intervening declaration either steals the directive (go:embed cannot apply to var of type int) or, for non-var lines, fails with a misplaced go:embed directive error. When embedding into a string or []byte variable, the embed package must be imported for its side effect — use import _ "embed" because you are not naming anything from the package. When embedding into embed.FS, use a normal import "embed" because you reference embed.FS by name.
Trap: Glob patterns in //go:embed do not match files or directories whose names begin with . or _. If you want to embed a .gitignore or _headers file, name it explicitly in the pattern (this works with any variable type, including string), or use the all: prefix when embedding a whole directory tree (//go:embed all:static).
The compile-time inclusion is the key difference from Java’s getClass().getResourceAsStream(); with //go:embed there is no path to get wrong at runtime.
13.11 Try It
Type this in and run it to see //go:embed bake a file into the binary at compile time. Create a file named tracks.txt next to main.go containing three lines: Emerald Triangle 2012, Sandstorm, and Better Off Alone, then run go run . (a go.mod is required, so go mod init example.com/tryit first).
package main
import (
"embed"
"fmt"
"io/fs"
"strings"
)
//go:embed tracks.txt
var trackList string
//go:embed *.txt
var assets embed.FS
func main() {
titles := strings.Split(strings.TrimSpace(trackList), "\n")
fmt.Printf("embedded %d titles:\n", len(titles))
for i, t := range titles {
fmt.Printf(" %d. %s\n", i+1, t)
}
// embed.FS satisfies fs.FS, so we can walk it at runtime.
fs.WalkDir(assets, ".", func(path string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
fmt.Println("asset file:", path)
}
return nil
})
}Try these modifications:
- Insert another declaration (for example
const placeholder = 1) between the//go:embeddirective and thevardeclaration it applies to, then rebuild — watch it fail withmisplaced go:embed directive. (A blank line alone is fine; the directive only needs to be the line immediately above the declaration, ignoring blanks and//comments.) - Add a second
.txtfile and confirm theembed.FSwalk finds it without any code change, while thestringvariable still holds onlytracks.txt. - Rename the second
.txtfile from the previous bullet to start with an underscore (say_extra.txt) and observe that the*.txtglob silently skips it in the walk output while the build still succeeds — the explicittracks.txtembed is unaffected.
13.12 Key Points
- Package names are short, lowercase, match their directory, and no underscores.
- Capitalization controls visibility: uppercase = exported, lowercase = unexported; there is no
protected. - A module is the unit of versioning (one
go.mod); a package is the unit of code organization within a module. go.moddeclares the module path and pins dependencies withrequire;replaceoverrides the source of a module for local development or forks.- Go uses Minimum Version Selection (MVS): the highest minimum version required by any module in the build graph wins — reproducible by design.
go.sumrecords checksums that verify downloaded modules; commit it alongsidego.mod.go getadds/upgrades/removes dependencies;go mod tidykeepsgo.modclean;go mod vendorcopies dependencies locally.internal/packages are enforced by the compiler: only code rooted at the parent ofinternalmay import them.- The standard layout uses
cmd/for executables andinternal/for private packages. - Go workspaces (
go work) let you develop multiple modules side by side withoutreplacedirectives. - Modules at
v2or higher must include the major version in the module path and every import. - Build tags (
//go:build) include or exclude files based on OS, architecture, Go version, or custom tags passed with-tags. //go:embedbakes files into the binary at compile time; usestring,[]byte, orembed.FSas the variable type.
13.13 Exercises
Think about it: Maven and Gradle resolve transitive dependencies automatically and let two artifacts declare conflicting version requirements for the same library. They use a strategy (nearest-wins in Maven, highest-requested in Gradle) to pick a single version at build time. Go’s module system takes a different approach called Minimum Version Selection (MVS): it always picks the minimum version that satisfies all requirements. Compare these two philosophies. What problems does MVS avoid? What does it make harder? When might the Go approach cause a surprise after running
go get pkg@latest?Where is the bug?
Given the following three files in a module
github.com/angoscia/demo:File
lyrics/lyrics.go:package lyrics import "fmt" func Print() { fmt.Println("Emerald Triangle 2012") }File
lyrics/internal/detail/detail.go:package detail import "fmt" func Show() { fmt.Println("internal detail") }File
main.go:package main import ( "github.com/angoscia/demo/lyrics" "github.com/angoscia/demo/lyrics/internal/detail" ) func main() { lyrics.Print() detail.Show() }What happens when you run
go build? If the build succeeds, what does the program print? If not, explain why.Calculation: A module’s
go.modcontains the following:module github.com/angoscia/app go 1.26 require ( github.com/angoscia/audio v1.4.0 github.com/angoscia/catalog v0.9.2 golang.org/x/text v0.14.0 // indirect )The
audiomodule atv1.4.0itself requiresgolang.org/x/text v0.12.0. Thecatalogmodule atv0.9.2requiresgolang.org/x/text v0.14.0.Under Go’s Minimum Version Selection, which version of
golang.org/x/textwill the final build use? Explain why. Now suppose you add a new dependency that requiresgolang.org/x/text v0.16.0. What version will MVS select then?What does this print? A single-file
package maincontains the following. Predict the exact output, then explain the order in which the package-levelvardeclarations and theinitfunction run.package main import "fmt" var a = b + c var b = f() var c = 2 func f() int { fmt.Println("f called") return 3 } func init() { fmt.Println("init, a =", a) } func main() { fmt.Println("main, a =", a) }Where is the bug? The following module has this layout and code:
betteroffalone/ ├── go.mod (module github.com/djcobra/betteroffalone) ├── main.go └── internal/ └── config/ └── config.gomain.go:package main import ( "fmt" "github.com/djcobra/betteroffalone/internal/config" ) func main() { fmt.Println(config.DefaultRegion) }A second module lives alongside it:
player/ ├── go.mod (module github.com/djcobra/player) └── main.goplayer/main.go:package main import ( "fmt" "github.com/djcobra/betteroffalone/internal/config" ) func main() { fmt.Println(config.DefaultRegion) }What happens when you run
go build ./...inside theplayer/module? Identify the bug and describe how to fix it without moving theconfigpackage out ofinternal/.Write a program: Create a small multi-package module with the following layout:
children/ ├── go.mod (module github.com/robertdreamhouse/children) ├── main.go ├── tracks/ │ └── tracks.go └── internal/ └── format/ └── format.gotracks.goshould define an exportedTrackstruct withTitleandArtiststring fields and a sliceCatalogcontaining at least two entries.format.goshould define an unexported-to-outside but exported-within-module functionLabel(t tracks.Track) stringthat returns"Title by Artist".main.goshould import bothtracksandinternal/format, iterate overtracks.Catalog, and print the label for each track usingformat.Label. Build and run the program withgo run ./...(orgo run main.go) and confirm it prints the expected output.