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 packageWhat go build does:
- Reads source files (*.go)
- Compiles to object files (*.o)
- Links object files into executable
- Embeds runtime and standard library
- 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 timeLimitations 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 identifierSupported platforms:
go tool dist list
# => Lists all supported GOOS/GOARCH combinations
# => Output: linux/amd64, windows/amd64, darwin/arm64, etc.
# => 50+ platform combinationsCommon platforms:
| GOOS | GOARCH | Platform |
|---|---|---|
| linux | amd64 | Linux x86-64 (most servers) |
| linux | arm64 | Linux ARM64 (Raspberry Pi 4+, AWS Graviton) |
| darwin | amd64 | macOS Intel |
| darwin | arm64 | macOS Apple Silicon (M1/M2/M3) |
| windows | amd64 | Windows x86-64 |
| freebsd | amd64 | FreeBSD |
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: arm64Build 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:
| Approach | Pros | Cons |
|---|---|---|
| Native compilation | Smaller binaries, CGO works | Requires build machine per platform |
| Cross-compilation | Single build machine, fast | CGO 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 usefulOptimization 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| Approach | Size Reduction | Startup Impact | When to Use |
|---|---|---|---|
| -ldflags only | 40% | None | Always (production default) |
| UPX | 80% | 10-50ms | CLI 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 featuresEmbedded 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 >1MBSize 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 # 2MBCGO 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 staticDisabling 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-containedCGO 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 neededWhen CGO is unavoidable:
Some packages require CGO (cannot disable):
netpackage (on some systems, DNS resolution)os/userpackage (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 binaryTrade-offs:
| CGO Setting | Pros | Cons |
|---|---|---|
| CGO_ENABLED=0 | Static binary, cross-compilation works | No C libraries, some stdlib features fail |
| CGO_ENABLED=1 | Full stdlib, C libraries accessible | Dynamic 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.3Multi-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 workDisable CGO unless required:
CGO_ENABLED=0 go build
# => Static binary
# => Cross-compilation works
# => Simpler deploymentVerify 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 issuesCross-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 appProblem: “C compiler not found” during cross-compilation
# Solution: Disable CGO
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go buildProblem: 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 buildProblem: Missing SSL certificates in Docker
# Add ca-certificates
RUN apk --no-cache add ca-certificatesSummary
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 myappProgressive adoption:
- Start with
go build(default settings) - Add
-ldflags="-s -w"for production - Disable CGO for static binaries
- Cross-compile for multiple platforms
- 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"