Packages and Modules: Organizing Real Go

How Go code is organized: packages (one directory, exported by capitalization, init functions, no import cycles), modules (go.mod, versions, go get, go.sum, go mod tidy), the compiler-enforced internal/ directory, the standard project layout, and go doc for your own code. Compiled and run against Go 1.26.5.

Everything so far has fit in one main package. Real programs don’t. This final chapter is about the two units Go uses to organize code at scale: the package, which is how you group and hide code within a project, and the module, which is how a project is versioned, depended upon, and shared. Both are deliberately simple, both are enforced by the compiler rather than by convention, and both were designed — like the rest of the language — to keep a large codebase legible years after it was written. Everything here is compiled and run against Go 1.26.5.

Packages: one directory, one package

The rule is mechanical: a directory is a package. Every .go file in a directory must declare the same package name on its first line, and together they form one package. There are no nested packages within a file, no namespaces to open, no header to include. The files in a directory are compiled together and share a single scope, so a function in one file can call an unexported function in another file of the same package without any import.

The package name is what callers type; the import path is how the toolchain finds it. They’re usually the last element of the path but need not be. You already know the visibility rule from chapter one, and it is the whole access-control system: an identifier exported if and only if it starts with an uppercase letter. No public, no private, no export. greet.Hello is callable from another package; greet.language is not.

Here is a library package. Note the doc comment on the package line, the capitalized Hello, and the lowercase language that nothing outside can reach:

// Package greet formats friendly greetings.
package greet

import "fmt"

// language is package-private: lowercase, so no other package can see it.
var language = "English"

// Hello returns a greeting for name. It is exported: capitalized.
func Hello(name string) string {
	return fmt.Sprintf("Hello, %s", name)
}

// init runs automatically when the package is first loaded,
// before any exported function is called. A package may have several.
func init() {
	fmt.Printf("[greet] initialized (language=%s)\n", language)
}

init() and when it runs

init is a special function: you never call it, the runtime does. When a package is first loaded, Go initializes its package-level variables, then runs every init function in the package, and only then does control reach any code that uses the package. A package may declare several init functions (even across its files); they all run. Crucially, a package’s dependencies are fully initialized before it is — so if main imports greet, greet’s variables and init complete before main starts. Use init sparingly, for genuine one-time setup like registering a driver; heavy logic hidden in init is hard to follow precisely because nobody calls it.

The blank import: importing purely for init

That “registering a driver” case has its own syntax, and it looks strange until you know the rule behind it. Normally an unused import is a compile error — Go refuses to build code that imports a package it never names. But sometimes you want a package only for the side effect its init produces, and you never call anything it exports. The database/sql drivers work this way: importing the driver registers it, and from then on you talk to it through the standard database/sql API, never by name. To ask for a package’s init without naming the package, you give the import the blank identifier _:

package main

import (
	"fmt"

	// Blank import: we never name driver, we only want its init side effect.
	_ "example.com/hello/driver"
)

func main() {
	fmt.Println("main running")
}

The driver package here does nothing but record, in its init, that it was loaded:

// Package driver registers itself as a side effect of being imported.
package driver

import "fmt"

var registered bool

func init() {
	registered = true
	fmt.Println("[driver] init ran: registered =", registered)
}

Run it and the init fires before main, exactly as the initialization rule promises, even though main never references the package:

$ go run .
[driver] init ran: registered = true
main running

The _ says “import this for its effects, and yes, I know I’m not using its name — that’s on purpose.” It’s the one sanctioned way around the unused-import rule, and it exists precisely for this register-on-load pattern. Reach for it only when a package documents that importing it does something; a blank import with no side effect is just dead weight.

The import graph has no cycles

A main package imports the library by its import path — the module path plus the directory:

package main

import (
	"fmt"

	"example.com/hello/greet"
)

func main() {
	fmt.Println(greet.Hello("Ada"))
	fmt.Println(greet.Hello("Grace"))
}

Run it, and greet’s init fires first, before main prints anything:

$ go run .
[greet] initialized (language=English)
Hello, Ada
Hello, Grace

That ordering is the initialization rule made visible: greet is fully set up before main runs. One hard constraint governs the whole graph: imports may not form a cycle. If package a imports b, then b may not import a, directly or through a chain. The compiler rejects it outright. This feels strict the first time it bites, but it is one of Go’s quiet superpowers — the import graph is always a DAG, so you can always find a bottom to start reading from, and the build can always be ordered. When you hit a cycle, it is a design signal: the two packages want to be one, or a shared third package should hold what they both need.

The internal/ directory

Exported-by-capitalization is the only visibility control within a package’s public surface, but it is all-or-nothing: capitalize Hello and the entire world can call it. Sometimes you want code that is exported to your own packages but closed to outside importers. That is what internal/ is for, and the compiler enforces it: a package whose import path contains an internal/ element may be imported only by code rooted at internal’s parent directory.

// Package token lives under internal/, so only code rooted at
// example.com/hello may import it. The compiler enforces this.
package token

import "fmt"

func New(id int) string {
	return fmt.Sprintf("tok-%04d", id)
}

A main package that shares the parent of internal/ imports it normally:

$ go run .
tok-0042

But a package outside that subtree cannot, and the failure is a compile error, not a lint warning. Dropping a sibling package that reaches into the same internal/token and building it gives:

