Build Compilation

Why Build & Compilation Matters

Go’s compilation system is critical for production because it produces static binaries with no runtime dependencies, enables cross-platform compilation from a single machine, and supports build optimization for size and performance. Understanding build flags and compilation modes prevents deployment issues and optimizes binary characteristics.

Core benefits:

  • Static binaries: No runtime dependencies, single file deployment
  • Cross-compilation: Build for any platform from any platform
  • Fast compilation: Incremental builds and caching speed development
  • Build optimization: Control binary size, debug symbols, and performance

Problem: Without understanding compilation, teams create oversized binaries (100MB+), face platform-specific bugs, and struggle with debugging production issues.

Solution: Master go build fundamentals first, then apply production techniques for cross-compilation, size reduction, and CGO management.

Standard Library: go build

Go’s built-in go build command compiles Go programs without external tools.

Basic compilation:

go build
# => Compiles current package
# => Output: executable with directory name
# => For package main, creates binary
# => For library, validates compilation only

go build -o myapp
# => Compiles with custom output name
# => -o flag specifies output file
# => Output: myapp executable

go build main.go
# => Compiles single file
# => Includes only explicitly listed files
# => Warning: misses files in same package

What go build does:

  1. Reads source files (*.go)
  2. Compiles to object files (*.o)
  3. Links object files into executable
  4. Embeds runtime and standard library
  5. Produces static binary (no external dependencies)

Build output example:

// File: main.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
    // => Output: Hello, World!
}
go build -o hello
# => Compiles to binary named "hello"

./hello
# => Runs compiled binary
# => Output: Hello, World!

file hello
# => Shows binary information
# => Output: ELF 64-bit executable, x86-64, dynamically linked (on Linux)

du -h hello
# => Shows file size
# => Output: ~2MB (includes runtime + stdlib)

Build caching:

go build
# => First build: compiles all dependencies
# => Subsequent builds: uses cache
# => Cache location: $GOCACHE or $HOME/.cache/go-build/

go clean -cache
# => Clears build cache
# => Forces full rebuild next time

Limitations of basic go build:

  • Default build includes debug symbols (large binaries)
  • No cross-compilation (builds for current OS/architecture)
  • No optimization flags exposed

Cross-Compilation: GOOS and GOARCH

Go cross-compiles to any platform without cross-compiler installation.

Environment variables:

GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64
# => GOOS: target operating system
# => GOARCH: target CPU architecture
# => Compiles for Linux x86-64 from any OS

GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe
# => Compiles Windows executable
# => .exe extension for Windows

GOOS=darwin GOARCH=arm64 go build -o myapp-macos-arm64
# => Compiles for macOS Apple Silicon
# => darwin is macOS identifier

Supported platforms:

go tool dist list
# => Lists all supported GOOS/GOARCH combinations
# => Output: linux/amd64, windows/amd64, darwin/arm64, etc.
# => 50+ platform combinations

Common platforms:

GOOSGOARCHPlatform
linuxamd64Linux x86-64 (most servers)
linuxarm64Linux ARM64 (Raspberry Pi 4+, AWS Graviton)
darwinamd64macOS Intel
darwinarm64macOS Apple Silicon (M1/M2/M3)
windowsamd64Windows x86-64
freebsdamd64FreeBSD

Cross-compilation example:

// File: main.go
package main

import (
    "fmt"
    "runtime"
    // => runtime provides platform information
)

func main() {
    fmt.Printf("OS: %s\nArch: %s\n", runtime.GOOS, runtime.GOARCH)
    // => runtime.GOOS is OS at compile time
    // => runtime.GOARCH is architecture at compile time
    // => Build-time constants (not runtime detection)
}
# Build for Linux
GOOS=linux GOARCH=amd64 go build -o app-linux

# Run on Linux
./app-linux
# => Output: OS: linux
#           Arch: amd64

# Build for macOS ARM
GOOS=darwin GOARCH=arm64 go build -o app-macos

# Transfer to macOS M1 and run
./app-macos
# => Output: OS: darwin
#           Arch: arm64

Build matrix (multiple platforms):

#!/bin/bash
# File: build.sh
# => Builds for multiple platforms

platforms=(
    "linux/amd64"
    "linux/arm64"
    "darwin/amd64"
    "darwin/arm64"
    "windows/amd64"
)

for platform in "${platforms[@]}"; do
    # => Splits platform string
    GOOS=${platform%/*}
    GOARCH=${platform#*/}

    output="myapp-${GOOS}-${GOARCH}"
    # => Constructs output filename

    if [ $GOOS = "windows" ]; then
        output="${output}.exe"
        # => Adds .exe for Windows
    fi

    echo "Building for $GOOS/$GOARCH..."
    GOOS=$GOOS GOARCH=$GOARCH go build -o $output
    # => Cross-compiles for target platform

    if [ $? -ne 0 ]; then
        echo "Build failed for $GOOS/$GOARCH"
        exit 1
    fi
