Skip to content
gohacks.dev
Basics

Read Go Code Written by Other People Much More Quickly

A working method for reading an unfamiliar Go codebase: shape first, entry points, types, tests as documentation, dependencies and concurrency.

10 min read
Summarize in AI
Flat illustration of an editor window full of code.

Most of your time as a programmer goes to reading code that someone else wrote, and Go is unusually kind to a reader. The language is small, the formatting is uniform, the layout conventions are shared and the tools that explain code ship with the compiler. What is left is method: knowing where to look first and what to ignore. This guide is the order I follow on a Go codebase I have never seen, with each step tied to a command or an editor feature. I use the standard library's text/tabwriter as the practice target, since it is small enough to read in an hour and every command works on it without downloading anything. The guide sits in the Go basics path and leans on packages and exported names, naming conventions and doc comments.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64); commands were run against the standard library package text/tabwriter

Start from the outside: module, tree, packages

Before opening a source file, ask three questions. What is this module called and what does it depend on? What programs does it build? What are the packages? The answers come from go.mod, the directory listing and one command.

go.mod names the module and the Go version, and lists every dependency with its version. A dozen requirements that are familiar names, a router, a database driver, a logger, tell you the shape of the program before you read a line. A hundred of them, mostly indirect, tell you a larger system. Then list the packages:

Shell
go list -f '{{.ImportPath}}: {{len .GoFiles}} files, {{len .XTestGoFiles}} external test files' text/tabwriter
go list -f '{{join .Imports ", "}}' text/tabwriter
Text
text/tabwriter: 1 files, 2 external test files
fmt, io, unicode/utf8

On your own project the argument is ./..., which lists every package in the module. The output is the module's table of contents: a handful of packages is a small library, a cmd directory with several subdirectories is a project with multiple binaries, and an internal directory is code the authors consider private. The second command shows what this package imports. The fewer standard library packages a package pulls in, the more self-contained it is, and a package that imports net/http, database/sql and os/exec is doing real work at the edges of the system.

Find the entry points

A program starts in a func main in a package main. Everything else is reachable from there, so find the mains first:

Shell
grep -rn "^func main()" --include=*.go . | head

Then look at what main does. In a well-structured program it is short: parse flags or config, build the dependencies, start a server or run a command, wait for a signal. The order of construction in main is the dependency graph of the program written as code. If main is long, with business logic inline, you are reading a script and not a layered design, and you can read it top to bottom.

Other entry points hide in places a call graph will not show you. HTTP handlers are registered with mux.HandleFunc, r.Get or a similar call, and a search for those registrations gives you a list of every URL the program serves. init functions run before main, and a blank import such as _ "net/http/pprof" adds behavior with no call at all, as the init article explains. Goroutines started with go and callbacks passed to time.AfterFunc or signal.Notify run on their own schedule. Searching for go func, go followed by a name, and HandleFunc finds most of them.

Read the types before the functions

Go programs are organized around types, and the type definitions say more about the design than any function. For an unfamiliar package, read its public surface first with go doc, which works on any package you have the source for, including the standard library and your dependencies:

Shell
go doc text/tabwriter | head -30
Text
package tabwriter // import "text/tabwriter"

Package tabwriter implements a write filter (tabwriter.Writer) that translates
tabbed columns in input into properly aligned text.

The package is using the Elastic Tabstops algorithm described at
http://nickgravgaard.com/elastictabstops/index.html.

The text/tabwriter package is frozen and is not accepting new features.

const FilterHTML uint = 1 << iota ...
const Escape = '\xff'
type Writer struct{ ... }
    func NewWriter(output io.Writer, minwidth, tabwidth, padding int, padchar byte, flags uint) *Writer

You get the package comment, the exported constants, types and function signatures, in a screenful. The question to ask is what the central type is and what it needs. In text/tabwriter it is Writer, created with NewWriter or Init, written to like any io.Writer, and finished with Flush. That one paragraph is the design. The same approach on an application package tells you what a Store or a Server does before you read how.

Then look at the struct definition itself, with go doc -src or by opening the file. The fields tell you the state, and unexported fields tell you what the authors had to remember. A struct with a sync.Mutex and a map is shared mutable state with locking. A struct holding a chan and a context.Context is a long-running worker. Embedded types tell you what is inherited, though embedding is composition and not a class hierarchy. Interfaces are the seams: where an interface is defined, and who satisfies it, tells you what the authors expected to replace in tests or at run time. Interfaces in Go explains why they are usually declared next to the consumer.

Let the editor follow the code

Text search finds strings. A language server finds meaning. With gopls, which every Go editor integration uses, these commands do most of the work of reading:

CommandThe question it answers
Go to definitionWhere is this function, type or method declared?
Find referencesWho calls this, uses this field or implements this?
Go to implementationsWhich concrete types satisfy this interface? And from a type, which interfaces does it satisfy?
Call hierarchyWho calls this function, and what does it call, to a given depth?
HoverWhat are the type and the doc comment?
RenameSafe for exploration too: rename a confusing identifier to what you now understand it to be, then undo

Grepping for Save will find every comment, every variable and every method called Save on twelve unrelated types. "Find references" on one specific method finds exactly its callers. "Go to implementations" on an interface method is the move that makes Go readable: the interface in the caller does not tell you what code runs, and the implementations list does. Use it on every interface you meet. If you do not have an editor handy, go doc -all and grep still get you most of the way.

