2026-04-08

Comments are that thing everyone learns on day one of any language and immediately assumes they know everything about. In Go, that assumption is expensive. The language turned comments into a first-class tool: the same // can be just a reminder to yourself, become public documentation, decide what the compiler includes in the binary, generate new code at build time, or even turn into actual C code.
This post is about the Go comments that do more than comment.
To get this out of the way: Go supports two kinds of comments, exactly like C.
// single-line comment
// that can span several stacked lines
/* block comment
that works well for long blocks
or when you need an inline comment in the middle of an expression */
x := /* inline in the middle of the line */ 42
In practice, // is the default in almost all idiomatic Go code, including the standard library. /* */ is reserved for specific cases, like very long package documentation or one-off inline comments.
Nothing magical so far. The magic starts when you realize the Go compiler and tools actually read some of these comments.
godocThe first unwritten rule of Go is: the comment preceding a declaration becomes the documentation for that declaration. No annotation, no tag, nothing. Just put the comment immediately above, with no blank line in between.
// Foo applies the foo transformation to the given string.
// Returns an error if the string cannot be foo'd.
func Foo(s string) error {
// ...
}
This comment isn’t just for people reading the code in their editor. It feeds pkg.go.dev, go doc in the terminal, and any tool that generates Go package documentation. Did you export a symbol (name starting with a capital letter)? The convention is that it should have a descriptive comment starting with the symbol’s own name.
It works for anything exportable:
package objects
// Object is a generic container for something.
type Object struct{}
// Process applies the processing logic to Object.
// Returns an error if the object is in an invalid state.
func (o Object) Process() error {
return nil
}
// List contains all currently registered objects.
var List []Object
// MaxObjects defines the maximum number of allowed objects.
const MaxObjects = 50
There’s also package-level documentation: a comment placed immediately before the package declaration, at the top of any file in the package. The convention is to put it in a file called doc.go when the description is long.
// Package objects provides a generic, concurrency-safe container,
// with a global registry and configurable limits.
package objects
One important subtlety: the first sentence of the comment is what appears in the pkg.go.dev index. Write the first sentence as if it were a one-line summary. The rest of the paragraph only appears when someone opens the full page.
Here things start to get fun. Go has a system called build constraints (also known as build tags) that lets you tell the compiler: “this file only makes sense on Linux”, or “only compile when the integration flag is active”. And it all lives inside a comment.
The classic syntax (pre Go 1.17):
// +build linux
package main
// everything here only compiles on Linux
The rule is very specific: the // +build ... comment must be in the first lines of the file, before the package declaration, followed by a blank line before package. If you forget the blank line, it becomes a regular comment and the compiler ignores it.
Starting in Go 1.17, there’s a cleaner syntax:
//go:build linux
package main
Both forms work, and modern projects usually include both for compatibility.
Build constraints accept boolean logic:
| Operator | Classic syntax | New syntax | Meaning |
|---|---|---|---|
| OR | // +build linux darwin | //go:build linux || darwin | compiles on Linux or macOS |
| AND | // +build windows,386 | //go:build windows && 386 | compiles on Windows and 386 |
| NOT | // +build !windows | //go:build !windows | compiles on anything except Windows |
In the classic syntax, multiple lines AND together:
// +build windows
// +build 386
package main
This is equivalent to windows AND 386.
You don’t have to limit yourself to operating systems and architectures. Any word can become a tag, and you activate it with go build -tags="mytag".
//go:build integration
package repository
// tests that only run with: go test -tags=integration ./...
This is how libraries separate unit tests from tests that need a database, for example.
ignore tagA useful convention: use ignore as a tag for files you want to keep in the repo but don’t want the compiler to include in any normal build.
//go:build ignore
package main
// this file is a manual generation helper
// run with: go run file.go
Bonus: if a file is named foo_linux.go, foo_windows.go, or foo_amd64.go, Go applies the constraint automatically, with no comment needed. The suffix is itself the build tag. Combinations work: foo_linux_amd64.go only compiles on Linux amd64.
go generateThis is the one that most surprises people coming from other languages. Go has a subcommand called go generate that scans your code looking for special comments and runs the commands inside them. The syntax is:
//go:generate <command> <arguments>
Pay attention to the detail: there can be no space between the two slashes and go:generate. // go:generate does not work. It has to be //go:generate, all glued together.
The canonical example is the stringer tool, which automatically generates a String() method for enumerated types. Starting with this code:
package order
//go:generate stringer -type=Status
type Status int
const (
StatusPending Status = iota
StatusPaid
StatusCancelled
StatusDelivered
)
When you run go generate ./..., Go finds that comment, runs stringer -type=Status in the directory, and generates a file status_string.go with something like:
// Code generated by "stringer -type=Status"; DO NOT EDIT.
package order
func (i Status) String() string {
switch i {
case StatusPending:
return "Pending"
case StatusPaid:
return "Paid"
case StatusCancelled:
return "Cancelled"
case StatusDelivered:
return "Delivered"
}
return "Unknown status"
}
From then on fmt.Println(StatusPaid) prints Paid, not the underlying integer. All without you writing a single line of the switch.
go generate does not run automatically during go build. It’s an explicit command you must run (ideally automated via Makefile or CI) before compiling. This is intentional: Go prefers generated code to be committed to the repository, not produced at build time.
You can use go:generate with any command, not just stringer. Common tools include mockgen (mock generation), protoc-gen-go (Protobuf), sqlc (typed SQL queries), and similar.
Here the magic leaves Go and enters foreign territory. Cgo is the official mechanism for calling C code from Go, and the way you write that C code is… you guessed it, inside comments.
The pattern is to place a comment block immediately before import "C" (with no blank line between them). Anything inside that comment is passed literally to the C compiler.
package main
// #include <stdio.h>
// #include <errno.h>
import "C"
You can also write entire C functions in there:
package main
// #include <stdio.h>
//
// static void myprint(char *s) {
// printf("%s\n", s);
// }
import "C"
func main() {
C.myprint(C.CString("hello, C world"))
}
That myprint is a real C function, compiled by the system’s C compiler, and called from Go as if it were a regular Go function.
#cgo directivesBesides pure C code, there are special directives starting with #cgo that configure compiler flags:
package images
// #cgo CFLAGS: -DPNG_DEBUG=1
// #cgo amd64 386 CFLAGS: -DX86=1
// #cgo LDFLAGS: -lpng
// #cgo pkg-config: png cairo
// #include <png.h>
import "C"
Translating each line:
CFLAGS: -DPNG_DEBUG=1 passes this flag to the C compiler on all architecturesamd64 386 CFLAGS: -DX86=1 passes this flag only on amd64 and 386LDFLAGS: -lpng tells the linker it needs to link with libpngpkg-config: png cairo uses pkg-config to automatically discover the correct flags for libpng and libcairoCgo is powerful, but it has a real performance cost (every Go-to-C call crosses a boundary) and complicates cross-compilation considerably. I use it as a last resort, when there’s no native Go library for what I need.
You can write Go ignoring all of this. You can live for years using // as just a regular comment and never bump into a //go:build. But understanding this mechanism changes two things in your relationship with the language.
First, you start to read the standard library differently. Suddenly that file with //go:build darwin at the top makes sense, and you understand why the net package has subtly different behaviors on each operating system. The standard library is a hands-on course on how to use build constraints well.
Second, and more importantly: you realize Go made a deliberate and unusual design decision. While other languages invent annotations (@override, @Component), attributes ([Serializable]), macros (#[derive]), or decorators (@classmethod), Go repurposed something every language already has: the comment. The syntax stays simple. Those who don’t know the mechanism ignore it without losing anything. Those who do, gain superpowers without polluting the language’s grammar.
It’s the kind of decision that seems too simple to work, and after a while using it you understand why it works.
A summary to keep open while you write:
| Comment | What it does | Who processes it |
|---|---|---|
// Foo does something. above a declaration | Becomes public documentation | go doc, pkg.go.dev |
// Package x does Y. before package | Documentation for the whole package | go doc, pkg.go.dev |
//go:build linux (or // +build linux) | Restricts compilation to Linux | compiler |
//go:build integration | Only compiles with -tags=integration | compiler |
//go:build ignore | File stays in repo but doesn’t compile | compiler |
//go:generate cmd args | Runs cmd args during go generate | go generate |
Comment before import "C" | Becomes literal C code | C compiler via cgo |
// #cgo LDFLAGS: -lpng | Configures cgo flags | cgo |
The next step is to look at the source code of a large standard library package (I recommend runtime or net) and look for these comments. You’ll find creative uses that don’t fit in one post.
And next time someone tells you a comment isn’t code, smile and tell them to open a .go file.