Packages, Modules, and go.mod
Every program in this book so far has fit comfortably in one file. That won't last, and it shouldn't — a real project needs to split into pieces that different people, or even just a future, more tired version of you, can work on without stepping on each other's toes. Go's answer to "how do we organize a growing codebase" turns out to be refreshingly literal: a folder on disk is a unit of code, and a file with an address on it is your dependency graph. No ceremony beyond that. This chapter is about how those two ideas — packages and modules — actually work.
A package is a room in a house — everything inside can see everything else in that room without asking permission. A module is the whole house — a boundary with an address, so other houses know exactly where to send mail when they need something from inside.
This chapter is about how Go code is organized above the level of a single
file: how packages group related code, how capitalization alone decides
what's public, and how modules — via go.mod and go.sum — manage
dependencies without a central package registry deciding what "the" latest
version of anything is.
Packages: Go's Unit of Organization
Every .go file starts with a package declaration, and every file in
the same directory must declare the same package name — a directory is
a package, in Go's model, and files within it share scope freely with no
imports needed between them:
// file: shapes/rectangle.go
package shapes
type Rectangle struct {
Width, Height float64
}
// file: shapes/circle.go
package shapes
import "math"
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
Both files live in a directory named shapes/ and both declare
package shapes — from outside, this directory is imported as a single
unit, .../shapes, regardless of how many files it's split across.
package main is special: it marks a package that produces an executable,
with func main() as the entry point, exactly as you saw in the very
first chapter of this part. Every other package name marks a library
package — importable code with no entry point of its own.
Exported vs. Unexported: Capitalization Is the Access Modifier
Here's one of Go's smallest, most elegant decisions, and it's easy to
miss how clever it is until you've fought with public/private
keywords in another language and had them drift out of sync with what a
name actually does. Go has no public, private, or protected
keywords at all. Visibility outside a package is decided entirely by the
first letter of an identifier's name: capitalized names are exported
(visible to importing packages), lowercase names are unexported
(visible only within the declaring package):
package shapes
type Rectangle struct { // exported: usable from other packages
Width, Height float64 // exported fields
}
func (r Rectangle) Area() float64 { // exported method
return r.Width * r.Height
}
func validateDimension(d float64) bool { // unexported: package-private
return d > 0
}
A package importing shapes can create a shapes.Rectangle, read
.Width, and call .Area() — but cannot call validateDimension at all;
the compiler reports it as undefined, because the name simply isn't
visible outside shapes.
Go's naming-as-visibility rule means you can tell what's part of a package's public API just by scanning for capital letters — there is no separate keyword to hunt for, and no way for the visibility to silently drift out of sync with the name.
Rectangle) does not automatically export its fields — each field's own capitalization decides its visibility. A common beginner mistake is exporting a struct but leaving its fields lowercase, then being confused why pkg.Config{}.timeout fails to compile from another package: timeout needs its own capital letter, or a public constructor/setter method, to be usable from outside.Naming conventions in Go favor short, clear names — Area, not
ComputeAndReturnTheAreaOfTheRectangle — and idiomatic Go avoids
repeating the package name inside identifiers it will always be qualified
with (shapes.Rectangle, not shapes.ShapesRectangle), because the
package name already provides that context at every call site.
Modules: go.mod and go.sum
A module is one or more packages versioned and distributed together,
rooted at a directory containing a go.mod file. You met go mod init
back in the very first chapter of this part; here is what the file it
produces actually declares:
module github.com/you/mytool
go 1.22
require (
github.com/google/uuid v1.6.0
)
moduledeclares the module's import path — the prefix every package inside it is imported under (github.com/you/mytool/shapes, for ashapessubdirectory).godeclares the minimum Go language version the module requires.requirelists direct dependencies and their exact versions, added automatically the first time you import and build against them.
Adding a dependency is as simple as importing it and building, or fetching it explicitly:
go get github.com/google/uuid@v1.6.0
go get updates go.mod and also writes or updates go.sum — a file
listing cryptographic checksums for every dependency (and every
dependency of a dependency), so a build can verify it is downloading
exactly the bytes it expects, not a tampered or accidentally different
version:
cat go.sum
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb59...
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+n...
go.sum or adding it to .gitignore is a common but serious mistake — without it, two builds of the same go.mod can silently resolve to different dependency bytes, defeating the entire point of reproducible builds. Always commit both go.mod and go.sum to version control.Keeping go.mod and go.sum Tidy
As you add and remove imports while developing, go.mod can drift out of
sync with what your code actually uses. go mod tidy reconciles the two
directions at once: it adds any missing require entries for packages you
import but haven't declared, and removes entries for dependencies nothing
imports anymore:
go mod tidy
Running it regularly — and especially right before committing — keeps
go.mod an accurate, minimal record of what the module actually depends
on, rather than an ever-growing list of things that were needed once.
import of a dependency from your code does not remove its require line from go.mod automatically — the module still builds fine with the stale entry present, so this is easy to miss. Left unaddressed across many changes, go.mod accumulates dependencies nothing in the codebase actually needs anymore, and go mod tidy is the one-command fix.internal Packages: Compiler-Enforced Privacy
Everything so far in this chapter controls visibility within an
already-importable package, decided purely by capitalization. A directory
literally named internal goes a level further: the compiler refuses to
let any package outside the module subtree rooted at internal's parent
import anything from inside it, no matter how the identifiers are
capitalized:
mymodule/
go.mod
internal/
auth/
token.go
cmd/
server/
main.go
Here, mymodule/cmd/server can import mymodule/internal/auth because
both live under the same mymodule tree, but a completely different
module importing mymodule as a dependency cannot reach
mymodule/internal/auth at all — the import simply fails to compile, with
"use of internal package ... not allowed." This is the one place Go
enforces an access boundary above the level of a single package, and it's
the standard way to mark code as "part of this module's implementation,
not its public API," even when every identifier inside is capitalized.
Semantic Versioning, Briefly
Go module versions follow semantic versioning: vMAJOR.MINOR.PATCH.
go get without a version suffix fetches the latest compatible release;
appending @v1.6.0 (or @latest, @none to remove, or a branch/commit
hash) pins or changes it explicitly:
go get github.com/google/uuid@latest
go get github.com/google/uuid@v1.5.0
One Go-specific rule worth knowing: a major version 2 or above must be
reflected in the import path itself (.../v2, .../v3) — this is how
Go allows two incompatible major versions of the same module to be
imported side by side without conflict, something most other ecosystems
handle only through separate package names.
| Version bump | Meaning | Import path changes? |
|---|---|---|
Patch (v1.6.0 to v1.6.1) |
Bug fixes, no API change | No |
Minor (v1.6.0 to v1.7.0) |
Backwards-compatible additions | No |
Major (v1.6.0 to v2.0.0) |
Breaking changes | Yes — import path gains /v2 |
A Small Multi-Package Example
A realistic small project splits code across a main package and one or
more library packages. Given this layout:
mytool/
go.mod
main.go
shapes/
rectangle.go
circle.go
main.go imports shapes by its full module-qualified path:
package main
import (
"fmt"
"example.com/mytool/shapes"
)
func main() {
r := shapes.Rectangle{Width: 3, Height: 4}
c := shapes.Circle{Radius: 2}
fmt.Println("rectangle area:", r.Area())
fmt.Println("circle area:", c.Area())
}
Note the import path: example.com/mytool/shapes combines the module
path declared in go.mod (example.com/mytool) with the subdirectory
(shapes) — Go derives every internal package's import path this way,
with no separate registration step needed for your own subpackages.
shapes/ folder with files inside is not enough on its own — each file must declare package shapes at the top, and the directory name and package name are conventionally (though not strictly required to be) the same. A mismatch between directory name and package name compiles fine but confuses readers and tools that assume the convention holds.Try It Yourself
Build the two-package layout above from scratch:
- Create
mytool/, rungo mod init example.com/mytoolinside it. - Create
mytool/shapes/rectangle.goandmytool/shapes/circle.gowith the two file contents shown earlier in this chapter (including theArea()methods). - Create
mytool/main.gowith the multi-package example above, and rungo run .from themytool/directory — confirm both areas print. - Add an unexported helper
func validateDimension(d float64) booltoshapes/rectangle.go, then try to callshapes.validateDimension(3)frommain.go— read the compiler error and confirm it names the identifier as undefined, not merely "private." - Bonus: run
go list -m allinsidemytool/to see your module and every dependency (there may be none yet) in the module graph.
A package boundary is a trust boundary as much as an organizational one — code inside a package trusts its own unexported details completely; code outside must go through whatever the package chooses to export.
Frequently Asked Questions
I capitalized my struct type but a field on it still isn't visible from another package — why?
Exporting a type and exporting its fields are two separate decisions in Go, and this chapter calls out exactly that trap: capitalizing Rectangle makes the type itself visible, but each field's own capitalization decides whether it is visible. A lowercase field like timeout on an otherwise-exported struct stays package-private no matter how public the struct is — give the field its own capital letter, or add a public constructor or setter, if it needs to be reachable from outside.
Do I need a public/private keyword mindset carried over from another language to understand Go visibility?
Not really — Go replaces that whole system with one rule: capitalized identifiers are exported, lowercase ones aren't. There's no separate keyword to hunt for and no way for the visibility to drift out of sync with the name, since the name and the visibility are literally the same piece of information.
Why did my build still succeed after I deleted the last import of a dependency, even though I never ran anything to clean up go.mod?
Because removing an import from your code doesn't automatically remove the corresponding require line from go.mod — the stale entry doesn't break the build, so it's easy to leave behind. Over many changes this quietly accumulates dependencies nothing in the codebase actually needs anymore; running go mod tidy regularly, especially right before committing, is the one-command fix.
What's the actual difference between making a package unexported-by-lowercase versus putting it under internal/?
Lowercase visibility only controls access within an already-importable package — anyone who can import the package at all can still see every capitalized name in it. A directory literally named internal goes further: the compiler refuses to let any package outside the module subtree rooted at internal's parent import from it at all, regardless of how its identifiers are capitalized. It's the one access boundary in Go that sits above the level of a single package.
Why does Go handle dependency versions through go.mod/go.sum and a module proxy instead of a central registry like npm's?
Because Go fetches modules directly from their source repository, addressed by the same import path used in code, rather than through a separate publish step to a registry with its own naming authority. go.sum then guarantees the build got exactly the bytes it expected via cryptographic checksums, and proxy.golang.org/sum.golang.org exist purely to cache and verify — the module's identity always traces back to its own repository path, never to a registry account.
Where This Goes From Here
You can now do something that felt out of reach at the start of this
part: split a real project across files and folders, control exactly
which parts of it the outside world can see, and pull in someone else's
code with a one-line go get and a checksum that guarantees you got
exactly the bytes you asked for. That's the organizational half of
writing Go solved.
What's missing is confidence that any of it actually works the way you
think it does. The next chapter is about go test — Go's built-in
answer to "how do I know this code still does what I claim it does,"
which needs no external framework and, true to form for this language,
comes bundled directly into the same toolchain you've been using since
Chapter 1.