In Go, the comment is also code

2026-04-08

post-thumb

Contents

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.


The basic syntax

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.


1. Documentation that becomes a public website: godoc

The 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.


2. Comments that control what compiles: build constraints

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.

Operators

Build constraints accept boolean logic:

OperatorClassic syntaxNew syntaxMeaning
OR// +build linux darwin//go:build linux || darwincompiles on Linux or macOS
AND// +build windows,386//go:build windows && 386compiles on Windows and 386
NOT// +build !windows//go:build !windowscompiles 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.

Custom tags

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.

The ignore tag

A 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

File name as an implicit build constraint

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.


3. Comments that generate code: go generate

This 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.


4. Comments that become C code: Cgo

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 directives

Besides 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 architectures
  • amd64 386 CFLAGS: -DX86=1 passes this flag only on amd64 and 386
  • LDFLAGS: -lpng tells the linker it needs to link with libpng
  • pkg-config: png cairo uses pkg-config to automatically discover the correct flags for libpng and libcairo

Cgo 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.


Why this matters

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.


To keep in your pocket

A summary to keep open while you write:

CommentWhat it doesWho processes it
// Foo does something. above a declarationBecomes public documentationgo doc, pkg.go.dev
// Package x does Y. before packageDocumentation for the whole packagego doc, pkg.go.dev
//go:build linux (or // +build linux)Restricts compilation to Linuxcompiler
//go:build integrationOnly compiles with -tags=integrationcompiler
//go:build ignoreFile stays in repo but doesn’t compilecompiler
//go:generate cmd argsRuns cmd args during go generatego generate
Comment before import "C"Becomes literal C codeC compiler via cgo
// #cgo LDFLAGS: -lpngConfigures cgo flagscgo

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.