$ go build .
package example.com/hello
	main.go:6:2: use of internal package example.com/hello/internal/token not allowed

This is how a library exposes a small, deliberate public API while keeping its guts private and refactorable. Everything under internal/ can change freely; no external code was ever allowed to depend on it.

Modules: the unit of versioning

A module is a tree of packages versioned and released together, rooted at a go.mod file. You met go mod init in chapter one; here is what the file it writes actually carries. A fresh module with one dependency looks like this:

module example.com/shop

go 1.26.5

require rsc.io/quote/v4 v4.0.1

require (
	golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
	rsc.io/sampler v1.3.0 // indirect
)

Four things to read here:

  • The module path (example.com/shop) is the import prefix for every package inside. It is conventionally a URL you control, because that is also where go get will fetch it from.
  • The go directive records the language version the module is written for.
  • require lists dependencies with exact semantic versions. Go’s dependency resolution is deterministic: it picks the minimum version that satisfies all requirements, so builds are reproducible without a separate lockfile step.
  • // indirect marks a dependency you don’t import directly but that something you do import needs. Go records the whole graph, not just your direct edges.

Adding a dependency

You add one with go get, which downloads it and updates go.mod:

$ go get rsc.io/quote/v4
go: added golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: added rsc.io/quote/v4 v4.0.1
go: added rsc.io/sampler v1.3.0

It resolved three modules from the one you asked for: rsc.io/quote/v4 directly, plus rsc.io/sampler and golang.org/x/text, which quote needs. Those two become the // indirect lines in go.mod.

Then you import and use it like any package:

$ go run .
Don't communicate by sharing memory, share memory by communicating.

Notice the /v4 in the import path rsc.io/quote/v4. That is semantic import versioning: a module at major version 2 or above puts its major version in the path itself. It looks odd, but it is what lets v1 and v4 of the same library coexist in one build — they have literally different import paths, so an upgrade across a breaking major version is an explicit code change, never a silent one.

go.sum and go mod tidy

Alongside go.mod sits go.sum, the integrity ledger. It records a cryptographic hash for every module version in your graph, so a later download that doesn’t match is rejected — supply-chain tampering fails the build rather than slipping through:

rsc.io/quote/v4 v4.0.1 h1:i/LHLEinr65wwTCqlP4OnMoMWeCgnFIZFvifdXNK+5M=
rsc.io/quote/v4 v4.0.1/go.mod h1:w/DafQky66grMesu3uPhdDMS3knhBippwwemZtMOyCI=

Both go.mod and go.sum are checked into version control. Keeping them honest is one command: go mod tidy adds every dependency your code actually imports, removes every one it no longer uses, and reconciles go.sum. Run it before you commit, and the module file reflects the code rather than its history.

The standard project layout

Go’s layout is more convention than rule, but the conventions are widely shared:

  • go.mod / go.sum at the repository root define the module.
  • main packages — the commands you build — live under cmd/<name>/, one directory per binary.
  • Library packages live at the root or under descriptive directories; put shared-but-private code under internal/.
  • Package directories are named for what they provide (store, token, httpapi), lowercase, no underscores. The directory name is usually the package name.

There is no framework-mandated src/, no deep nesting for its own sake. A small tool can be a single package; a large service groups packages by responsibility with internal/ guarding the parts that aren’t a public API.

go doc reads your own packages

The documentation tool you used on the standard library works on your code with no extra step, because Go’s docs are the source comments. Point go doc at your package:

$ go doc ./greet
package greet // import "example.com/hello/greet"

Package greet formats friendly greetings.

func Hello(name string) string

It lists the exported surface and the package-level comment. Ask for a symbol and you get its doc comment too:

$ go doc ./greet Hello
func Hello(name string) string
    Hello returns a greeting for name. It is exported: capitalized.

That is the whole loop: write a comment above an exported identifier, and it is documentation — readable in the terminal, on pkg.go.dev, and in your editor, all from the one source of truth. This is why idiomatic Go comments the exported names carefully and leaves the private ones to speak for themselves.

Final thoughts

Packages and modules close the language. A package is a directory; capitalization decides what leaves it; internal/ closes what should stay in; imports form a cycle-free graph the compiler guarantees. A module is a go.mod, a set of versioned dependencies pinned reproducibly, a go.sum that makes tampering fail the build, and go mod tidy to keep it all honest. None of it needs a build framework, because the go command is the build system.

Step back and the whole language rhymes with the philosophy from chapter one. One obvious way to do things. A small surface you can hold in your head. Rules the compiler enforces so humans don’t have to argue: unused imports don’t build, exports are a capital letter, formatting is gofmt, cycles are illegal. Errors are values you check, not exceptions you catch. Composition over inheritance, interfaces satisfied structurally, zero values that mean there is no uninitialized memory. Go trades expressiveness for the thing that actually dominates the cost of software over years — reading code you didn’t write — and nearly every spartan choice pays into that.

There is one large piece of Go this series deliberately left for its own book: concurrency. Goroutines and channels are why many people choose the language, and they deserve more than a closing section. That is the next series in this track, Concurrency in Go, where the go keyword, channels, select, and the patterns for coordinating thousands of concurrent tasks get the room they need. You now have the whole sequential language — types, methods, interfaces, errors, generics, and the tools and structure to ship it. That is more than enough to build real things.

Comments