done

echo "All builds successful"

Trade-offs:

ApproachProsCons
Native compilationSmaller binaries, CGO worksRequires build machine per platform
Cross-compilationSingle build machine, fastCGO disabled, larger binaries

When to use:

  • Native: CGO dependencies (database drivers, C libraries)
  • Cross-compilation: Pure Go projects, distribution to many platforms

Reducing Binary Size

Production binaries can be optimized from 10MB+ to sub-2MB.

Default binary size:

go build -o app
du -h app
# => Output: ~10MB (debug symbols, symbol table)

Optimization 1: Strip debug symbols (-ldflags):

go build -ldflags="-s -w" -o app
# => -ldflags passes flags to linker
# => -s: strip symbol table
# => -w: strip DWARF debug info
# => Reduces binary size significantly

du -h app
# => Output: ~6MB (40% smaller)

What gets stripped:

# Before stripping
objdump -t app | wc -l
# => Output: 50000+ symbols

# After stripping
objdump -t app-stripped | wc -l
# => Output: 0 symbols
# => Stack traces less useful

Optimization 2: UPX compression:

# Install UPX (Ultimate Packer for eXecutables)
brew install upx     # macOS
apt install upx      # Linux

# Compress binary
go build -ldflags="-s -w" -o app
upx --best --lzma app
# => --best: maximum compression
# => --lzma: LZMA algorithm (slower, smaller)

du -h app
# => Output: ~2MB (80% smaller than original)

UPX trade-offs:

time ./app-original
# => 0.001s startup time

time ./app-upx
# => 0.050s startup time (50x slower)
# => Decompression overhead on startup
# => Memory usage same after startup
ApproachSize ReductionStartup ImpactWhen to Use
-ldflags only40%NoneAlways (production default)
UPX80%10-50msCLI tools, infrequent startup

Optimization 3: Build tags (exclude unused code):

// File: debug.go
//go:build debug
// => Build tag: only included if -tags=debug

package main

import "fmt"

func init() {
    // => init runs before main
    fmt.Println("Debug mode enabled")
}
go build
# => Excludes debug.go (no debug tag)
# => Smaller binary

go build -tags=debug
# => Includes debug.go
# => Larger binary with debug features

Embedded resources optimization:

package main

import _ "embed"

//go:embed large_file.json
var data []byte
// => Embeds file into binary at compile time
// => Increases binary size by file size

func main() {
    // Use data
}
go build
# => Binary size increases by large_file.json size
# => Consider external file if >1MB

Size optimization summary:

# Baseline
go build -o app                           # 10MB

# Production standard
go build -ldflags="-s -w" -o app          # 6MB

# Maximum compression
go build -ldflags="-s -w" -o app && upx --best app  # 2MB

CGO Considerations

CGO enables calling C code but complicates cross-compilation and static linking.

CGO basics:

package main

/*
#include <stdio.h>

void hello() {
    printf("Hello from C!\n");
}
*/
import "C"
// => import "C" enables CGO
// => Comment block above is C code

func main() {
    C.hello()
    // => Calls C function
    // => Output: Hello from C!
}

Building with CGO:

go build -o app
# => CGO enabled by default (CGO_ENABLED=1)
# => Requires C compiler (gcc/clang)

file app
# => Output: ELF 64-bit, dynamically linked
# => Links to system libc (glibc on Linux)
# => Not truly static

Disabling CGO (static binary):

CGO_ENABLED=0 go build -o app
# => Disables CGO
# => Produces static binary
# => No C dependencies

file app
# => Output: ELF 64-bit, statically linked
# => Fully self-contained

CGO and cross-compilation:

# With CGO enabled (default)
GOOS=linux GOARCH=arm64 go build
# => Error: C compiler not configured for target
# => Requires cross-compiler toolchain

# With CGO disabled
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build
# => Success: pure Go cross-compilation
# => No C compiler needed

When CGO is unavoidable:

Some packages require CGO (cannot disable):

  • net package (on some systems, DNS resolution)
  • os/user package (user lookup on Unix)
  • Database drivers (mattn/go-sqlite3, musl-based images)
  • Libraries wrapping C code (ImageMagick, TensorFlow)

CGO with musl (Alpine Linux):

# Dockerfile for CGO with Alpine
FROM golang:1.23-alpine AS builder

# Install musl-dev for static linking
RUN apk add --no-cache gcc musl-dev

WORKDIR /app
COPY . .

# Build statically with musl
ENV CGO_ENABLED=1
RUN go build -ldflags="-linkmode external -extldflags '-static'" -o app

FROM scratch
# => scratch is empty base image
# => No libc dependencies

COPY --from=builder /app/app /app
# => Copies static binary

