---
title: 'Build Tags'
source: 'https://academia.sh/en/courses/go-production/build-tags'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-10-21T05:03:06+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Measures how many of a directory's source files enter the build by asking only the tool, without running any program"
    - 'Compares how AND, OR and NOT logic in build tags changes the file count across four scenarios'
    - 'Shows that a file left out by its tag keeps sitting on disk but that no tool ever tests it'
---

# Build Tags

Which of six source files in the same directory enters the build is learned not from a running program but from asking the tool; the file count across four tag scenarios is read this way. The limiting measurement shows, by running it, that a file left out by its tag is not deleted from disk but that no tool ever examines it — a flaw in the file becomes visible only once the tag is activated.

The Performance and Memory topic's final lesson measured that a permission, the moment it is
granted, changes the program's behavior; the numbers were read under that effect from then on.
This lesson looks at only one of the same three classes (source, tool, run) and disables the
run entirely: before a program is ever run, even before it is ever compiled, which file in the
source tree enters that build is already decided. The one who makes this decision is neither a
person reading the code nor the running program itself — only the tool, reading a directive
above the source and filtering the file list. The next four lessons follow the same pattern:
which platform to target, what gets embedded in the binary, which version counts as compatible,
and which pattern counts as flawed — all of it is read from the tool alone, without waiting for
a run.

## What a Build Tag Is

A **build tag** is a special comment line written at the very top of a source file, before the
package declaration: `//go:build`. Three formatting rules govern whether the directive works.
The comment must sit among the file's topmost lines; at least one blank line must separate it
from the package declaration; and no line of code may come between the directive and the rest
of the file. If any of these three rules is broken, the compiler treats the directive as an
ordinary comment and the file always enters the build — the directive's violation does not
produce an error, only silent neglect.

The directive's body is a small logical expression: a single name can stand alone
(`expansion`), two names can be joined with `&&` to require both active at once, `||` can
require at least one of the two active, or a leading negation mark can require a name to
**not** be active. These three operators can be combined with parentheses to any depth, but the
six files this lesson measures use each of the three only once — enough to see all three
corners without complicating the logic itself.

This logic's practical counterpart is usually one of two kinds of distinction. First, a package
carrying an extra file that is not normally compiled, only activated during a test or a
diagnostic session — it sits in the source tree without growing or slowing the everyday build.
Second, the same package carrying two mutually exclusive implementations — one default, the
other a variant that only takes effect when a given tag is supplied. What both share is that
the choice can be made **at build time**, without ever touching the source itself; the same
repository, the same branch, the same commit can turn into a different program depending only
on the `-tags` flag.

The tag names come from outside the program: they are defined nowhere within the source itself,
only supplied to the build command through the `-tags` flag. This marks a basic difference from
the build constants of the previous topic — a value written with `const` inside the source
stays the same across every build, while a build tag is determined **outside** the source,
separately on every invocation. The same source tree, with not a single line changed, can be
built from a completely different set of files depending on the tag set supplied.

## Four Scenarios, One Logic

The measurement below sets up a small six-file package — one untagged, five each marked with a
different tag expression — and reads the number of files entering the build across four
different tag sets. The package is not written to a real directory but to a temporary module
opened with `os.MkdirTemp`; the path is never printed anywhere in the rest of this lesson.

```go
// helpers.go — the helpers this lesson shares: setting up a temporary package and calling the tool
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
)

// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
	fmt.Printf("%-38s %-7s %s\n", info, speaker, evidence)
}

// setupModule writes the given files into a new temporary module; the path is never printed.
func setupModule(files map[string]string) string {
	dir, err := os.MkdirTemp("", "tag")
	if err != nil {
		return ""
	}
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module tagtrial\n\ngo 1.24\n"), 0o644)
	for name, content := range files {
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

// runTool runs the language's own command, returning whether the exit succeeded; output is never printed.
func runTool(dir string, arg ...string) (string, bool) {
	cmd := exec.Command("go", arg...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	output, err := cmd.CombinedOutput()
	return string(output), err == nil
}

// fileCount counts, without calling any tool, the total number of .go files in the directory.
func fileCount(dir string) int {
	entries, _ := os.ReadDir(dir)
	n := 0
	for _, e := range entries {
		if strings.HasSuffix(e.Name(), ".go") {
			n++
		}
	}
	return n
}

// goFileCount reads from the tool the number of files entering the build with the given tag set.
func goFileCount(dir, tags string) int {
	arg := []string{"list", "-f", "{{len .GoFiles}}", "."}
	if tags != "" {
		arg = []string{"list", "-tags", tags, "-f", "{{len .GoFiles}}", "."}
	}
	output, _ := runTool(dir, arg...)
	n, _ := strconv.Atoi(strings.TrimSpace(output))
	return n
}

// fieldCount reads the length of any listing field ("go list" output such as GoFiles,
// TestGoFiles); which field to ask for is given by the template parameter.
func fieldCount(dir, tmpl string) int {
	output, _ := runTool(dir, "list", "-f", tmpl, ".")
	n, _ := strconv.Atoi(strings.TrimSpace(output))
	return n
}
```

