skip to content

How does a `//go:build go1.21` line let one file use a newer language feature while the package still builds on older releases?

level: middleimportance: nice to knowfreq 25%

answer

  1. one comment, two separate effects
  2. release tags are cumulative
  3. it raises the file's language version
  4. the fallback needs the negated tag
  5. same package, same identifiers, exactly one compiles

basics

~20 s

A //go:build go1.21 constraint does two things: it excludes the file from builds by older Go releases, and it raises that one file's language version to 1.21 even when the module's go line is lower. A sibling file constrained with //go:build !go1.21 supplies the fallback.

solid answer

~40 s

Release tags like `go1.21` are set cumulatively by the toolchain, so `//go:build go1.21` selects the file only on Go 1.21 and later, and `//go:build !go1.21` selects its fallback on everything older. On top of that file selection, a `//go:build go1.x` constraint **raises the language version for that file** above the module's go line, which is what lets the guarded file use a feature the module as a whole has not adopted. Both files must live in the same package and declare the same identifiers, so exactly one of them compiles in any build. The payoff is that the module's go line — the floor every consumer inherits — stays where it is while one file gets to use the newer feature.

code

go · 8 lines
go
//go:build go1.21

package mathutil

// max is a builtin from Go 1.21 onwards.
func clampLow(v, lo int) int {
	return max(v, lo)
}

go deeper

for a junior

Recall that a //go:build line at the top of a file decides whether that file is part of the build at all, and that tags naming a Go release are matched by that release and every later one.

for a middle

Explain both effects — file selection and raising that file's language version above the module's go line — and write the negated constraint for the fallback correctly.

for a senior

Judge when the pattern earns its maintenance cost, and make sure the fallback path is actually exercised by running tests on the oldest release you claim to support.

for a principal

Weigh guarded adoption against simply raising the floor: two code paths cost review and CI time forever, while a raised go line costs your consumers one upgrade. Decide which price your library should pay.

## Two mechanisms in one comment line A build constraint at the top of a Go file, `//go:build go1.21`, is doing two separable things. ### Mechanism 1: file selection The toolchain sets a **release tag** for its own version and every earlier one: a Go 1.27 install satisfies `go1.1` through `go1.27`. So: - `//go:build go1.21` — the file is part of the package on Go 1.21 and later, and invisible to anything older. - `//go:build !go1.21` — the file is part of the package only on releases older than 1.21. Because the tags are cumulative, the negated form is the correct way to write "older than 1.21". Writing `//go:build go1.20` for the fallback is the classic mistake: that tag is also satisfied by Go 1.27, so both files would compile and the package would fail with duplicate declarations. ### Mechanism 2: the language version The module's go line sets the language version for the whole module, but a `//go:build go1.x` constraint **raises** it for the single file that carries it. That is the part people miss. In a module declaring `go 1.20`, a file with `//go:build go1.21` is compiled at language version 1.21 and may therefore use the `max` builtin, which Go 1.21 introduced. Without the constraint the same file is rejected with a message telling you the language version it was compiled at and pointing at go.mod. The mechanism only raises; a constraint naming an older release does not lower a file below the module's go line. ### Putting them together The pair of files below lets a package use `max` where it is available and fall back to a hand-written comparison where it is not — while go.mod keeps saying `go 1.20`, so consumers still on Go 1.20 can build the module at all. Rules that make the pattern work: 1. Both files declare **the same package** and the same exported (or unexported) identifiers with identical signatures, so callers compile either way. 2. The constraints must be **mutually exclusive and exhaustive** — `go1.21` and `!go1.21` — or you get either a duplicate declaration or an undefined symbol. 3. `//go:build` must appear before the package clause, followed by a blank line. 4. Keep the guarded surface tiny. The bigger the divergence between the two files, the more likely one of them rots untested. ### When to reach for it, and when not to This is the tool for **optional** adoption: you want a newer feature where it exists without imposing a floor on every consumer of a widely used library. It is the polite alternative to raising the go line for people who did not ask for it. It is the wrong tool when the newer feature is not optional — if the module genuinely cannot work correctly without it, gate the whole module by raising the go line and give consumers one clear version error instead of a subtly different build. It is also a real maintenance cost: every guarded file needs the fallback to be exercised, which means running your tests on the oldest release you claim to support, not just on the newest. ### A related trap Build constraints select files and raise language versions; they cannot conjure standard-library symbols. A file guarded with `//go:build go1.24` may call an API introduced in Go 1.24 safely, because it simply is not compiled on anything older — that is precisely the guard's value. But the same call in an unguarded file compiles for you and fails with an undefined-symbol error for anyone on an older release, which is why the guard has to sit on the file, not merely in your head.

  • Why is `//go:build go1.20` the wrong constraint for the pre-1.21 fallback file?
    Because release tags are cumulative: a Go 1.27 install satisfies `go1.20` as well as `go1.27`. Both files would then be included and the package would fail to compile with duplicate declarations. The fallback must be negated — `//go:build !go1.21` — so exactly one of the pair is ever selected.
  • Does the guarded file's raised language version apply to the rest of the package?
    No. The raise is per file. Other files in the same package keep the module's go line as their language version, so a feature you used behind the guard is still rejected in the file next to it. That containment is what makes the pattern safe to apply narrowly.
  • When would you raise the module's go line instead of guarding a single file?
    When the feature is not optional — when the module cannot behave correctly without it, or when the guarded fallback would be materially different code you cannot keep tested. One clear version error at build time beats two divergent code paths that silently disagree.

saying these in an interview costs you the question

  • Thinks a build constraint only selects files, never the language version
  • Writes go1.20 rather than !go1.21 for the fallback file
  • Believes the constraint raises the language version for the whole package
  • Forgets the fallback must declare the same identifiers
  • Assumes a guard makes a newer stdlib symbol available on older releases