A Go module is a tree of packages under one go.mod file. Learn how import paths, exported names and internal folders work, then test the code with table-driven tests, subtests, examples and benchmarks.
Every program so far in this series fit in one file called main.go. Real Go code doesn’t stay that small. You split it into packages, the packages live in a module, and the module comes with tests that go test runs.
This post builds one small module, a word counter with a library, a private helper package, a command and tests, and walks through each file. Every program below was run on Go 1.26, and its output is pasted from the run. The code shown comes straight from the module’s files, and those files pass go vet, go test and go test -race.
A package is a folder, a module is a tree of them
A Go package is all the .go files in one folder, and a module is a folder tree of packages with one go.mod file at the top. Here is the whole module this post uses:
11-wordcount/
├── go.mod
├── wordcount.go
├── wordcount_test.go
├── example_test.go
├── internal/
│ └── tokenize/
│ ├── tokenize.go
│ └── tokenize_test.go
└── cmd/
└── wc/
├── main.go
└── main_test.go
That’s three packages. The files at the top form package wordcount, the library. internal/tokenize splits text into words. cmd/wc is a small program that uses the library.
Every file in a folder must say the same package name on its first line, and the folder holds exactly one package. The one exception is test files, which come up below.
The go.mod file is two lines:
module example.com/wordcount
go 1.26
The module line is the module path. It’s the prefix of every import path inside the module. Add a package’s folder to the end, and you have its import path. go list prints them:
$ go list ./...
example.com/wordcount
example.com/wordcount/cmd/wc
example.com/wordcount/internal/tokenize
./... means “this folder and every folder below it”. You’ll use it with go test, go vet and go build too.
So the import path is not a file name, and it isn’t a path on your disk. It’s the module path joined to the folder path. The package name, the word after package, is usually the last part of that path, and it’s what you type before the dot: tokenize.Words, wordcount.Count.
Exported names are the only way in
Code in one Go package can use another package’s names only when they start with a capital letter. You met that rule with strings.ToUpper in the first part. It works the same way for packages you write. Here’s the library:
// Package wordcount counts words in text.
package wordcount
import (
"cmp"
"slices"
"strings"
"example.com/wordcount/internal/tokenize"
)
// Pair is one word and how many times it appeared.
type Pair struct {
Word string
Count int
}
// Count returns how many times each word appears in text.
// Words are compared without regard to case.
func Count(text string) map[string]int {
counts := map[string]int{}
for _, w := range tokenize.Words(text) {
counts[normalize(w)]++
}
return counts
}
// Top returns the n most frequent words, most frequent first.
// Words with the same count are sorted alphabetically.
func Top(counts map[string]int, n int) []Pair {
pairs := make([]Pair, 0, len(counts))
for w, c := range counts {
pairs = append(pairs, Pair{Word: w, Count: c})
}
slices.SortFunc(pairs, func(a, b Pair) int {
if c := cmp.Compare(b.Count, a.Count); c != 0 {
return c
}
return cmp.Compare(a.Word, b.Word)
})
return pairs[:min(n, len(pairs))]
}
// normalize is unexported: only code in package wordcount can call it.
func normalize(word string) string {
return strings.ToLower(word)
}
Pair, Count and Top start with a capital letter, so they’re exported. So are the fields Word and Count. normalize starts with a lower-case letter, so only code in package wordcount can call it. Count calls it freely, because they live in the same package.
The ties in Top are broken alphabetically on purpose. Map iteration order changes from run to run, and a function that returns results in a random order is hard to test.
To see the rule enforced, I added an example function to a test file that sits outside the package and calls wordcount.normalize("Go"). go test refused to build it:
$ go test .
# example.com/wordcount_test [example.com/wordcount.test]
./example_test.go:27:24: undefined: wordcount.normalize
FAIL example.com/wordcount [build failed]
Notice what the message says. It doesn’t say “normalize is private”. It says undefined. From outside the package, an unexported name doesn’t exist at all.
That’s what makes an unexported name safe to change. You can rename normalize, change its arguments or delete it, and no code outside the package can break, because none can reach it.
internal/: packages only this module can import
A folder named internal makes the packages under it importable only by code rooted at the folder that contains internal. Here internal sits at the top of the module, so every package in the module can import tokenize, and nothing outside can:
// Package tokenize splits text into words.
package tokenize
import (
"strings"
"unicode"
)
// Words splits text into words. A word is a run of letters, digits and
// apostrophes. Apostrophes at either end of a word are dropped.
func Words(text string) []string {
fields := strings.FieldsFunc(text, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\''
})
words := fields[:0]
for _, f := range fields {
if w := strings.Trim(f, "'"); w != "" {
words = append(words, w)
}
}
return words
}
Words is exported, so package wordcount can call tokenize.Words. But it’s exported only within the module.
To check, I made a second module, example.com/other, that imports both packages:
$ go build .
package example.com/other
main.go:7:2: use of internal package example.com/wordcount/internal/tokenize not allowed
Importing example.com/wordcount itself was fine. The import of internal/tokenize was refused by the go command before the compiler looked at a single line.
That gives you a place for code you want to share across your own packages without promising it to anyone else. If tokenize were a normal package, someone could import it, and changing Words would break their code. Under internal, you can change it whenever you like.
Explain it like I’m ten
Think of a module as a house, and each package as a room in it.
Things inside a room with a lower-case name stay in that room. Nobody from another room can touch them. They can’t even see them.
Things with a capital letter sit by the room’s door, facing the hallway. Anyone who walks up to the door can use them. That’s what exported means.
The internal rooms are the family rooms. Their doors open onto the hallway too, but a sign at the front door says “family only”. People who live in this house can walk in. A visitor from another house gets stopped at the front door.
The precise version
A name declared at the top level of a package is exported when its first character is an upper-case letter. Only exported names can be referred to from another package. That’s checked by the compiler.
An import path containing an internal element can be imported only by packages whose path starts with the part before internal. example.com/wordcount/internal/tokenize can be imported by example.com/wordcount and anything below it. That’s checked by the go command when it resolves imports.
The two rules stack. A name inside an internal package needs a capital letter and an allowed importer.
Where the analogy breaks: rooms in a real house are side by side. Go’s rule follows the folder tree. An internal folder three levels down only lets in packages from the folder above it, so “family” can be a small part of the house, not the whole house.
A command that uses the library
A folder whose files say package main builds a program, and inside a module it imports the library by its import path like any other package. Putting commands under cmd/<name> is a common layout, not a rule:
// Command wc prints the most frequent words read from standard input.
package main
import (
"fmt"
"io"
"log"
"os"
"example.com/wordcount"
)
func main() {
if err := run(os.Stdin, os.Stdout, 3); err != nil {
log.Fatal(err)
}
}
// run reads all of r and writes the n most frequent words to w.
func run(r io.Reader, w io.Writer, n int) error {
text, err := io.ReadAll(r)
if err != nil {
return err
}
counts := wordcount.Count(string(text))
for _, p := range wordcount.Top(counts, n) {
fmt.Fprintf(w, "%-8s %d\n", p.Word, p.Count)
}
return nil
}
main does almost nothing. The work is in run, which takes an io.Reader and an io.Writer instead of using os.Stdin and os.Stdout directly. That makes it easy to test, as you’ll see further down.
Feed it the sentence from the part on maps:
$ printf 'the cat sat on the mat and the cat slept' | go run ./cmd/wc
the 3
cat 2
and 1
go run ./cmd/wc names the package folder. You didn’t write anything in go.mod to make the import work, because example.com/wordcount is inside the module you’re in.
Other people’s code: go get, go mod tidy and go.sum
This series uses only the standard library, so the module has no dependencies. You’ll still add them to real projects, and the steps are short.
You add an import for the package in your code, then run go mod tidy. It reads every import in the module, downloads the modules that provide the missing ones, and writes a require line for each into go.mod. It also removes require lines nothing imports any more. go get example.com/some/module@v1.2.3 does the adding directly, when you want a particular version.
On this module, go mod tidy prints nothing and changes nothing, because there’s nothing to add or remove. Run it anyway before you commit. It’s the quickest way to keep go.mod honest.
The first time a module gains a dependency, a second file appears: go.sum. It holds a cryptographic hash of every module version the build uses. The next time anyone downloads that version, on any machine, the go command checks it against the hash and stops if the contents changed. Commit go.sum with go.mod, and don’t edit it by hand.
Your first test
A Go test lives in a file whose name ends in _test.go, in the same folder as the code it tests. A test is a function whose name starts with Test and that takes a *testing.T:
package tokenize
import (
"slices"
"testing"
)
func TestWords(t *testing.T) {
got := Words("Don't panic -- 'quoted' words, 42 times!")
want := []string{"Don't", "panic", "quoted", "words", "42", "times"}
if !slices.Equal(got, want) {
t.Errorf("Words() = %q, want %q", got, want)
}
}
There’s no assertion library. You compare the values yourself and call t.Errorf when they’re wrong. The message follows a Go habit: say what you called, what you got, and what you wanted.
go build ignores _test.go files, so tests never end up in your program. go test compiles them with the package and runs every Test function. go test ./... does that for every package in the module:
$ go test ./...
ok example.com/wordcount 0.005s
ok example.com/wordcount/cmd/wc 0.004s
ok example.com/wordcount/internal/tokenize 0.004s
The duration at the end of each line changes on every run. Run it again without changing anything, and each line says (cached) instead. go test remembers a passing result until the code or the test changes. Add -count=1 when you want the tests to run again anyway.
Table-driven tests with subtests
A table-driven test puts the cases in a slice of structs and runs the same check on each one. It’s the most common shape of test in Go code:
package wordcount
import (
"maps"
"testing"
)
func TestCount(t *testing.T) {
tests := []struct {
name string
text string
want map[string]int
}{
{"empty", "", map[string]int{}},
{"one word", "go", map[string]int{"go": 1}},
{"mixed case", "Go go GO", map[string]int{"go": 3}},
{"punctuation", "stop. Stop, stop!", map[string]int{"stop": 3}},
{"apostrophes", "don't 'quote' me", map[string]int{"don't": 1, "quote": 1, "me": 1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Count(tt.text)
if !maps.Equal(got, tt.want) {
t.Errorf("Count(%q) = %v, want %v", tt.text, got, tt.want)
}
})
}
}
Adding a case is one line in the table. t.Run runs each case as a named subtest, with its own t. A failing case reports its name, and the other cases still run.
This file says package wordcount, the same as the code it tests. That puts the test inside the room, so it can test unexported names too. This is the whole of TestNormalize:
func TestNormalize(t *testing.T) {
if got := normalize("HeLLo"); got != "hello" {
t.Errorf("normalize(%q) = %q, want %q", "HeLLo", got, "hello")
}
}
-v lists every test and subtest as it runs:
$ go test -v -run TestCount .
=== RUN TestCount
=== RUN TestCount/empty
=== RUN TestCount/one_word
=== RUN TestCount/mixed_case
=== RUN TestCount/punctuation
=== RUN TestCount/apostrophes
--- PASS: TestCount (0.00s)
--- PASS: TestCount/empty (0.00s)
--- PASS: TestCount/one_word (0.00s)
--- PASS: TestCount/mixed_case (0.00s)
--- PASS: TestCount/punctuation (0.00s)
--- PASS: TestCount/apostrophes (0.00s)
PASS
A final ok line with a duration follows, as before.
-run takes a regular expression and runs only the tests whose names match. A slash in the pattern reaches into subtests. The case names had spaces, and Go replaced them with underscores. You can type either form. -run 'TestCount/mixed case' and -run TestCount/mixed_case both pick the same single subtest, because the pattern gets the same rewrite:
$ go test -v -run 'TestCount/mixed case' .
=== RUN TestCount
=== RUN TestCount/mixed_case
--- PASS: TestCount (0.00s)
--- PASS: TestCount/mixed_case (0.00s)
PASS
t.Errorf, t.Fatalf and t.Helper
t.Errorf marks a test as failed and keeps going, while t.Fatalf marks it as failed and stops that test straight away. The helper that checks Top‘s results uses both:
// assertPairs fails the test if got and want differ.
func assertPairs(t *testing.T, got, want []Pair) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %d pairs, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("pair %d = %v, want %v", i, got[i], want[i])
}
}
}
func TestTop(t *testing.T) {
counts := map[string]int{"a": 1, "b": 3, "c": 3, "d": 2}
assertPairs(t, Top(counts, 2), []Pair{{"b", 3}, {"c", 3}})
assertPairs(t, Top(counts, 10), []Pair{{"b", 3}, {"c", 3}, {"d", 2}, {"a", 1}})
assertPairs(t, Top(counts, 0), []Pair{})
}
If the lengths differ, the loop below would index past the end of a slice and panic. So that check uses t.Fatalf. If the lengths match, every wrong pair is worth seeing, so the loop uses t.Errorf.
To watch the difference, I broke the second call’s expectation to three pairs, and the third call’s to one pair. Only one failure was reported:
$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
wordcount_test.go:53: got 4 pairs, want 3: [{b 3} {c 3} {d 2} {a 1}]
FAIL
t.Fatalf stopped TestTop at line 53, so the broken check on line 54 never ran. t.Fatalf stops the current test or subtest, not the whole run. Other tests still run.
The line number is t.Helper()‘s doing. It marks assertPairs as a helper, so failures report the line that called it. With the tests restored, I swapped the expected order in the first call on line 52:
$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
wordcount_test.go:52: pair 0 = {b 3}, want {c 3}
wordcount_test.go:52: pair 1 = {c 3}, want {b 3}
FAIL
Then I commented out t.Helper() and ran it again:
$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
wordcount_test.go:44: pair 0 = {b 3}, want {c 3}
wordcount_test.go:44: pair 1 = {c 3}, want {b 3}
FAIL
Line 44 is the t.Errorf inside the helper. It’s the same for all three calls, so it doesn’t tell you which call failed. Put t.Helper() first in every test helper.
Testing the command
A package main can have tests too, and the run function makes that easy. The test hands it a string reader and collects what it writes:
package main
import (
"strings"
"testing"
)
func TestRun(t *testing.T) {
in := strings.NewReader("one two two three three three")
var out strings.Builder
if err := run(in, &out, 2); err != nil {
t.Fatalf("run: %v", err)
}
want := "three 3\ntwo 2\n"
if got := out.String(); got != want {
t.Errorf("run wrote %q, want %q", got, want)
}
}
strings.Builder is an io.Writer, so it can stand in for os.Stdout. Keeping main tiny and passing readers and writers around is how Go programs stay testable without starting a real process.
Example functions are documentation that gets checked
An example function starts with Example, takes no arguments and ends with an // Output: comment. go test runs it and compares what it printed with that comment:
package wordcount_test
import (
"fmt"
"example.com/wordcount"
)
func ExampleCount() {
counts := wordcount.Count("The cat saw the other cat.")
fmt.Println(counts)
// Output: map[cat:2 other:1 saw:1 the:2]
}
func ExampleTop() {
counts := wordcount.Count("to be or not to be, that is the question")
for _, p := range wordcount.Top(counts, 3) {
fmt.Println(p.Word, p.Count)
}
// Output:
// be 2
// to 2
// is 1
}
This file says package wordcount_test, with a _test suffix. It’s the one case where a folder can hold two package names. The _test package is compiled separately and imports wordcount like any outside code would. So the examples use only exported names, exactly as a reader of your documentation would. This is also the file where the normalize call failed to build earlier.
fmt.Println on a map is safe here, because fmt sorts map keys before it prints them.
To see the check work, I changed the expected output of ExampleCount to something wrong:
$ go test -run ExampleCount .
--- FAIL: ExampleCount (0.00s)
got:
map[cat:2 other:1 saw:1 the:2]
want:
map[cat:2 other:1 saw:1 The:1 the:1]
FAIL
That’s the point of examples. go doc and pkg.go.dev show ExampleCount next to Count as usage documentation, and go test fails the moment the documentation stops being true. An example without an // Output: comment is compiled but not run.
Benchmarks with b.Loop
A benchmark is a function whose name starts with Benchmark and that takes a *testing.B. It’s in the same test file:
var sample = "the quick brown fox jumps over the lazy dog and the dog sleeps"
func BenchmarkCount(b *testing.B) {
for b.Loop() {
Count(sample)
}
}
for b.Loop() runs the body as many times as the testing package needs to get a steady measurement. It arrived in Go 1.24. Older code uses for i := 0; i < b.N; i++, which still works. b.Loop is the better choice now: it keeps setup code before the loop out of the timing, and it stops the compiler from optimising away a call whose result you ignore.
go test doesn’t run benchmarks unless you ask:
$ go test -bench=. -run='^$' .
-bench=. runs every benchmark, and -run='^$' matches no test names, so the normal tests are skipped. The output starts with lines naming your OS, CPU architecture, package and CPU model. Then there’s one line per benchmark: its name with the number of CPUs as a suffix, like BenchmarkCount-12, how many times the loop ran, and the average time per run in ns/op. Add -benchmem and the line also shows bytes and allocations per run.
I haven’t pasted the numbers, because they depend on the machine and change from run to run. Compare benchmark results only from the same machine, run a few times.
go vet runs inside go test
go test runs a set of go vet checks on the package before it runs any test, and a vet problem fails the build. To see it, I changed TestNormalize to pass a string to a %d verb:
$ go test .
# example.com/wordcount
# [example.com/wordcount]
./wordcount_test.go:32:29: (*testing.common).Errorf format %d has arg got of wrong type string
FAIL example.com/wordcount [build failed]
No test ran. The same mistake in a normal program would have printed a garbled message at run time. In a test, it stops you before anything runs. go test runs only a subset of vet’s checks, the ones that are almost never wrong, so run go vet ./... yourself as well.
go test -race ./... builds the tests with the race detector turned on. This module has no goroutines, so it passes and has little to find. It becomes essential in the part on goroutines.
What to remember
- A package is one folder of
.gofiles. A module is a tree of packages with onego.mod. An import path is the module path plus the folder path. - A capital first letter exports a name. From outside the package, an unexported name is
undefined. - Packages under
internal/can only be imported from inside the tree that holds thatinternalfolder. go mod tidykeepsgo.modmatching your imports.go.sumrecords hashes of your dependencies. Commit both.- Tests live in
_test.gofiles asfunc TestX(t *testing.T). Use tables andt.Run,t.Errorfto keep going,t.Fatalfto stop, andt.Helperin helpers. go test ./...runs everything,-runpicks tests,-vlists them, and vet checks run first. Examples with// Output:are checked, and benchmarks usefor b.Loop().
In Go, the folder decides the package, the first letter decides who can see a name, and
go testchecks both.