How-to › Document and support developers

How to make a Go client reference render correctly on pkg.go.dev#

Doc comments in the syntax the Go formatter keeps, examples that run under the right symbol, and a local render check before a tag the module proxy keeps forever.

Audience
Library maintainer
Level
beginner
Topic
Publish reference docs
Languages
Go
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A self-hosted pkgsite instancePrivate modules, a network without internet access, or a look at the render before you tagA Go server to run and update, a module cache or proxy beside it, and a URL no search engine sends anyone toThe module is public, where pkg.go.dev renders the same pages at no cost
A separate hosted reference siteThe Go reference has to sit inside a docs site you style, beside guides for other languagesA generator such as gomarkdoc in the build, a host, and a second copy of the reference that drifts from the one Go developers open firstYour readers are Go developers, who already look on pkg.go.dev
pkg.go.devAny public module with a license the site recognizesNo styling, no fix for a published tag other than another tag, and no page at all for a private repositoryThe 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.

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

  1. The doc comment reference records the measurement behind the reformatting. In 2022 a draft of the Go 1.19 gofmt changed 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

  2. 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 go command stops offering it as an upgrade. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.