Read the tests

A test file is a list of the behaviors the authors care about, written as code. For an unfamiliar package, list the test names before you read anything else:

Shell
go test -list '.*' text/tabwriter
Text
Test
TestPanicDuringFlush
TestPanicDuringWrite
BenchmarkTable
BenchmarkPyramid
BenchmarkRagged
BenchmarkCode
ExampleWriter_Init
Example_elastic
Example_trailingTab
ok  	text/tabwriter	0.291s

Test and example names are sentences: TestFlush, TestPanicDuringWrite and similar. Reading the list tells you what the code is supposed to handle, including the edge cases the authors learned about the hard way. Then read one table-driven test closely, because its table is a compact specification, input on the left and expected output on the right. Example functions are the best documentation of all, since go test runs them and compares their output. Run them with -v to see the names and confirm they pass in your environment:

Shell
go test -run 'Example' -v text/tabwriter 2>&1 | head -12
Text
=== RUN   ExampleWriter_Init
--- PASS: ExampleWriter_Init (0.00s)
=== RUN   Example_elastic
--- PASS: Example_elastic (0.00s)
=== RUN   Example_trailingTab
--- PASS: Example_trailingTab (0.00s)
PASS
ok  	text/tabwriter	0.202s

If the tests do not pass on your machine, that is information too, usually about a missing service, an environment variable or a build tag, and you want to know before you change anything. Another way tests help is go test -cover, which tells you which paths the authors thought worth testing. Code with no tests deserves extra caution when you edit it.

Follow the dependencies

You will often need to know why a dependency is there, or what a transitive one does. Three commands cover it:

Shell
go list -deps text/tabwriter | head -8
Text
internal/goarch
unsafe
internal/abi
internal/unsafeheader
internal/cpu
internal/bytealg
internal/byteorder
internal/chacha8rand

In a module with dependencies, two more commands apply: go mod why golang.org/x/text prints the chain of imports that pulls a module in, and go mod graph prints the whole requirement graph.

go list -deps prints the packages a package depends on, directly or not, in dependency order. In a module, go mod why explains why a module is in the build, giving the chain of imports from your code. go mod graph prints the whole module requirement graph, which is long but greppable. If a dependency looks odd, check its go doc and its repository's README before you decide it is a mistake. Why go.mod exists at all is covered earlier, and the module cache under $GOPATH/pkg/mod holds the source for every dependency, so you can read a library's code with the same tools as your own.

Trace one path end to end

The mistake that costs the most time is reading files in order. A program is not a book. Pick one concrete behavior, such as "what happens when a client calls POST /orders" or "what does app sync do", and follow it. Start at the entry point you found, go to the handler, follow the call to the layer below, note each decision and each side effect, and write the chain on paper. For a request that is usually five or six hops: router, handler, service, store, SQL, and back, with the errors that can be returned at each step. After one trace you know how the layers divide responsibility, and the second trace takes a quarter of the time.

When reading is not enough, run it. go run . with a debug flag, go test -run TestName -v, a log.Printf at the point you are unsure of, or the dlv debugger (dlv test) are all cheaper than guessing. The race detector, go test -race, also doubles as a reading tool: a race report names the two code paths that touch the same variable, which is often the fastest way to learn what shares state.

Reading concurrency

Concurrent code is where reading gets hard, and Go makes it findable. Three searches orient you: every go statement, every channel declaration and every sync. type. Each goroutine has an owner, and a good codebase makes it obvious who starts it, how it stops and what it communicates over. Look for the context.Context that cancels it, the WaitGroup that waits for it and the channel that it closes when it is done. If you cannot find how a goroutine ends, it probably does not, and goroutine leaks are the next thing to check. A struct that has a Close or Stop method owns something running. The patterns repeat, which is why concurrency patterns that survive production is a good thing to have read first.

Recognizing idioms so you do not stop at them

A reader new to Go meets a few constructions that look strange and are completely standard. Knowing them saves minutes each time.

  • if err != nil { return ... } after nearly every call is the error convention, not noise, as functions and multiple returns describes.
  • func New(opts ...Option) *Thing is functional options, a way of passing optional configuration.
  • A first parameter ctx context.Context is cancellation and deadline plumbing, and it is passed through every call that might block.
  • defer mu.Unlock() and defer f.Close() are cleanup tied to the function, as defer explains.
  • var _ Interface = (*Type)(nil) is a compile-time check that the type satisfies the interface, with no run-time effect.
  • A blank import _ "pkg" runs a package's init for its side effects.
  • Methods with a one-letter receiver, func (s *Server) ..., are normal; the letter is the type's initial.
  • //go:generate, //go:build and //go:embed comments are directives that the tools read.

None of these needs special knowledge beyond a lookup, and after a week of reading they become invisible.

Where this method needs adapting

Very large codebases need a map before a trace, and the map is often a diagram in a design document, which you should ask for. Generated code, such as protocol buffer stubs and ORM models, is not worth reading line by line; read the generator's input and trust the output. Code with heavy reflection or dependency injection frameworks hides its wiring from Find references, and there you read the configuration. And if the project predates Go modules or uses vendor heavily, the paths differ and the commands still work. Whatever the project, the order stays: outside before inside, types before functions, one path before the whole. The official Go Code Review Comments is worth reading once, because reviewers apply it and it explains many of the conventions you will see, and the go doc reference lists the flags used above.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.