You tag the first release of your Go client, open its page on pkg.go.dev, and the overview is one gray paragraph. The bullet list has run together with dashes in the middle, the heading has vanished, and the method you wrote an example for shows a signature with no example under it. The tag is in the module proxy, so fixing the comment fixes nothing until you tag again.
What you get
You will end up with a package comment that renders as headings, a list, and a code block, and examples that run and land under the right symbols. A test guards both. This is for you if readers find your Go client on pkg.go.dev.
Short answer
Write doc comments in the syntax gofmt recognizes: a # heading set off by blank lines,
list items and code indented, link targets as [Text]: URL lines, and [Symbol] for
cross-references. Put ExampleType_Method functions with an Output: comment in a
_test.go file, so go test runs them and pkg.go.dev shows them under the method. Run
pkgsite on the module before you tag, because the proxy keeps every tag it has seen.
You will need
Go 1.24 or later, a Go client module you are about to tag, and a repository the module proxy
can reach. Verified 2026-10-21 against Go 1.24.7 and pkgsite v0.6.0. The sample is a client
for a made-up shipping API, with a test server standing in for the API, so no command here
reaches a network.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
A self-hosted pkgsite instance | Private modules, a network without internet access, or a look at the render before you tag | A Go server to run and update, a module cache or proxy beside it, and a URL no search engine sends anyone to | The module is public, where pkg.go.dev renders the same pages at no cost |
| A separate hosted reference site | The Go reference has to sit inside a docs site you style, beside guides for other languages | A generator such as gomarkdoc in the build, a host, and a second copy of the reference that drifts from the one Go developers open first | Your readers are Go developers, who already look on pkg.go.dev |
| pkg.go.dev | Any public module with a license the site recognizes | No styling, no fix for a published tag other than another tag, and no page at all for a private repository | The module is private, or the reference needs tables, images, or long prose |
pkg.go.dev costs nothing and is where a Go developer looks first. It renders exactly what the
proxy fetched, so a typo in a published comment stays until the next tag. A self-hosted
pkgsite renders the same HTML for a private module, in exchange for a server to run and a URL
nobody finds by searching. A gomarkdoc site adds styling and prose, at the price of a build
step and a second copy to keep in step with the code.
Write the comment in the shape gofmt keeps
The doc comment syntax is small. A paragraph is a run of lines
with no indentation. A heading is a line starting with # , set off by blank lines. A list is a run of indented lines whose first line
starts with a bullet or a number, and a code block is a run of indented lines that does not. A
[Text]: URL line defines a link for [Text] to use in the same comment, and [Client] or
[*HTTPError] links a symbol directly. gofmt rewrites each form to one canonical shape:
dashes with two spaces before them, a tab before code, link definitions at the end, and a #
on an old-style heading.1
// Package shipping is a client for the Example Shipping API.
//
// A [Client] holds one base URL and one API key, sends the key as a bearer
// token on every request, and maps the status codes the API answers with to
// errors a caller can test with [errors.Is].
//
// # Quick start
//
// Construct a client with [New] and call its methods with a context:
//
// c := shipping.New("https://api.example.com", "sk_test_1")
// s, err := c.Get(ctx, "shp_1")
//
// # Errors
//
// Every method returns one of three kinds of error:
//
// - [ErrNotFound] when the API answers 404
// - an [*HTTPError] carrying the status for any other non-2xx answer
// - any other error, from a refused id, the transport, or the JSON decoder
//
// The HTTP endpoints themselves are described in the [API reference].
//
// [API reference]: https://api.example.com/docs
package shipping
go doc prints the comment through the parser the site uses, and it re-wraps paragraphs, so
the line breaks in the source are yours to choose.
go doc -all .
package shipping // import "example.com/shipping"
Package shipping is a client for the Example Shipping API.
A Client holds one base URL and one API key, sends the key as a bearer token on
every request, and maps the status codes the API answers with to errors a caller
can test with errors.Is.
# Quick start
Construct a client with New and call its methods with a context:
c := shipping.New("https://api.example.com", "sk_test_1")
s, err := c.Get(ctx, "shp_1")
# Errors
Every method returns one of three kinds of error:
- ErrNotFound when the API answers 404
- an *HTTPError carrying the status for any other non-2xx answer
- any other error, from a refused id, the transport, or the JSON decoder
The heading, the three list items, and the two code lines all survived, because each was
indented or marked the way the parser expects. errors.Is and ErrNotFound print as plain
words here and become links on the web page.
Attach each example to the symbol it shows
An example is a function in a _test.go file, named by the
testing package’s rules for the symbol it belongs to.
ExampleNew documents the function New and ExampleClient_Get the method. A suffix starting
with a lowercase letter, as in ExampleClient_Get_notFound, adds a second example to the same
method, which the site lists as Example (NotFound) under Get. The // Output: comment
turns the example into a test: go test compares the function’s stdout with the comment and
fails on a difference. An example without one is compiled and never run.
Each example here starts its own httptest server, so the output is fixed and the example
still shows a real HTTP round trip.
func ExampleClient_Get() {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"shp_1","status":"in_transit","weight_grams":1200}`)
}))
defer api.Close()
c := shipping.New(api.URL, "sk_test_1")
s, err := c.Get(context.Background(), "shp_1")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("%s is %s and weighs %d g\n", s.ID, s.Status, s.WeightGrams)
// Output: shp_1 is in_transit and weighs 1200 g
}
func ExampleClient_Get_notFound() {
api := httptest.NewServer(http.NotFoundHandler())
defer api.Close()
c := shipping.New(api.URL, "sk_test_1")
_, err := c.Get(context.Background(), "shp_404")
fmt.Println(errors.Is(err, shipping.ErrNotFound))
// Output: true
}
go test -v -run Example . | grep -E '^(=== RUN|PASS$|FAIL$)'
=== RUN ExampleNew
=== RUN ExampleClient_Get
=== RUN ExampleClient_Get_notFound
PASS
The grep drops the per-example timings, which differ between machines. Three examples ran and
the run ends in PASS, so each Output: comment matched.
Render the module the way pkg.go.dev will
The site is built from two standard packages: go/doc, which
attaches each example to a symbol, and go/doc/comment,
which parses a comment into blocks. The sample’s docview package asks them a reader’s three
questions: what the package comment parsed into, which exported symbols have no comment, and
where each example lands. Like pkgsite, it skips the files a build would skip, such as a
//go:build ignore generator.
// readExamples lists every Example function in the test files and finds the
// symbol go/doc attached it to. One it attached to nothing is one pkg.go.dev
// drops without a word.
func (r *Report) readExamples(p *doc.Package, tests []*ast.File) {
attached := map[string]string{}
suffix := map[string]string{}
record := func(owner string, exs []*doc.Example) {
for _, ex := range exs {
attached[ex.Name] = owner
suffix[ex.Name] = ex.Suffix
}
}
record("package", p.Examples)
for _, f := range p.Funcs {
record(f.Name, f.Examples)
}
for _, t := range p.Types {
record(t.Name, t.Examples)
for _, f := range t.Funcs {
record(f.Name, f.Examples)
}
for _, m := range t.Methods {
record(t.Name+"."+m.Name, m.Examples)
}
}
for _, ex := range doc.Examples(tests...) {
r.Examples = append(r.Examples, Example{
Name: "Example" + ex.Name,
Attached: attached[ex.Name],
Suffix: suffix[ex.Name],
Output: ex.Output != "" || ex.EmptyOutput,
Playable: ex.Play != nil,
})
}
sort.Slice(r.Examples, func(i, j int) bool { return r.Examples[i].Name < r.Examples[j].Name })
}
go run ./cmd/docview .
example.com/shipping, as go/doc reads it
package comment
paragraph Package shipping is a client for the Example Shipping API.
paragraph A Client holds one base URL and one API key, sends the key as a bearer to...
heading Quick start
paragraph Construct a client with New and call its methods with a context:
code 2 lines
heading Errors
paragraph Every method returns one of three kinds of error:
list 3 items
paragraph The HTTP endpoints themselves are described in the API reference.
links doc links 5, URL links 1
exported symbols without a doc comment: none
examples
ExampleClient_Get output yes playable yes shown under Client.Get
ExampleClient_Get_notFound output yes playable yes shown under Client.Get, labeled "notFound"
ExampleNew output yes playable yes shown under New
Every block is the kind it was written as, every exported symbol has a comment, and all three
examples run, are playable, and sit under a symbol. Playable means go/doc could build a whole
program from the example and its file’s declarations, which the Run button on the site needs.
For the page itself, install the server the site runs,
pkgsite, with
go install golang.org/x/pkgsite/cmd/pkgsite@latest, start it in the module directory, and
open http://localhost:8080/example.com/shipping. It renders the working tree, untagged, so it
is the check to run before the tag exists. Against this sample it showed the two headings, the
list, and the three examples under New and Get, the third labeled Example (NotFound).
Then tag, push the tag, and ask the proxy for it with
GOPROXY=https://proxy.golang.org GO111MODULE=on go get example.com/shipping@v1.0.0, one of the
three routes the about page lists. The
proxy’s FAQ puts a version requested this way a minute away, and
one somebody requested before the tag was pushed at up to thirty minutes. The site indexes the
first sentence of the package comment for search.
Check it worked
Run the whole suite. The docs test asserts the shape the comment was written with, two headings,
one list, and one code block. It also fails on an exported symbol with no comment and on an
example with no Output: comment or no symbol to sit under.
func TestDocsRenderAsWritten(t *testing.T) {
r, err := docview.Inspect(".")
if err != nil {
t.Fatal(err)
}
kinds := map[string]int{}
for _, b := range r.Blocks {
kinds[b.Kind]++
}
if kinds["heading"] != 2 || kinds["list"] != 1 || kinds["code"] != 1 {
t.Errorf("package comment parsed as %v, want 2 headings, 1 list, 1 code block", kinds)
}
for _, s := range r.Undocumented {
t.Errorf("%s has no doc comment", s)
}
if len(r.Examples) == 0 {
t.Fatal("no examples found")
}
for _, ex := range r.Examples {
if ex.Attached == "" {
t.Errorf("%s names no symbol in the package, so pkg.go.dev drops it", ex.Name)
}
if !ex.Output {
t.Errorf("%s has no Output comment, so go test compiles it and never runs it", ex.Name)
}
if !ex.Playable {
t.Errorf("%s cannot be made a whole program, so pkg.go.dev shows it without a Run button", ex.Name)
}
}
}
go test -v . | grep -E '^(=== RUN|PASS$|FAIL$)'
=== RUN TestDocsRenderAsWritten
=== RUN TestInspectReadsOnlyTheFilesTheBuildUses
=== RUN TestDocviewExitsOneOnTheProblemsItPrints
=== RUN TestGetSendsTheKeyAsABearerToken
=== RUN TestGetMapsStatusCodesToErrors
=== RUN TestGetKeepsTheIDToOnePathSegment
=== RUN ExampleNew
=== RUN ExampleClient_Get
=== RUN ExampleClient_Get_notFound
PASS
Remove the two spaces before the dashes in the Errors list and the run ends in FAIL, with
package comment parsed as map[code:1 heading:2 paragraph:6] naming what the list became.
When it goes wrong
The overview is a paragraph with dashes in it. The list was written flush with the comment’s
left margin, and a line with no indentation is paragraph text by the syntax rules, so the parser
joined the items into one paragraph. gofmt cannot repair that, as the diff below shows. The
pitfall module in the sample carries that list, an old-style heading, and a code block
indented by two spaces.
gofmt -d pitfall/pitfall.go
diff pitfall/pitfall.go.orig pitfall/pitfall.go
--- pitfall/pitfall.go.orig
+++ pitfall/pitfall.go
@@ -1,7 +1,7 @@
// Package pitfall is the shipping client with its documentation written the
// way a Markdown habit suggests, which is not the way go/doc reads it.
//
-// Quick start
+// # Quick start
//
// Create a client with New and call Get:
// - the key is sent as a bearer token
@@ -8,8 +8,8 @@
// - every call takes a context
// - a missing shipment is ErrNotFound
//
-// c := pitfall.New("https://api.example.com", "sk_test_1")
-// s, err := c.Get(ctx, "shp_1")
+// c := pitfall.New("https://api.example.com", "sk_test_1")
+// s, err := c.Get(ctx, "shp_1")
//
// See [the HTTP reference] for the status codes.
package pitfall
The heading gained its # and the code block its tab. The list is untouched, and the inspector
shows what the page will make of it.
go run ./cmd/docview ./pitfall
example.com/pitfall, as go/doc reads it
package comment
paragraph Package pitfall is the shipping client with its documentation written the...
heading Quick start
paragraph Create a client with New and call Get: - the key is sent as a bearer toke...
code 2 lines
paragraph See [the HTTP reference] for the status codes.
links doc links 0, URL links 0
exported symbols without a doc comment:
Client.Track
examples
ExampleClient_Fetch output yes playable yes shown under nothing: it names no symbol in the package, so pkg.go.dev drops it
ExampleClient_Get output no playable yes shown under Client.Get
exit status 1
The list is a paragraph with the dashes inline, and two spaces before each dash is the fix.
The run exits 1 on these findings, so CI can use it.
Client.Track has no doc comment, so the page shows a bare signature. ExampleClient_Fetch
names a method Client does not have, so go/doc attached it to nothing and the site drops it
without a message. go test catches that one, because it runs the
tests vet analyzer before
building the test binary:
go -C pitfall vet .
./example_test.go:15:1: ExampleClient_Fetch refers to unknown field or method: Client.Fetch
pkg.go.dev never runs go test or go vet. It reads the source zip the proxy serves, so a
module whose tests never ran is published with the example missing, and the page is the first
place anyone notices.
The page still shows the typo after you pushed the fix. The proxy caches a version the first
time anyone fetches it, and the about page states that published
versions cannot be reused or modified. Moving the tag makes it worse. Every go get checks the
download against the checksum database, so users who fetched the old bytes get a security error
instead of your fix, as the publishing guide warns.
Tag a patch version and request it. The retract directive is for a version that must not be
depended on,2 and the
modules reference says a retracted version stays
available on proxies.
The page lists the module and shows no documentation. The site detects the license from a file
with one of the names its license policy lists, and shows
only limited information when it detects none. Edited license text and an unusual filename both
defeat the detection. Add an unmodified copy of a recognized license as LICENSE and tag again.
When not to do this
Do not run your own pkgsite for a public module to get a theme or a logo. The server renders
the same HTML with the same templates, so the result is a copy of pkg.go.dev at an address no
search engine ranks. Use it for modules the proxy cannot see: it serves a directory, a module
cache, or a private proxy. The modules reference
covers the GOPRIVATE setting that keeps those modules off the public services.
Do not put the whole reference into the package comment. The syntax has no tables, no images,
and no nested lists, and a list item holds paragraphs only. Write the overview and the short
code block there, and link the long guide from a [Text]: URL line.
Do not give an example an Output: comment when its output depends on a network, a clock, or
map iteration order. The example then fails on the first machine where the world differs, and
the usual repair is to delete the comment, which turns a test back into decoration. Start a test
server, fix the data, or print a value that holds everywhere.
Related how-tos
Last verified
Verified 2026-10-21 against Go 1.24.7 and pkgsite v0.6.0. Every output block is what the
command preceding it printed in the page’s code directory. pkgsite ran by hand against the
sample and the pitfall module to confirm the headings, the list, the example labels, and the
dropped example. It was built with the go1.26.10 toolchain, because its go.mod asks for Go
1.26. That run is not one of the checked commands. Nothing was tagged, pushed, or fetched from the module proxy or
from pkg.go.dev.
Footnotes
-
The doc comment reference records the measurement behind the reformatting. In 2022 a draft of the Go 1.19
gofmtchanged only 3% of the doc comments in public Go modules. About 87% of those kept the structure a reader would have inferred, and about 6% were tripped by lists, shell commands, and brace-delimited blocks written flush left. The heuristics that merge such a line into a neighboring indented block were added for that 6%, and a list with no indented line in it is outside their reach. ↩︎ Back to text -
The modules reference says where the word comes from. It is borrowed from academic literature, where a retracted paper is still available but has problems and should not be the basis of future work. The directive carries the same meaning: the version stays on the proxy with its checksum and its dependents’ builds intact, and the
gocommand stops offering it as an upgrade. ↩︎ Back to text