BLOG POSTS
    MangoHost Blog / Using ldflags to Set Version Information for Go Applications
Using ldflags to Set Version Information for Go Applications

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.

Leave a reply

Your email address will not be published. Required fields are marked