- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A pager goes off at 2am. Latency spiked on one service in a
fleet of forty. You SSH into the box, run the binary with
--version, and it prints dev. Now you have no idea which
commit is actually running, whether the rollback landed, or if
this box even got the last deploy. The one piece of metadata
that would answer the question was never stamped into the
build.
Go gives you two ways to fix that, and they solve slightly
different problems. One is -ldflags -X, where you feed values
in at build time. The other is runtime/debug.BuildInfo, where
the toolchain already recorded your git revision for free since
Go 1.18. Most codebases use one and forget the other exists.
The one-liner everyone copies wrong
You've seen this in a Makefile somewhere:
package main
import "fmt"
var version = "dev"
func main() {
fmt.Println("version:", version)
}
Then the build:
go build -ldflags "-X main.version=1.4.2" -o app
Run it and you get version: 1.4.2 instead of dev. The
linker overwrote the variable's initial value at link time.
The part people get wrong is the target name. -X takes
importpath.name. For a variable in package main that's
main.version. For a variable anywhere else, it's the full
import path of the package, not just the package name:
go build -ldflags \
"-X github.com/you/app/internal/build.Version=1.4.2"
Get the path wrong and nothing happens. No error, no warning.
The linker silently ignores an -X flag whose target it can't
resolve, and you're left staring at dev wondering why.
The rules -X actually enforces
-X is not a general variable setter. It has three
constraints, and each one produces a silent no-op when you
break it.
The target must be a package-level var of type string. Not
a const. Not an int. A const is baked in at compile time
before the linker runs, so there's nothing to overwrite:
const version = "dev" // -X can't touch this
var version = "dev" // -X can set this
The value must be a plain string. If your variable is an int
and you try to inject a number, the linker leaves it alone. You
parse everything you inject as a string and convert later if you
need a number.
And the variable has to actually exist and be referenced enough
that the linker keeps it. Dead-code elimination can drop an
unused package-level var, and then your -X has no target. In
practice, printing it or exposing it through an endpoint is
enough to keep it alive.
Wiring in commit and build time
One version string is the start. What you want on that 2am box
is the commit hash and the build timestamp too. You pull those
from git and the shell at build time and pass them as three
separate -X flags.
Put the variables in their own package so the import path is
stable and the values aren't scattered across main:
package build
// Set via -ldflags -X at build time.
var (
Version = "dev"
Commit = "none"
Time = "unknown"
)
Then a Makefile target that gathers the values and builds the
flag string:
VERSION := $(shell git describe --tags --always)
COMMIT := $(shell git rev-parse --short HEAD)
TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
PKG := github.com/you/app/internal/build
LDFLAGS := -X $(PKG).Version=$(VERSION) \
-X $(PKG).Commit=$(COMMIT) \
-X $(PKG).Time=$(TIME)
build:
go build -ldflags "$(LDFLAGS)" -o app .
git describe --tags --always gives you the nearest tag when
there is one and falls back to the short hash when there isn't,
so untagged builds still produce something readable. The -u
on date keeps the timestamp in UTC, which is the only sane
choice for something that gets compared across machines.
Read it back wherever you expose version metadata:
package main
import (
"fmt"
"github.com/you/app/internal/build"
)
func main() {
fmt.Printf("version: %s\n", build.Version)
fmt.Printf("commit: %s\n", build.Commit)
fmt.Printf("built: %s\n", build.Time)
}
The half you already have for free
Here's the part most teams miss. Since Go 1.18, go build
stamps the git revision into every binary automatically. You
don't need -X for the commit at all. The toolchain reads your
VCS state and stores it in the build metadata.
You read it through runtime/debug:
package build
import "runtime/debug"
func VCSInfo() (rev string, dirty bool, ok bool) {
info, available := debug.ReadBuildInfo()
if !available {
return "", false, false
}
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
return rev, dirty, rev != ""
}
The settings you care about are vcs.revision (the full commit
hash), vcs.time (the commit timestamp), and vcs.modified
(the string "true" when you built with uncommitted changes in
the tree). That last one is the honest signal you rarely add by
hand: it tells you the binary was built from a dirty checkout
and does not match any commit exactly.
debug.ReadBuildInfo() also hands you the module version and
the Go toolchain version:
info, _ := debug.ReadBuildInfo()
fmt.Println(info.GoVersion) // e.g. go1.24.0
fmt.Println(info.Main.Version) // module version
info.Main.Version is the interesting one. Build with
go install github.com/you/app@v1.4.2 and it reads v1.4.2.
Build locally with go build and it reads (devel), because a
local checkout has no released version to report.
When to reach for which
The two mechanisms overlap on the commit hash, so pick based on
what the value is.
Use the automatic BuildInfo VCS stamps for the commit,
commit time, and dirty flag. They cost you nothing, they can't
drift out of sync with the actual git state, and vcs.modified
catches builds you'd otherwise mislabel as clean.
Use -X for values git doesn't know: a semantic version you
compute from a tag plus build number, a release channel like
stable or canary, a build-pipeline ID. Those live outside
the repo, so the linker injection is the right tool.
A version package that reads from both ends up small:
func String() string {
rev, dirty, ok := VCSInfo()
if !ok {
rev = Commit // -X fallback
}
suffix := ""
if dirty {
suffix = "-dirty"
}
return fmt.Sprintf("%s (%s%s)",
Version, short(rev), suffix)
}
func short(s string) string {
if len(s) > 7 {
return s[:7]
}
return s
}
Gotchas that waste an afternoon
go run and go test don't record VCS stamps the way
go build and go install do, so vcs.revision comes back
empty in those. Test the real binary, not go run ., when you
verify version output.
Building outside the repository, or with -buildvcs=false,
skips the VCS stamping entirely. CI systems that copy source
into a directory without the .git folder hit this constantly.
If your commit hash is mysteriously empty in CI but fine
locally, check whether .git made it into the build context.
Stripping the binary is fine. The -w and -s linker flags
remove DWARF and the symbol table to shrink the file, and they
do not remove -X values or the BuildInfo block. You can ship
-ldflags "-s -w -X main.version=1.4.2" and still read the
version back at runtime.
And spaces in an injected value need quoting the linker
understands. Wrap the individual assignment, not the whole
flag string:
go build -ldflags \
"-X 'main.buildUser=Jane Doe'" -o app
None of this is exotic. It's four variables, a Makefile target,
and one function that reads BuildInfo. The payoff is that the
next time a binary answers --version, it tells you the tag,
the commit, and whether the tree was dirty, instead of shrugging
back dev.
Build-time injection is one of those Go details that stays
simple right up until you spread it across main, a Makefile,
and three environments. The Complete Guide to Go Programming
digs into the linker, runtime/debug, and how the toolchain
records build metadata. Hexagonal Architecture in Go is the
one to read when you want that version data behind a clean
boundary instead of leaking -ldflags details through your
whole codebase.

Top comments (0)