CMD ["/app"]
# => Runs static binary

Trade-offs:

CGO SettingProsCons
CGO_ENABLED=0Static binary, cross-compilation worksNo C libraries, some stdlib features fail
CGO_ENABLED=1Full stdlib, C libraries accessibleDynamic linking, cross-compilation complex

When to use:

  • CGO_ENABLED=0: Default for pure Go projects
  • CGO_ENABLED=1: When C libraries required (SQLite, image processing)

Production Build Patterns

Makefile for builds:

# File: Makefile

BINARY_NAME=myapp
VERSION=$(shell git describe --tags --always --dirty)
# => VERSION from git tag
# => --dirty adds "-dirty" if uncommitted changes

LDFLAGS=-ldflags "-s -w -X main.Version=$(VERSION)"
# => -s -w: strip symbols
# => -X sets variable at link time
# => main.Version injected with git version

.PHONY: build
build:
 @echo "Building $(BINARY_NAME) version $(VERSION)..."
 CGO_ENABLED=0 go build $(LDFLAGS) -o $(BINARY_NAME)

.PHONY: build-linux
build-linux:
 @echo "Building for Linux..."
 CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-linux-amd64

.PHONY: build-all
build-all: build-linux
 @echo "Building for macOS..."
 CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-darwin-amd64
 CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY_NAME)-darwin-arm64
 @echo "Building for Windows..."
 CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-windows-amd64.exe

.PHONY: compress
compress: build
 @echo "Compressing binary with UPX..."
 upx --best --lzma $(BINARY_NAME)

.PHONY: clean
clean:
 @echo "Cleaning build artifacts..."
 rm -f $(BINARY_NAME) $(BINARY_NAME)-*

Injecting version information:

// File: main.go
package main

import "fmt"

var Version = "dev"
// => Variable set at link time
// => Default: "dev" for local builds

func main() {
    fmt.Printf("Version: %s\n", Version)
    // => Output: Version: v1.2.3-5-gf2c8d11
    // => Injected by -X flag during build
}
make build
# => Builds with version from git tag
# => Version variable replaced at link time

./myapp
# => Output: Version: v1.2.3

Multi-stage Docker builds:

# Stage 1: Build
FROM golang:1.23-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myapp

# Stage 2: Runtime
FROM alpine:latest
# => alpine:latest is 5MB base image

RUN apk --no-cache add ca-certificates
# => Adds SSL certificates for HTTPS
# => Required for external API calls

WORKDIR /root/
COPY --from=builder /app/myapp .
# => Copies only binary from builder stage
# => Final image: 10MB (vs 800MB with full golang image)

CMD ["./myapp"]

Best Practices

Always strip in production:

go build -ldflags="-s -w"
# => Reduces size
# => Removes unnecessary debug info
# => Stack traces still work

Disable CGO unless required:

CGO_ENABLED=0 go build
# => Static binary
# => Cross-compilation works
# => Simpler deployment

Verify static linking:

ldd myapp
# => Output: "not a dynamic executable" (good)
# => Or lists libc dependencies (bad, not static)

file myapp
# => Output: "statically linked" (good)

Version injection:

go build -ldflags="-X main.Version=$(git describe --tags)"
# => Embeds version in binary
# => Useful for debugging production issues

Cross-compilation checklist:

  • Disable CGO: CGO_ENABLED=0
  • Set GOOS/GOARCH
  • Test binary on target platform
  • Verify file size (cross-compiled may be larger)

Common Issues

Problem: Binary too large (20MB+)

# Solution: Strip symbols
go build -ldflags="-s -w" -o app

# Further: Use UPX
upx --best app

Problem: “C compiler not found” during cross-compilation

# Solution: Disable CGO
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build

Problem: Binary crashes with “No such file or directory” on Alpine

# Cause: Dynamic linking to glibc (Alpine uses musl)
# Solution: Build statically
CGO_ENABLED=0 go build

Problem: Missing SSL certificates in Docker

# Add ca-certificates
RUN apk --no-cache add ca-certificates

Summary

Go build fundamentals:

  • go build: Produces static binaries with embedded runtime
  • Cross-compilation: GOOS/GOARCH for any platform
  • Size optimization: -ldflags="-s -w" (40% reduction), UPX (80% reduction)
  • CGO: Disable for static binaries (CGO_ENABLED=0)
  • Version injection: -ldflags="-X main.Version=…"

Production build command:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=$(git describe --tags)" -o myapp

Progressive adoption:

  1. Start with go build (default settings)
  2. Add -ldflags="-s -w" for production
  3. Disable CGO for static binaries
  4. Cross-compile for multiple platforms
  5. Consider UPX for CLI tools only

Build matrix example:

# Linux x86-64 (most common)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w"

# macOS Apple Silicon (developer machines)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w"

# Windows x86-64 (corporate environments)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w"
Last updated