
Using ldflags to Set Version Information for Go Applications
When building Go applications for production, one of the most frustrating moments is trying to figure out which version of your code is actually running on a server. You ssh into a production box, run your binary, and realize you have no idea if it’s the latest version or some random commit from last week. This is where Go’s ldflags feature becomes your best friend. Using ldflags (linker flags), you can inject version information, build timestamps, and other metadata directly into your binary at compile time, making debugging and deployment tracking infinitely easier.
How ldflags Works Under the Hood
The ldflags mechanism works through Go’s linker, which can modify the values of string variables in your code during the build process. When you compile a Go program, the linker has the ability to overwrite the initial values of package-level string variables with values you specify via the -ldflags parameter.
The magic happens with the -X flag, which follows this syntax:
-X 'package_path.variable_name=new_value'
This tells the linker to find the specified variable and replace its value with whatever you provide. The variable must be a package-level string variable that’s not declared as const – otherwise the linker won’t be able to modify it.
Here’s what happens during compilation:
- Go compiler processes your source code and creates object files
- Linker combines object files and resolves dependencies
- If ldflags are present, linker searches for specified variables
- Variable values get replaced with your provided strings
- Final binary is produced with the modified values
Setting Up Version Variables in Your Go Code
First, you need to declare the variables that will hold your version information. The most common approach is creating a dedicated package or adding variables to your main package:
package main
import (
"fmt"
"os"
)
var (
version = "dev"
commit = "none"
date = "unknown"
builtBy = "unknown"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Printf("Version: %s\n", version)
fmt.Printf("Commit: %s\n", commit)
fmt.Printf("Built: %s\n", date)
fmt.Printf("Built by: %s\n", builtBy)
return
}
// Your actual application logic here
fmt.Println("Hello, World!")
}
For larger applications, it’s cleaner to put version info in a separate package:
// version/version.go
package version
var (
Version = "dev"
Commit = "none"
Date = "unknown"
BuiltBy = "unknown"
)
func Info() string {
return fmt.Sprintf("Version: %s, Commit: %s, Date: %s",
Version, Commit, Date)
}
Building with ldflags – Step by Step
Now comes the fun part – actually using ldflags to inject real version information. Here’s how to do it manually first:
go build -ldflags "-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD) -X main.date=$(date +%Y-%m-%d_%H:%M:%S) -X main.builtBy=$(whoami)" -o myapp
For the separate package approach:
go build -ldflags "-X github.com/youruser/yourapp/version.Version=1.0.0 -X github.com/youruser/yourapp/version.Commit=$(git rev-parse HEAD)" -o myapp
The real power comes when you automate this with scripts. Here’s a bash script that gathers all the relevant information:
#!/bin/bash
VERSION=$(git describe --tags --always --dirty)
COMMIT=$(git rev-parse HEAD)
DATE=$(date +%Y-%m-%d_%H:%M:%S)
BUILT_BY=$(whoami)
LDFLAGS="-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE} -X main.builtBy=${BUILT_BY}"
go build -ldflags "${LDFLAGS}" -o myapp
For production builds, you might want to add additional linker flags to reduce binary size and remove debug information:
LDFLAGS="-w -s -X main.version=${VERSION} -X main.commit=${COMMIT}"
go build -ldflags "${LDFLAGS}" -o myapp
The -w flag omits DWARF symbol table, and -s omits the symbol table and debug information, making your binary smaller.
Advanced Integration with CI/CD Pipelines
In real-world scenarios, you’ll want to integrate this with your CI/CD pipeline. Here are examples for different platforms:
GitHub Actions:
name: Build and Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
with:
go-version: 1.21
- name: Build
run: |
VERSION=${GITHUB_REF#refs/tags/}
COMMIT=${GITHUB_SHA}
DATE=$(date +%Y-%m-%d_%H:%M:%S)
LDFLAGS="-w -s -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}"
go build -ldflags "${LDFLAGS}" -o myapp
Makefile approach:
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse HEAD)
DATE := $(shell date +%Y-%m-%d_%H:%M:%S)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
.PHONY: build
build:
go build -ldflags "$(LDFLAGS)" -o bin/myapp
.PHONY: build-prod
build-prod:
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-w -s $(LDFLAGS)" -o bin/myapp
Working with Multiple Architectures and Cross-Compilation
When building for multiple platforms, ldflags work seamlessly with Go’s cross-compilation features:
#!/bin/bash
VERSION=$(git describe --tags --always --dirty)
COMMIT=$(git rev-parse HEAD)
DATE=$(date +%Y-%m-%d_%H:%M:%S)
LDFLAGS="-w -s -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}"
# Build for different platforms
GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/myapp-linux-amd64
GOOS=windows GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/myapp-windows-amd64.exe
GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/myapp-darwin-amd64
GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o bin/myapp-darwin-arm64
Common Pitfalls and Troubleshooting
Even though ldflags seems straightforward, there are several gotchas that can waste hours of debugging time:
Variable Must Be Package-Level String
This won’t work:
const version = "dev" // const variables can't be modified
var version int = 0 // must be string type
func main() {
var version = "dev" // function-level variables don't work
}
Incorrect Package Path
Make sure your package path in ldflags exactly matches your module and package structure. If your go.mod says module github.com/user/app and your version variables are in the main package, use:
-X github.com/user/app.version=1.0.0
Not:
-X main.version=1.0.0 // This only works if you're in the main package
Shell Escaping Issues
When your version strings contain spaces or special characters, proper quoting becomes critical:
# Wrong - will break with spaces in commit messages
LDFLAGS=-X main.version=$(git describe --tags) -X main.commit=$(git log -1 --pretty=format:"%s")
# Right - properly quoted
LDFLAGS="-X main.version=$(git describe --tags) -X 'main.commit=$(git log -1 --pretty=format:"%s")'"
Debugging ldflags
To verify that your ldflags are being applied correctly, you can use the go version -m command on your binary:
go version -m ./myapp
Or check if the variables were set by running strings on the binary:
strings ./myapp | grep -E "(version|commit)"
Performance Impact and Binary Size Considerations
Using ldflags has minimal runtime performance impact since the values are embedded as string constants. However, there are some build-time and binary size considerations:
Aspect | Impact | Mitigation |
---|---|---|
Build Time | Negligible increase (<1%) | None needed |
Binary Size | Few extra bytes per string | Use -w -s flags to strip debug info |
Runtime Performance | Zero impact | N/A |
Memory Usage | Strings loaded with binary | Keep version strings reasonably short |
Here’s a comparison of binary sizes with different ldflags configurations:
# Basic build
go build -o app-basic main.go
# Size: ~6.1MB
# With version info
go build -ldflags "-X main.version=1.0.0" -o app-version main.go
# Size: ~6.1MB (virtually no difference)
# With stripped symbols
go build -ldflags "-w -s -X main.version=1.0.0" -o app-stripped main.go
# Size: ~4.2MB (significant reduction from stripping)
Real-World Examples and Use Cases
Web Application with Health Check Endpoint
package main
import (
"encoding/json"
"net/http"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
type VersionInfo struct {
Version string `json:"version"`
Commit string `json:"commit"`
Date string `json:"date"`
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
info := VersionInfo{
Version: version,
Commit: commit,
Date: date,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
func main() {
http.HandleFunc("/health", healthHandler)
http.ListenAndServe(":8080", nil)
}
CLI Tool with Version Command
package main
import (
"flag"
"fmt"
"os"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
var showVersion = flag.Bool("version", false, "Show version information")
flag.Parse()
if *showVersion {
fmt.Printf("%s version %s\n", os.Args[0], version)
fmt.Printf("Git commit: %s\n", commit)
fmt.Printf("Built on: %s\n", date)
os.Exit(0)
}
// Application logic here
fmt.Println("Running application...")
}
Microservice with Structured Logging
package main
import (
"log/slog"
"os"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("Application starting",
slog.String("version", version),
slog.String("commit", commit),
slog.String("build_date", date),
)
// Application logic
}
Alternative Approaches and When to Use Them
While ldflags is the most common approach, there are alternatives worth considering:
Method | Pros | Cons | Best For |
---|---|---|---|
ldflags | Built into Go, no external deps | Compile-time only | Most use cases |
Embedded files (go:embed) | Can include complex version data | Requires Go 1.16+ | Rich version metadata |
Environment variables | Runtime flexibility | Not embedded in binary | Dynamic environments |
External version file | Easy to update | Extra file to manage | Frequently changing versions |
Using go:embed for version files:
package main
import (
_ "embed"
"encoding/json"
"fmt"
)
//go:embed version.json
var versionData []byte
type Version struct {
Version string `json:"version"`
Commit string `json:"commit"`
Date string `json:"date"`
}
func main() {
var v Version
json.Unmarshal(versionData, &v)
fmt.Printf("Version: %s\n", v.Version)
}
Best Practices and Security Considerations
Here are the proven patterns that work well in production environments:
- Keep version variables in a dedicated package – makes them easier to manage and test
- Always provide default values – your app should work even without ldflags
- Use semantic versioning – git describe –tags gives you proper semver strings
- Include build environment info – knowing which CI job built a binary is invaluable
- Don’t embed secrets – ldflags values are visible in the binary with strings command
- Automate everything – manual version setting leads to mistakes
For security, remember that ldflags values are embedded as plain text in your binary. Never use ldflags to embed:
- API keys or passwords
- Database connection strings
- Private repository URLs
- Internal network information
A production-ready build script might look like this:
#!/bin/bash
set -euo pipefail
# Ensure we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "Error: Not in a git repository"
exit 1
fi
# Get version info
VERSION=$(git describe --tags --always --dirty)
COMMIT=$(git rev-parse HEAD)
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
BUILD_USER=$(whoami)
BUILD_HOST=$(hostname)
# Validate we have a clean version for production
if [[ "${VERSION}" == *-dirty ]]; then
echo "Warning: Building from dirty git state"
fi
LDFLAGS="-w -s"
LDFLAGS="${LDFLAGS} -X main.version=${VERSION}"
LDFLAGS="${LDFLAGS} -X main.commit=${COMMIT}"
LDFLAGS="${LDFLAGS} -X main.date=${DATE}"
LDFLAGS="${LDFLAGS} -X main.builtBy=${BUILD_USER}@${BUILD_HOST}"
echo "Building version ${VERSION}..."
go build -ldflags "${LDFLAGS}" -o bin/myapp
echo "Build complete. Binary info:"
./bin/myapp version
The ldflags approach gives you a powerful, zero-dependency way to embed version information in your Go binaries. It integrates seamlessly with existing build processes and provides the debugging information you need when things go wrong in production. Start with the basics – version, commit, and build date – then expand based on your specific operational needs.
For more detailed information about Go’s linker and build process, check out the official Go documentation at https://golang.org/cmd/link/ and the go build command reference at https://golang.org/cmd/go/#hdr-Compile_packages_and_dependencies.

This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.
This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.