Why would go build -ldflags -X silently leave a Go string variable empty at run time?
answer
- no error either way, that is the clue
- the linker is matching a symbol name
- package path, not package name
- a const has no slot to write into
- a start-up initialiser would win anyway
basics
~20 sThe Go linker matches -X against a package-level string variable by its full path-qualified name and does nothing at all when nothing matches. A typo, a missing module path prefix, a const, or a non-constant initialiser each leaves the value untouched, with no error.
solid answer
~40 s`-X` is a best-effort symbol rewrite, not a checked instruction: if the linker finds no matching symbol it links the binary anyway and says nothing, so the variable keeps its source value. Four things break the match. The name must be package-qualified by **import path** — `example.com/daemon/internal/build.Version`, not `build.Version`. The target must be a `var`, not a `const`, because a constant is folded into its use sites and has no storage. It must be a `string`. And its initialiser must be a constant expression: `var t = time.Now().Format(time.RFC3339)` is initialised at start-up, which would overwrite anything the linker wrote, so it is not a valid target. Diagnose it by checking the declaration against the exact name you passed; `go tool nm` will list the symbols actually present in the built binary.
code
go · 10 linespackage build
// Settable: package-level string with a constant initialiser.
var Version = "dev"
// Not settable: a constant has no storage to rewrite.
const Name = "daemon"
// Not settable: a non-constant initialiser runs at start-up.
var BuiltAt = time.Now().Format(time.RFC3339)go deeper
Know that a wrong -X target is not a build error — the binary just reports its source default. Check the spelling and the package qualifier before concluding that the pipeline is broken.
Explain the four conditions a target must meet — package-level, string, var not const, constant initialiser — and derive each one from the fact that the linker is writing into a data slot before the program starts.
Turn the silence into a signal: assert the stamped value in a smoke test or fail the release when the artefact still reports its development default, so an un-stamped binary never ships.
Decide where that verification lives. A pipeline that is able to ship an un-stamped binary eventually will, so make the check a gate teams cannot quietly skip rather than a habit each service reinvents.
## The failure looks like nothing You add `-ldflags "-X main.version=1.4.0"` to the release build. The build succeeds. The image ships. The daemon starts and logs `version=dev`. Nothing in the pipeline output hints at a problem, because from the linker's point of view nothing went wrong: it was asked to set a symbol it could not find, and it carried on. That silence is the single most important fact about this flag. `-X` never fails your build. Every diagnosis therefore starts from the assumption that the *name did not match*, and works backwards. ## Rule 1 — the name is an import path, not a package name In Go source you write `build.Version`, because `build` is the package's name in that file's scope. The linker has no file scope; it matches the fully qualified symbol. For `package main` the import path is literally `main`, so `main.version` is correct. For everything else you need the whole path as it appears in the import statement, which for a module-based project starts with the module path: `example.com/daemon/internal/build.Version`. A related trap: the variable must be **exported** if a different package is going to read it, and the package must actually be linked into the binary. If nothing imports it, the package is not in the output and the symbol does not exist. ## Rule 2 — a var, not a const `const Version = "dev"` gives you no place to write. Go constants are compile-time values folded into every expression that uses them; there is no runtime storage, so there is no data slot for the linker to overwrite. The declaration must be `var`. ## Rule 3 — string only The flag sets string variables. An `int`, a `bool`, a `[]byte`, a struct field, a map entry: none of these are targets. This restriction exists because the linker is writing a string's data and header into the binary, and there is no general mechanism for arbitrary types. ## Rule 4 — the initialiser must be constant This is the subtle one, and it is where careful people still get caught: var BuiltAt = time.Now().Format(time.RFC3339) A variable whose initialiser is not a constant expression is not set by the linker; it is set by generated initialisation code that runs when the program starts, before `main`. Anything the linker wrote into that slot would be overwritten a microsecond into the process's life, so the flag simply does not apply to such variables. The same applies to `var Version = os.Getenv("VERSION")` or `var Version = defaultVersion()`. The fix is to make the declaration boringly simple — `var BuiltAt = "unknown"` — and let the pipeline supply the formatted timestamp as a string. ## Diagnosing it Work in this order, because it goes from cheapest to most expensive: 1. **Read the declaration and the flag side by side.** Nine times in ten it is a typo, a wrong package qualifier, or a `const` that used to be a `var`. 2. **Check the shell quoting.** `-ldflags` takes one argument. If the quoting collapses, part of your definition becomes a separate word and the flag you think you passed is not the flag that ran. 3. **Look at the built artefact.** `go tool nm` lists the symbols in a binary; the variable you are aiming at should appear under exactly the name you are passing to `-X`. 4. **Check which package the flags applied to.** `-ldflags` accepts an optional package pattern prefix — `-ldflags=pattern=flags` — and in large builds it is possible to scope flags somewhere other than the package you meant. ## Making the silence loud Because the toolchain will not tell you, the pipeline has to. Two cheap habits cover it: - Initialise the variable to a recognisable sentinel such as `"dev"` rather than the empty string, so an un-stamped binary is visibly un-stamped rather than merely blank. - Add a release-gate step that runs the freshly built binary's `--version` (or reads the value it logs at start-up) and fails the pipeline if it still reports the sentinel. It is three lines of shell, and it converts a silent no-op into a build failure at the moment it is cheapest to fix. An un-stamped binary is not a cosmetic problem. It is the artefact that will be running when you most need to know which build was running.
- How do you confirm the exact symbol name your -X should target?Derive it from the declaration: the package's full import path, a dot, the variable identifier — `main.version` for package main, `example.com/daemon/internal/build.Version` otherwise. To check against the artefact rather than the source, `go tool nm` lists the symbols present in a built binary, and your target should appear there under exactly the name you are passing.
- Why can't -X set a variable initialised with time.Now()?Because that initialiser is not a constant expression, so the value is produced by initialisation code that runs before main. Whatever the linker wrote would be overwritten immediately, so such variables are not valid targets at all. Declare it as a plain string with a constant default and have the pipeline pass the formatted timestamp in.
- How would you stop an un-stamped binary from reaching production?Give the variable a recognisable default such as "dev", then add a release-gate step that runs the freshly built binary and fails if it still reports that default. The toolchain will never tell you the flag missed, so the pipeline has to check the artefact it just produced.
saying these in an interview costs you the question
- Expects the build to fail on a misspelled -X target
- Uses the short package name instead of the import path
- Tries to stamp a const instead of a var
- Stamps a variable initialised by a function call
- Blames the CI runner before checking the symbol name