Lesson 11 / 16
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.
Published Updated
In this lesson
- 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
Contents
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.
// 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
}
// 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.
// 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
-tagsflag, 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
runclass — 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.