```go
// measurement.go — which files enter the build, read through the tool across four tag scenarios
package main

import "fmt"

func main() {
	files := map[string]string{
		"core.go":      "package main\n\nfunc coreFunction() int { return 1 }\n",
		"expansion.go": "//go:build expansion\n\npackage main\n\nfunc expansionFunction() int { return 2 }\n",
		"trial.go":     "//go:build trial\n\npackage main\n\nimport \"fmt\"\n\nfunc trialFunction() int {\n\tfmt.Printf(\"%d\\n\", \"error\")\n\treturn 3\n}\n",
		"both.go":      "//go:build expansion && trial\n\npackage main\n\nfunc bothFunction() int { return 4 }\n",
		"either.go":    "//go:build expansion || trial\n\npackage main\n\nfunc eitherFunction() int { return 5 }\n",
		"not_trial.go": "//go:build !trial\n\npackage main\n\nfunc notTrialFunction() int { return 6 }\n",
	}
	dir := setupModule(files)
	defer func() { _ = dir }()

	fmt.Println("-- which files enter the build: four tag scenarios --")
	show("unlabeled file count", "tool", fmt.Sprint(goFileCount(dir, "")))
	show("file count with expansion tag", "tool", fmt.Sprint(goFileCount(dir, "expansion")))
	show("file count with trial tag", "tool", fmt.Sprint(goFileCount(dir, "trial")))
	show("file count with both tags at once", "tool", fmt.Sprint(goFileCount(dir, "expansion,trial")))

	fmt.Println()
	fmt.Println("-- limiting measurement: a left-out file is not deleted, not tested --")
	show(".go file count on disk", "tool", fmt.Sprint(fileCount(dir)))
	show("count entering unlabeled build", "tool", fmt.Sprint(goFileCount(dir, "")))

	_, unlabeledClean := runTool(dir, "vet", "./...")
	_, trialClean := runTool(dir, "vet", "-tags", "trial", "./...")
	show("unlabeled analysis result", "tool", fmt.Sprint("clean=", unlabeledClean))
	show("analysis result with trial tag", "tool", fmt.Sprint("clean=", trialClean))
}
```

```
-- which files enter the build: four tag scenarios --
unlabeled file count                   tool    2
file count with expansion tag          tool    4
file count with trial tag              tool    3
file count with both tags at once      tool    5

-- limiting measurement: a left-out file is not deleted, not tested --
.go file count on disk                 tool    6
count entering unlabeled build         tool    2
unlabeled analysis result              tool    clean=true
analysis result with trial tag         tool    clean=false
```

**BD1.** None of these ten lines run a program; the bodies of functions like `trialFunction`
and `bothFunction` were never executed. The only thing measured is the file list itself — which
is why the whole table is written under the **tool** class, and the course's `run` column stays
empty for the entire lesson.

The six files' tags sample all three operators: `core.go` carries no tag at all, `expansion.go`
and `trial.go` carry a single name each, `both.go` carries an `&&`, `either.go` an `||`, and
`not_trial.go` a `trial` with a leading negation mark. In the unlabeled call, only the untagged
file and the two files satisfying a negated condition remain — since `trial` is not active,
`not_trial.go` is included too. When called with the `expansion` tag, both its own tagged file
and `either.go`, which satisfies the `||` condition, are added to the table, but `both.go` still
stays out because the `&&` condition needs a second tag it does not have. **BD2.** Only when
both tags are supplied at once does `both.go` enter the table, and at the same moment
`not_trial.go` drops out — because `trial` is now active and the negated condition breaks. The
four numbers (2, 4, 3, 5) are therefore the result of four separate logical evaluations, not
four separate runs.

## Naming Is Also an Implicit Tag: `_test.go`

A build tag is not the only mechanism. The `_test.go` suffix appended to a file name answers
the same question without a single `//go:build` directive written: does this file enter the
normal build? The answer is no — but that does not mean the file is never compiled at all; it
is only compiled in a separate list, under `TestGoFiles`. The measurement below sets up a
two-file package — one untagged, one carrying the `_test.go` suffix — and reads two separate
fields.

