How to Use ldflags to Inject Version Info at Build Time

Inject build-time values like version numbers into Go binaries using the -ldflags -X command line option.

The version that doesn't exist

You just deployed a new release of your Go service. The dashboard shows a spike in errors. You SSH into the server to check the running binary, but the executable has no version string. You stare at the file, wondering if this is the build from Tuesday or the one you pushed an hour ago. Hardcoding the version in main.go feels like a trap. You change the code, build, deploy, and then realize you forgot to update the string before the build. The binary lies to you.

Go solves this with a linker flag. The -ldflags option passes arguments directly to the linker. The -X flag tells the linker to replace the value of a specific variable with a string you provide on the command line. You define the variable in your code as a normal var. At build time, the linker overwrites the default value with whatever you pass. The code doesn't change. The binary gets the data stamped into it during the final assembly step.

Minimal example

Here's the simplest setup: a variable waiting for a value.

package main

// Version holds the build version injected by ldflags.
// The linker overwrites this variable at build time.
var Version string

func main() {
    // Print the version to stdout.
    // If ldflags is not used, this prints an empty line.
    println(Version)
}

Build the binary with the flag:

go build -ldflags "-X main.Version=1.0.0" -o myapp main.go

Run the binary:

./myapp
# output:
# 1.0.0

The variable Version is empty in the source. The build command injects 1.0.0. The result is a binary that reports the correct version without any code generation or source file manipulation.

How the linker patches the binary

When you run go build, the compiler turns your source into object files. The variable Version exists as a symbol in the object file, initialized to an empty string. The linker takes all object files and combines them into the final executable. When it sees -X main.Version=1.0.0, it locates the symbol main.Version in the binary and writes the bytes for 1.0.0 into that memory location.

The linker modifies the binary data directly. It doesn't create a new variable. It finds the existing storage for Version and replaces the zero value with the string you provided. This happens after compilation, so the compiler never sees the injected value. The code compiles as if the variable is always empty. The linker fills in the blanks at the very end.

This approach is atomic. You don't need to regenerate source files. You don't need to run a pre-build script that edits main.go. The build command carries all the information. The source stays clean. The binary carries the truth.

Goroutines are cheap. Build flags are precise. Keep your source static and inject data at the edge.

Realistic build metadata

Real projects need more than a version number. You usually want the git commit hash and the build timestamp to track exactly what code is running. Group related variables in a block.

package main

import "fmt"

// BuildInfo holds metadata injected at link time.
// The linker overwrites these variables based on ldflags.
var (
    // Version is the semantic version of the release.
    Version string
    // GitCommit is the SHA of the commit used for this build.
    GitCommit string
    // BuildTime is the timestamp when the binary was created.
    BuildTime string
)

// PrintInfo displays the build metadata.
func PrintInfo() {
    // Format the output for readability.
    fmt.Printf("Version: %s\nCommit: %s\nBuilt: %s\n", Version, GitCommit, BuildTime)
}

func main() {
    // Show version info before running the app.
    PrintInfo()
}

Chain multiple -X flags in a single -ldflags string. Quote the entire argument to protect against shell interpretation.

go build -ldflags "-X main.Version=2.1.0 -X main.GitCommit=abc123 -X main.BuildTime=2023-10-27T10:00:00Z" -o myapp main.go

The linker processes each -X assignment in order. It finds each symbol and patches the value. The binary ends up with all three fields populated.

Trust the linker. Pass the flags, get the data.

The package path trap

The linker resolves variables by their fully qualified import path, not just the package name. This is the most common source of bugs. If your module is github.com/acme/server, the variable path is github.com/acme/server.Version, even if the package is named main.

You write -X main.Version=... and it works when you run go build . in the directory. It fails when you run go build github.com/acme/server from the module root. The symbol main.Version doesn't exist in the linker's view. The symbol github.com/acme/server.Version does. The linker skips the assignment silently. The variable keeps its zero value. The compiler produces no error. The linker produces no error. You just get an empty string.

Always use the full import path in your build scripts. You can find the path by running go list on the package.

go list ./cmd/server
# output:
# github.com/acme/server/cmd/server

Use that output in your flag: -X github.com/acme/server/cmd/server.Version=1.0.0. This works regardless of where you run the build command or how the package is named.

The linker never complains about a missing symbol. Check your package path.

Automating with a build script

Hardcoding values in the build command defeats the purpose. A build script pulls the values from git and the system clock. This ensures the binary always matches the source.

# Get the current git commit hash.
GIT_COMMIT := $(shell git rev-parse --short HEAD)
# Get the current date in ISO 8601 format.
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
# Define the ldflags string with all variables.
# Use the full import path for the main package.
LDFLAGS := -X main.Version=1.0.0 -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)

build:
	# Build the binary with injected flags.
	# Pass the LDFLAGS variable to go build.
	go build -ldflags "$(LDFLAGS)" -o myapp main.go

The Makefile captures the git state and timestamp before the build starts. The $(LDFLAGS) variable expands to the full string. The build command injects the data. You get a reproducible binary that reports exactly what it contains.

Run make build instead of go build. The script handles the details.

Testing build variables

Tests run with go test, which also supports -ldflags. You can inject values into tests to verify that your code reads the variables correctly.

go test -ldflags "-X main.Version=test-v1" ./...

The test runner passes the flags to the linker for the test binary. The variables inside the test package get the injected values. You can assert that Version equals test-v1. This lets you test the version reporting logic without building a separate binary.

Don't skip testing build metadata. Inject values in tests to verify the plumbing.

When to use ldflags

Use -ldflags when you need immutable build metadata like version, commit hash, or build time stamped directly into the binary.

Use environment variables when the value changes per deployment environment, such as database URLs or API keys that differ between staging and production.

Use a configuration file when operators need to tweak settings without rebuilding the binary, like log levels or feature flags.

Use code generation when you need to derive complex data structures from templates or protobuf definitions, not just simple string injection.

Use hardcoded constants when the value never changes across builds, such as the name of the application or default timeouts.

Build metadata goes in the binary. Runtime config goes in the environment.

Where to go next