Documentation and APIs

Updated

September 13, 2026

Documentation and APIs

The boring API is a small exported surface with comments that go doc can print and example tests that go test runs. Compatibility is a promise: do not change a signature because a new name feels nicer. Change it when the old one is wrong, and then bump a major module version.

Mental model

In Go, the documentation is the comments above exported names, in the same file as the code. go doc reads those comments. There is no parallel docbook.

An example test is a function named Example, ExampleT, or ExampleT_M in a _test.go file. If it has an // Output: comment, go test compiles the function, runs it, and compares stdout to that comment. The example shows up in go doc and on pkg.go.dev.

The Go 1 compatibility promise is the standard library’s rule: exported signatures stay. Your module is not the standard library, but the same habit is how a desk API lasts more than one quarter.

Worked examples

Directory layout:

desk/
  go.mod
  ticket/ticket.go
  ticket/example_test.go

Save go.mod at desk/:

module example.com/desk

go 1.27

Case 1: Package and function comments that go doc prints

Save as ticket/ticket.go. The package comment starts with Package ticketgo doc looks for that prefix.

// ticket.go

// Package ticket is the public API for opening desk tickets.
//
// Call Open, then Label. Zero values are not a valid ticket.
package ticket

import "fmt"

// Ticket is an opened desk ticket. Construct with Open, not a struct literal.
type Ticket struct {
    id    int
    table int
}

// Open returns a ticket for the given id and table.
// It returns an error if id or table is not positive.
func Open(id, table int) (Ticket, error) {
    if id <= 0 {
        return Ticket{}, fmt.Errorf("ticket id must be positive")
    }
    if table <= 0 {
        return Ticket{}, fmt.Errorf("table must be positive")
    }
    return Ticket{id: id, table: table}, nil
}

// Label is the one-line form operators print on a stub.
func (t Ticket) Label() string {
    return fmt.Sprintf("ticket %d → table %d", t.id, t.table)
}

From the desk/ directory (the module root):

go doc ./ticket

Output (word wrap may differ):

package ticket // import "example.com/desk/ticket"

Package ticket is the public API for opening desk tickets.

Call Open, then Label. Zero values are not a valid ticket.

func Open(id, table int) (Ticket, error)
type Ticket struct

One symbol:

go doc ./ticket.Open

Output:

package ticket // import "example.com/desk/ticket"

func Open(id, table int) (Ticket, error)
    Open returns a ticket for the given id and table. It returns an error if id
    or table is not positive.

The comment is the API. If you would not say it to a new teammate, do not export the name.

Case 2: An example test is documentation that can fail

Save as ticket/example_test.go. External test package (ticket_test) documents the API the way a caller sees it.

// example_test.go
package ticket_test

import (
    "fmt"

    "example.com/desk/ticket"
)

func ExampleOpen() {
    t, err := ticket.Open(7, 12)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(t.Label())
    // Output:
    // ticket 7 → table 12
}

Run:

go test ./ticket

Output:

PASS
ok      example.com/desk/ticket 0.002s

go test -v ./ticket names the example:

=== RUN   ExampleOpen
--- PASS: ExampleOpen (0.00s)
PASS
ok      example.com/desk/ticket 0.002s

If you change Label and forget the example, the test fails. That is the point. go doc ./ticket.Open will also list the example once it lives next to the package.

Case 3: A caller that only uses the stable surface

Save as main.go at the module root (desk/main.go):

// main.go
package main

import (
    "fmt"
    "os"

    "example.com/desk/ticket"
)

func main() {
    t, err := ticket.Open(7, 12)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(t.Label())
}

Run:

go run .

Output:

ticket 7 → table 12

The caller never mentions id or table fields. You can add an unexported note string to Ticket later without touching this file.

The trap

Renaming Open to NewTicket because a review asked for “consistency” is a compatibility break. Every caller recompiles with edits. This program still works — the damage is social:

Keep ticket/ticket.go from Case 1. Save this as main.go (replace Case 3’s main.go if it is still there):

// main.go
package main

import (
    "fmt"
    "os"

    "example.com/desk/ticket"
)

func main() {
    // After a “cleanup” that renamed Open → NewTicket, this line would not compile:
    // t, err := ticket.NewTicket(7, 12)
    t, err := ticket.Open(7, 12)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(t.Label())
}

Run from the module root:

go run .

Output:

ticket 7 → table 12

The trap is the commented line. If you already published Open, leave it. Add a name only when it does something different. Changing parameter order, changing a field from int to string, or returning *Ticket instead of Ticket are all new APIs. They belong in example.com/desk/v2, not in a quiet Tuesday commit.

Unkeyed struct literals of exported structs are also fragile. That is why Ticket’s fields stay unexported and Open is the constructor.

The boring rule

  • Comment every exported name. Start package comments with Package <name>.
  • Run go doc ./… on the packages you ship. If you cannot explain a name in one sentence, hide it.
  • Add Example… functions with // Output: for the two or three calls you want copied.
  • Do not change exported signatures, parameter order, or types on a whim.
  • Prefer unexported fields plus a constructor over public structs that callers fill in.
  • A new major module path is how you break things on purpose.

Try this

  1. Add // ID returns the ticket id. and a method ID() int. Run go doc ./ticket.Ticket.ID.
  2. In ExampleOpen, change the expected output to a wrong string. Run go test ./ticket and read the diff. Restore it.
  3. Add ExampleOpen_badTable that calls Open(7, 0) and expects the error line. go test ./ticket.
  4. Try to write ticket.Ticket{id: 7} from main.go. Read the compile error. That error is the API working.