```go
// naming.go — the _test.go suffix removes a file from the normal build with no directive written
package main

import "fmt"

func main() {
	files := map[string]string{
		"core.go":      "package main\n\nfunc coreFunction() int { return 1 }\n",
		"core_test.go": "package main\n\nimport \"testing\"\n\nfunc TestCoreFunction(t *testing.T) {\n\tif coreFunction() != 1 {\n\t\tt.Fatal(\"unexpected\")\n\t}\n}\n",
	}
	dir := setupModule(files)
	defer func() { _ = dir }()

	fmt.Println("-- naming is also an implicit tag: _test.go --")
	show(".go file count on disk", "tool", fmt.Sprint(fileCount(dir)))
	show("file count entering normal build", "tool", fmt.Sprint(fieldCount(dir, "{{len .GoFiles}}")))
	show("file count compiled for test", "tool", fmt.Sprint(fieldCount(dir, "{{len .TestGoFiles}}")))
}
```

```
-- naming is also an implicit tag: _test.go --
.go file count on disk                 tool    2
file count entering normal build       tool    1
file count compiled for test           tool    1
```

**BD3.** Two files sit on disk, but neither mixes into the other's list: the normal build sees
only `core.go`, the test build only `core_test.go`. This split rests not on a directive but on a
plain name — seeing `_test.go` at the end of a file name is a rule built into the tool itself,
never written anywhere in the source. The `//go:build` directive adds an optional second layer
on top of this rule; the naming convention is always there, working automatically for every
package. Both mechanisms answer the same question — which file enters which list — but one
rests on a directive explicitly written in the source, the other only on a name.

## Limiting Measurement: A File Is Not Deleted, Not Tested

The second half of the table above asks a completely different question: what happens to a file
left out by its tag? The answer has two parts, and both are measured. First: the number of
`.go` files in the directory is always six — no tag combination deletes a file from disk, only
removes it from that run's build list. The `fileCount` function counts this directly with
`os.ReadDir`, without calling any `go` command at all; **BD4.** against six files on disk, only
two enter the unlabeled build — the other four are neither lost nor broken, only sitting outside
that run's field of view.

Second, and sharper: `trial.go` had a flaw deliberately written into it — a string is given to
a `%d` formatter, and this kind of mismatch is caught not by the compiler but by the analyzer
introduced in the Go Command Family lesson. Run without a tag, this tool says `clean=true`,
because the flawed file is not in the build list at all — the tool does not even read it.
**BD5.** The same tool, called with the `trial` tag, flips to `clean=false`; the flaw did not
change, only the file's visibility changed. This is the full proof of the claim "a file left out
by its tag is not tested": the flaw waits in a corner no tool can reach, until someone activates
the tag and runs it.

This boundary works both ways in practice. On one hand, when experimental or platform-specific
code is written behind a tag, that code's quality sits in an area the main build and the main
analysis never check — the tag only decides in which environment it gets built, it does not
guarantee that code's correctness. On the other hand, this is exactly the reason tags exist: a
source tree can carry multiple variants that are never meant to run at the same time — a variant
outside the main flow can sit on disk without ever affecting the main flow. The tag's decision is
made before the build, with a single text comparison; this is why it is both very cheap and very
precise, but its consequences are visible only to whoever supplies the tag.

The balance between these two sides turns into a shared responsibility. Whoever writes a file
behind a tag knows that the file has vanished from the normal flow's view; regularly compiling
and analyzing it together with its tag therefore becomes the **only** way to keep that file's
quality up. The tag itself issues no reminder at all — the flaw inside `trial.go` can go unnoticed
for weeks, even as the source tree grows, unless someone explicitly supplies `-tags trial`. This
lesson shows this risk through a concrete number: in a small six-file example the difference was
a single line, but in a real repository, as the number of tagged files grows, so does the amount
of source the untagged build and the untagged analysis **do not see** — and this amount itself
can only be measured the same way, by asking the tool alone and comparing file lists.

## Summary

- A build tag is a directive that determines, from outside the source, through the `-tags` flag,
  whether a file enters the build; the decision is given by the **tool** without running any
  program.
- Tag expressions are built with `&&`, `||` and a negation mark; the same six-file source can
  produce a file list ranging from two to five depending on the tag set supplied.
- A file left out by its tag is not deleted from disk — it only stays outside that run's build
  list; the file count in the directory always stays fixed.
- A flaw in a left-out file is invisible because no tool reads that file; the same tool finds
  the same flaw immediately once the tag is activated.
- This lesson never used the `run` class — every result was obtained without calling a single
  function, only by reading the file list and the analysis result.

## Next Step

The question measured in this lesson was limited to "which file" — once the files were settled,
all of them compiled to a single target, the machine actually running the build. The next lesson
asks one step further along the same question: can the same source, with nothing changed, be
built for a target completely different from the machine? The answer again comes without waiting
for a run, read from a single command's pass-or-fail result — but this time with a limit attached:
being compiled does not mean it will run on the target.
