Shrinking Binaries and Build Tags

Go binaries are already small compared to .NET self-contained deployments. But sometimes you want smaller—for Lambda deployments, embedded systems, or just because.

Let’s look at ldflags for stripping binaries and build tags for conditional compilation.

Stripping with ldflags

The simplest optimisation:

go build -ldflags="-s -w" -o myapp
FlagEffectSize Impact
-sStrip symbol table~15-20% smaller
-wStrip DWARF debug info~10-15% smaller

Combined, you might see 25-30% reduction.

Before:

go build -o myapp && ls -lh myapp
# 14M myapp

After:

go build -ldflags="-s -w" -o myapp && ls -lh myapp
# 10M myapp

Embedding Version Information

Use ldflags to set variables at build time:

// main.go
package main

var (
    version   = "dev"
    commit    = "none"
    buildTime = "unknown"
)

func main() {
    fmt.Printf("Version: %s, Commit: %s, Built: %s\n", version, commit, buildTime)
}

Build with values:

go build -ldflags="-s -w \
  -X main.version=1.2.3 \
  -X main.commit=$(git rev-parse --short HEAD) \
  -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -o myapp

Now myapp --version shows real build info, baked in at compile time.

Build Tags: Conditional Compilation

Build tags let you include/exclude files based on conditions. It’s Go’s equivalent to #if DEBUG or conditional compilation symbols.

File-Level Tags

Add a comment at the top of a file:

//go:build linux

package main

// This file only compiles on Linux
func platformSpecificSetup() {
    // Linux-specific code
}

Or exclude a platform:

//go:build !windows

package main

// This file compiles everywhere except Windows

Combining Tags

//go:build linux && amd64

// Only Linux on x86-64
//go:build linux || darwin

// Linux or macOS
//go:build !cgo

// Only when CGO is disabled

Custom Build Tags

Define your own:

//go:build premium

package features

func PremiumFeatures() {
    // Only in premium builds
}

Build with:

go build -tags premium -o myapp-premium

Without the tag, files marked //go:build premium are excluded.

Common Use Cases

Debug vs Production:

//go:build debug

package main

func init() {
    log.SetLevel(log.DebugLevel)
}
//go:build !debug

package main

func init() {
    log.SetLevel(log.InfoLevel)
}

Feature Flags:

//go:build enterprise

package auth

func SSOLogin() { ... }

Platform-Specific Implementations:

mypackage/
├── file.go           // shared code
├── file_linux.go     // Linux-specific
├── file_windows.go   // Windows-specific
└── file_darwin.go    // macOS-specific

Go automatically selects by filename suffix: _linux.go, _windows.go, _darwin.go, _amd64.go, _arm64.go.

UPX Compression (Controversial)

UPX compresses executables:

go build -ldflags="-s -w" -o myapp
upx --best myapp

Before UPX: 10MB After UPX: 3MB

Sounds great, but:

  • Slower startup (decompression)
  • Some virus scanners flag UPX-packed binaries
  • Can interfere with debugging
  • Memory usage increases at runtime

Most Go developers skip UPX. The uncompressed binary is usually small enough.

Trimpath for Reproducibility

Remove local paths from the binary:

go build -trimpath -o myapp

Without -trimpath, stack traces contain your local paths:

/Users/alice/projects/myapp/main.go:42

With -trimpath:

myapp/main.go:42

Cleaner error messages and reproducible builds.

Comparing to .NET

Feature.NETGo
Strip debug infoPublishTrimmed-ldflags="-s -w"
Conditional compilation#if DEBUGBuild tags
Platform-specificRuntime checks or #ifFile suffixes
Build-time variablesMSBuild properties-X main.var=value
IL trimmingPublishTrimmedN/A (no IL)

.NET’s trimming is more complex because it’s trimming IL and dependencies. Go doesn’t have this problem—it compiles to native code and includes only what’s used.

A Complete Production Build

#!/bin/bash

VERSION=$(git describe --tags --always --dirty)
COMMIT=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)

go build \
  -trimpath \
  -ldflags="-s -w \
    -X main.version=$VERSION \
    -X main.commit=$COMMIT \
    -X main.buildTime=$BUILD_TIME" \
  -o myapp \
  ./cmd/server

This produces:

  • Stripped binary (smaller)
  • No local paths (reproducible)
  • Version info embedded (debuggable in production)

Build Tags in Practice

A common pattern for different build configurations:

cmd/server/
├── main.go
├── config_dev.go      //go:build dev
├── config_prod.go     //go:build !dev

config_dev.go:

//go:build dev

package main

var defaultConfig = Config{
    LogLevel: "debug",
    Database: "localhost:5432",
}

config_prod.go:

//go:build !dev

package main

var defaultConfig = Config{
    LogLevel: "info",
    Database: "",  // must be set via env
}

Build:

go build -tags dev -o myapp-dev   # includes config_dev.go
go build -o myapp                  # includes config_prod.go

The Honest Take

Go’s build customisation is simpler than .NET’s MSBuild system.

What Go does well:

  • Simple ldflags for common needs
  • Build tags are straightforward
  • File suffixes for platform code
  • Fast rebuilds

What .NET does better:

  • More sophisticated trimming
  • Rich MSBuild conditionals
  • Better IDE support for conditional code
  • Source generators for build-time code

The verdict: For most cases, -ldflags="-s -w" and maybe a build tag or two is all you need. Go’s simplicity means less configuration and fewer surprises.

If you’re coming from complex MSBuild configurations, you might miss the flexibility. But you probably won’t miss the complexity.


Next up: Dockerfiles for Go—multi-stage builds, scratch images, and why Go containers are so small.