DEV Community

Cover image for Writing Go Doc Comments That godoc Renders the Way You Meant
Gabriel Anhaia
Gabriel Anhaia

Posted on

Writing Go Doc Comments That godoc Renders the Way You Meant


You open a package on pkg.go.dev to figure out how one function
works. The summary line reads like the middle of a sentence. A
block that was meant to be a code sample renders as a wall of
wrapped prose. A bullet list you carefully typed shows up as one
paragraph with stray dashes. The author knew what the function did.
The renderer disagreed with how they wrote it down.

Go doc comments are not free text. Since Go 1.19 they are a small,
defined markup language that go doc, godoc, and pkg.go.dev all
parse the same way. If you know the handful of rules, your comments
render the way you meant. If you don't, the tool guesses, and its
guesses are conservative. Here is the whole system.

The first sentence is a real API

The convention every Go tool relies on: a doc comment starts with
the name of the thing it documents, and the first sentence is a
complete summary.

// Marshal returns the JSON encoding of v.
func Marshal(v any) ([]byte, error)
Enter fullscreen mode Exit fullscreen mode

That first sentence is not a style preference. go doc shows it in
one-line package summaries. pkg.go.dev uses it as the synopsis in
search results and index listings. Tooling extracts it by taking
everything up to the first period followed by a space. Start with
"This function marshals..." and the synopsis reads "This function
marshals." Start with the name and it reads like an index entry,
because that is what it becomes.

The rule extends to every exported identifier. Types, constants,
functions, methods, and the package itself all get a comment that
begins with the identifier's name:

// Package tokenizer splits source text into lexical tokens.
package tokenizer

// Token is a single lexical unit produced by a Scanner.
type Token struct { ... }

// ErrUnterminated reports a string that never closed.
var ErrUnterminated = errors.New("unterminated string")
Enter fullscreen mode Exit fullscreen mode

go vet will not fail you for skipping this. The tool that will
notice is every reader who reads the synopsis and gets a fragment.

Paragraphs, headings, and lists have exact rules

The Go 1.19 doc comment grammar recognizes four block kinds:
paragraphs, headings, lists, and code blocks. It tells them apart
by indentation and blank lines, not by any special characters you
might expect from Markdown.

A paragraph is one or more lines of text with a blank comment
line above and below. Two paragraphs need a blank // between them,
or they merge into one:

// Scan reads the next token from the input.
//
// It returns io.EOF when the input is exhausted. Any other error
// means the input was malformed and scanning cannot continue.
func (s *Scanner) Scan() (Token, error)
Enter fullscreen mode Exit fullscreen mode

Drop the middle // and both sentences collapse into a single
paragraph on the rendered page.

A heading is a single line, set off by blank lines, that starts
with a # and a space:

// Package cache implements an in-memory LRU cache.
//
// # Eviction
//
// Entries are evicted in least-recently-used order once the
// configured size is exceeded.
//
// # Concurrency
//
// All methods are safe for concurrent use.
Enter fullscreen mode Exit fullscreen mode

The # prefix is the Go 1.19 syntax. A line without the # that
merely looks like a title (short, capitalized, no period) used to
be promoted to a heading by the old heuristic, and pkg.go.dev still
honors that for older comments, but the explicit # is what you
want now. It removes the guessing.

A list is a run of lines that start with a marker. Bullets use
-, *, or +; numbered lists use 1. or 1). The markers must
be indented relative to the surrounding text, and the list needs a
blank line before it:

// Encode writes v to the stream. It supports:
//
//   - structs, maps, and slices
//   - the encoding.TextMarshaler interface
//   - time.Time, rendered as RFC 3339
//
// Anything else returns an error.
Enter fullscreen mode Exit fullscreen mode

Miss the blank line before the list and the whole thing renders as
one paragraph with literal dashes in the middle of it.

Code blocks are indented, not fenced

This is the rule that trips up people coming from Markdown. Doc
comments have no triple-backtick fences. A code block is any run of
lines indented more than the surrounding paragraph text:

// NewClient builds a client with default timeouts.
//
// Typical use:
//
//  c := api.NewClient()
//  defer c.Close()
//  resp, err := c.Get(ctx, "/status")
//
// The returned client is safe for concurrent use.
func NewClient() *Client
Enter fullscreen mode Exit fullscreen mode

The four-line sample is indented one tab past the // prefix. The
renderer treats it as preformatted text: no wrapping, no reflow,
fixed width. That is exactly what you want for code.

Get the indentation wrong and you get the two failure modes from
the top of this post. Under-indent and your sample reflows as
prose. Accidentally indent an ordinary sentence and it renders as a
gray code box in the middle of your explanation. When something
looks off on pkg.go.dev, check the leading whitespace first.

