Skip to content

-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, in main or 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=dev in production is the only message you get.
  • Take the commit from the build info, not from a flag. In a git checkout debug.ReadBuildInfo already carries vcs.revision and vcs.modified; use -X for what the go command cannot know, such as a release name.
  • Ship -s -w when 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.

version: dev
package path: command-line-arguments
stamped with -X: false
compiler: gc
build mode: exe

In other languages

  • The Rust library's What cargo run -v shows ↗ lists the CARGO_PKG_VERSION variables Cargo hands the compiler, and std macros you have not met ↗ shows env! reading one into the program — the version comes from Cargo.toml at 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 an undeclared identifier rather than silence.

Sources