-ldflags -X sets a variable at link time¶
Level: 201 · anyone whose --version says dev in production
One line: go build -ldflags "-X importpath.name=value" writes value into the package-level string variable name while the binary is linked, so one source tree builds binaries that know their version and commit; it reaches only a string that is uninitialised or initialised with a constant — an int is refused, a computed initialiser keeps its own value without a word, and a misspelt name is ignored without a word — and the binary records the flag, so go version -m and debug.ReadBuildInfo can say how it was built.
The program below declares five package-level variables, one for each case, and prints them together with whatever -ldflags the go command recorded in the binary. The script builds it plain, then stamped, then with an int in the flag, then with a name that does not exist, and last stripped with -s -w.
Verified output of ldflags_x_sets_a_variable_at_link_time_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ gofmt -l .
$ go build -o release . && ./release
version="dev" commit="" channel="stable" fromConst="0.0.0" build=0
$ go build -ldflags "-X main.version=1.4.2 -X main.commit=9f3a1c0 -X main.channel=beta -X main.fromConst=2.0" -o release . && ./release
version="1.4.2" commit="9f3a1c0" channel="stable" fromConst="2.0" build=0
recorded in the binary: -ldflags=-X main.version=1.4.2 -X main.commit=9f3a1c0 -X main.channel=beta -X main.fromConst=2.0
$ go build -ldflags "-X main.build=7" -o release .
# example
main.build: cannot set with -X: not a var of type string (type:int)
exit status 1
$ go build -ldflags "-X main.nosuch=1" -o release . && ./release
version="dev" commit="" channel="stable" fromConst="0.0.0" build=0
recorded in the binary: -ldflags=-X main.nosuch=1
exit status 0
$ go build -ldflags "-s -w -X main.version=1.4.2" -o stripped . && ./stripped
version="1.4.2" commit="" channel="stable" fromConst="0.0.0" build=0
recorded in the binary: -ldflags=-s -w -X main.version=1.4.2
stripped is smaller: yes
$ go version -m stripped | grep ldflags
build -ldflags="-s -w -X main.version=1.4.2"
Reading the output¶
dev became 1.4.2 without a change to the source. version had an initialiser, commit had none, fromConst was initialised with a named constant: -X set all three. The linker's documentation ↗ draws the line exactly there — the flag is effective only for a string variable declared uninitialised or initialised with a constant string expression, and does not work if the initialiser calls a function or refers to other variables. -X writes the value into the variable's storage in the binary; an initialiser that runs at program start would overwrite it, so the linker never tries.
channel stayed stable, and nothing said so. strings.ToLower("STABLE") runs when the program starts, so the linker left channel alone — exit status 0, no message. A version variable computed from anything is a version variable -X cannot reach.
An int is refused; a name that does not exist is not. -X main.build=7 stopped the link with cannot set with -X: not a var of type string (type:int) and exit status 1. -X main.nosuch=1 built and ran, exit status 0, and the binary faithfully recorded -ldflags=-X main.nosuch=1 — a flag accepted and applied to nothing. A typo in a build script, main.Version for main.version, ships dev and tells no one; the compiler errors page shows that case beside the refused ones, and the kata below is the check that catches it.
-s -w makes the binary smaller. -s omits the symbol table and debug information, -w the DWARF debugging data (cmd/link ↗). The script prints only that the stripped binary is smaller, because the sizes differ between platforms. A stripped binary still prints stack traces on panic, still runs under pprof and still answers debug.ReadBuildInfo, as the output shows: the runtime keeps its own line tables, and the build information lives in its own section, not in the symbol table. What is gone is what a debugger reads.
The binary remembers how it was built. recorded in the binary: comes from debug.ReadBuildInfo ↗, whose Settings hold -ldflags, -compiler, -buildmode, -trimpath when it was set, GOOS, GOARCH, CGO_ENABLED, and inside a git checkout vcs.revision and vcs.modified (Builds are reproducible with -trimpath). go version -m binary prints the same table from outside, with the value quoted when it has spaces; the script keeps only its ldflags line, because the first line names the Go version.
ldflags_x_sets_a_variable_at_link_time_sh.sh
ldflags_x_sets_a_variable_at_link_time_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# -X sets a package-level string variable at link time, so one source tree
# gives a binary that knows its version. It reaches only a string that is
# uninitialised or initialised with a constant: an int is refused, a computed
# initialiser keeps its own value without a word, and a name that does not
# exist is ignored without a word. The binary records the flag it was built
# with, and -s -w makes it smaller.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
say() { printf '$ %s\n' "$*"; }
go mod init example >/dev/null 2>&1 || exit 1
cat >main.go <<'GO'
package main
import (
"fmt"
"runtime/debug"
"strings"
)
const defaultVersion = "0.0.0"
var (
version = "dev" // -X main.version=... replaces this
commit string // uninitialised: -X reaches it too
channel = strings.ToLower("STABLE") // computed at run time: -X cannot
fromConst = defaultVersion // a constant initialiser: -X can
build = 0 // not a string: the linker refuses
)
func main() {
fmt.Printf("version=%q commit=%q channel=%q fromConst=%q build=%d\n", version, commit, channel, fromConst, build)
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "-ldflags" {
fmt.Printf("recorded in the binary: %s=%s\n", s.Key, s.Value)
}
}
}
}
GO
say 'gofmt -l .'
gofmt -l .
say 'go build -o release . && ./release'
go build -o release . && ./release
say 'go build -ldflags "-X main.version=1.4.2 -X main.commit=9f3a1c0 -X main.channel=beta -X main.fromConst=2.0" -o release . && ./release'
go build -ldflags "-X main.version=1.4.2 -X main.commit=9f3a1c0 -X main.channel=beta -X main.fromConst=2.0" -o release . && ./release
say 'go build -ldflags "-X main.build=7" -o release .'
go build -ldflags "-X main.build=7" -o release . 2>&1
echo "exit status $?"
say 'go build -ldflags "-X main.nosuch=1" -o release . && ./release'
go build -ldflags "-X main.nosuch=1" -o release . 2>&1 && ./release
echo "exit status $?"
say 'go build -ldflags "-s -w -X main.version=1.4.2" -o stripped . && ./stripped'
go build -ldflags "-s -w -X main.version=1.4.2" -o stripped . && ./stripped
if [ "$(wc -c <stripped)" -lt "$(wc -c <release)" ]; then
echo "stripped is smaller: yes"
else
echo "stripped is smaller: no"
fi
say 'go version -m stripped | grep ldflags'
go version -m stripped | grep ldflags
What to do¶
- Declare
var version = "dev"at package level, uninitialised or with a constant, inmainor in a small package of its own — then the flag names it by full import path,-X example/internal/buildinfo.Version=1.4.2. - Check at start-up, or in a test, that the value arrived. The linker will not tell you about a misspelt name; a program that logs
version=devin production is the only message you get. - Take the commit from the build info, not from a flag. In a git checkout
debug.ReadBuildInfoalready carriesvcs.revisionandvcs.modified; use-Xfor what the go command cannot know, such as a release name. - Ship
-s -wwhen size matters, and keep an unstripped build for the debugger. Nothing else changes.
Practice¶
Write a program that reports its own version and whether that version was stamped at link time — by looking for -X main.version= in the -ldflags setting that debug.ReadBuildInfo returns — and prints, from the same settings, its package path, the compiler and the build mode. Built by this library's runner, with no -ldflags, it must say dev and stamped with -X: false; print nothing that names the machine.
Solution
ldflags_x_sets_a_variable_at_link_time_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// Kata: a program that reports its own version and whether that version was
// stamped at link time, by looking for -X main.version among the -ldflags the
// go command recorded in the binary. Built by this library's runner, with no
// -ldflags, it says "dev" and "stamped with -X: false"; the lesson's script
// shows the same check saying true. It prints nothing that names the
// machine: no GOOS, no GOARCH, no Go version.
//
// go build -trimpath ldflags_x_sets_a_variable_at_link_time_kata_go.go && ./ldflags_x_sets_a_variable_at_link_time_kata_go
package main
import (
"fmt"
"runtime/debug"
"strings"
)
var version = "dev"
func main() {
info, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("no build info: the binary was not built by the go command")
return
}
fmt.Println("version:", version)
fmt.Println("package path:", info.Path)
fmt.Println("stamped with -X:", strings.Contains(setting(info, "-ldflags"), "-X main.version="))
fmt.Println("compiler:", setting(info, "-compiler"))
fmt.Println("build mode:", setting(info, "-buildmode"))
}
// setting returns the recorded value of one build setting, or "" when the
// go command recorded none under that key.
func setting(info *debug.BuildInfo, key string) string {
for _, s := range info.Settings {
if s.Key == key {
return s.Value
}
}
return ""
}
Verified output of ldflags_x_sets_a_variable_at_link_time_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.
In other languages¶
- The Rust library's What
cargo run -vshows ↗ lists theCARGO_PKG_VERSIONvariables Cargo hands the compiler, and std macros you have not met ↗ showsenv!reading one into the program — the version comes fromCargo.tomlat compile time, through a macro, and there is no linker flag to set a variable. - C sets a version the same way as Rust and earlier than Go: a preprocessor define,
-DVERSION='"1.4.2"', at compile time (GCC, preprocessor options ↗); it reaches any macro use, and a misspelt name is anundeclared identifierrather than silence.
Sources¶
cmd/link↗ — the flags-X,-sand-w, and the rule about initialisers.debug.ReadBuildInfoandBuildSetting↗, and the Go 1.18 release notes ↗ on the build settings and version-control information a binary records.go version -m, in the go command documentation ↗.- Miki Tebeka, Effective Go Recipes (Pragmatic, 2024), chapter 14, "Building Applications" — Recipe 73, "Injecting Version to Your Executable".
- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 11, "Go Tooling" — the section "Reading the Build Info Inside a Go Binary".