Doc links connect the package to itself

Go 1.19 added doc links. Write an identifier in square brackets and
the renderer turns it into a hyperlink to that symbol's
documentation:

// Decode reads a value from r and stores it in v.
//
// It mirrors [Encode] and shares the same type rules. For streaming
// input, prefer [Decoder.Decode] over calling Decode directly.
func Decode(r io.Reader, v any) error
Enter fullscreen mode Exit fullscreen mode

[Encode] links to the Encode function in the same package.
[Decoder.Decode] links to a method. For symbols in other
packages, use the import path element: [json.Marshal],
[io.Reader]. You can also link to a package as a whole with a
[pkg/path] form.

Plain URLs are turned into links automatically, and you can write a
labeled link with [text] on one line and a [text]: https://...
definition elsewhere in the comment, the same shape as a Markdown
reference link. Everything else that looks like Markdown, including
inline code backticks and **bold**, is rendered literally.
Backticks are just backticks in a Go doc comment.

Deprecation is a keyword, not a sentence

There is one convention every Go tool agrees to read: a paragraph
that starts with Deprecated: marks the symbol as deprecated. The
exact token is Deprecated: at the start of a paragraph. A casual
"this is deprecated" buried in the prose does nothing, and neither
does an all-caps DEPRECATED heading.

// ParseString parses s into a Config.
//
// Deprecated: ParseString does not report line numbers on error.
// Use [Parse] with a [strings.Reader] instead.
func ParseString(s string) (*Config, error)
Enter fullscreen mode Exit fullscreen mode

pkg.go.dev renders the symbol with a strikethrough and a warning
badge. gopls, the language server behind most editors, flags call
sites with a deprecation diagnostic and can strike through the name
inline. Static analysis tools like staticcheck report SA1019
when your code calls it. All of that keys off the literal prefix.
Write "Deprecated" without the colon, or bury it mid-paragraph, and
none of it fires. The symbol looks fine and keeps getting called.

Keep the deprecation paragraph after the normal description, and
always point at the replacement with a doc link so the reader has
somewhere to go.

Examples are tests, and tests are documentation

The strongest documentation in Go is not a comment at all. It is an
example function in a _test.go file. Name a function Example,
ExampleParse, or ExampleScanner_Scan and go test compiles and
runs it like any other test, while pkg.go.dev renders it as a
runnable, syntax-highlighted sample attached to the symbol.

func ExampleParse() {
    cfg, err := config.Parse("port = 8080")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(cfg.Port)
    // Output: 8080
}
Enter fullscreen mode Exit fullscreen mode

The // Output: comment is the contract. go test captures what
the function prints to stdout and compares it against the text after
Output:. If Parse starts returning the wrong port, the example
fails in CI. Your documentation cannot drift from the code, because
the code that produces the docs has to pass.

The naming maps examples to symbols. ExampleParse attaches to the
Parse function. ExampleConfig attaches to the Config type.
ExampleConfig_Reload attaches to the Reload method on Config.
A bare Example attaches to the package. You can attach several
examples to one symbol with a trailing suffix that starts with a
lowercase letter: ExampleParse_invalid, ExampleParse_withDefaults.

Use // Unordered output: instead when you print map contents or
anything where order is not guaranteed. go test then compares the
lines as a set. Omit the output comment entirely and the example
still compiles and appears on pkg.go.dev, but go test does not run
it, so nothing keeps it honest. Add the Output: line whenever you
can.

Check it before you push

You do not need to publish to see what the renderer will do. Run the
doc server locally against your module:

// From your module root:
//  go install golang.org/x/pkgsite/cmd/pkgsite@latest
//  pkgsite -open .
Enter fullscreen mode Exit fullscreen mode

That serves a local copy of pkg.go.dev pointed at your code, so you
see the exact HTML your users will see. For a quick terminal check,
go doc ./... prints the parsed synopses, and go doc -all ./pkg
shows the full rendered comment for one package. Reading your own
output once is usually enough to catch a mangled list or a runaway
code block before anyone else does.

The habit that makes all of this pay off is small: treat the comment
as structured input the renderer parses, and let example functions
carry the samples that have to stay correct.

Go's documentation tooling was designed so that plain, disciplined
comments render well without any extra ceremony. The Complete Guide
to Go Programming
goes deep on the toolchain around this, from
go doc and go test example functions to how gopls reads your
comments in the editor. Hexagonal Architecture in Go is where to
look next if you want those doc comments describing ports and
adapters whose boundaries actually hold up over time.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)