Go Cross-Compilation: Building for Multiple Platforms
Go's cross-compilation capabilities are one of its most underrated features. Unlike languages that require separate toolchains, cross-compilers, or virtual machines for each target platform, Go ships...
Key Insights
- Go’s native cross-compilation requires only setting GOOS and GOARCH environment variables—no additional toolchains or complex setup needed for pure Go code
- CGO dependencies break simple cross-compilation; disable with CGO_ENABLED=0 or use platform-specific build environments for C bindings
- Automate multi-platform builds with Makefiles or GoReleaser to generate consistent, versioned binaries for all target platforms in a single command
Introduction to Cross-Compilation in Go
Go’s cross-compilation capabilities are one of its most underrated features. Unlike languages that require separate toolchains, cross-compilers, or virtual machines for each target platform, Go ships with everything you need to build binaries for any supported platform from any other platform. Write your code on a Mac, compile for Linux servers, Windows desktops, and ARM devices—all from the same machine.
This capability is critical for modern software distribution. Your users run different operating systems and architectures. Rather than maintaining separate build environments or asking contributors to build platform-specific binaries, you can generate all release artifacts from a single CI/CD pipeline. This article shows you exactly how to do it.
Understanding GOOS and GOARCH
Go uses two environment variables to determine the target platform:
- GOOS: Target operating system (linux, windows, darwin, freebsd, etc.)
- GOARCH: Target architecture (amd64, arm64, 386, arm, etc.)
When you run go build without setting these variables, Go uses your current platform’s values. Setting them tells the compiler to generate code for a different platform.
To see all supported platform combinations:
go tool dist list
This outputs pairs like linux/amd64, windows/386, darwin/arm64. As of Go 1.21, there are over 40 supported combinations.
The most common targets you’ll build for:
- linux/amd64: Linux servers and most cloud instances
- linux/arm64: ARM-based servers, AWS Graviton instances
- windows/amd64: Modern Windows machines
- darwin/amd64: Intel Macs (pre-2020)
- darwin/arm64: Apple Silicon Macs (M1, M2, M3)
- linux/arm: Raspberry Pi and embedded devices
Know your audience. If you’re building a CLI tool for developers, prioritize linux/amd64, darwin/arm64, and windows/amd64. For IoT applications, add linux/arm and linux/arm64.
Basic Cross-Compilation Commands
Cross-compiling is as simple as prefixing your build command with the target GOOS and GOARCH values.
Building a Windows executable from Linux or macOS:
GOOS=windows GOARCH=amd64 go build -o myapp.exe
Building a Linux binary from Windows (PowerShell):
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o myapp
Or in Windows Command Prompt:
set GOOS=linux
set GOARCH=amd64
go build -o myapp
Building for Raspberry Pi (ARM):
GOOS=linux GOARCH=arm GOARM=7 go build -o myapp-arm
Building for Apple Silicon:
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64
The -o flag specifies the output filename. Use descriptive names that include the platform to avoid confusion: myapp-linux-amd64, myapp-windows-amd64.exe, myapp-darwin-arm64.
Platform-Specific Code with Build Tags
Sometimes you need different implementations for different platforms. File path separators, system calls, and platform-specific features require conditional compilation.
Go provides build tags (also called build constraints) for this. Add a comment at the top of your file:
//go:build linux
// +build linux
package main
import "fmt"
func platformSpecific() {
fmt.Println("Running on Linux")
}
Create separate files for each platform:
main_linux.go:
//go:build linux
// +build linux
package main
import "syscall"
func getSystemInfo() string {
var info syscall.Sysinfo_t
syscall.Sysinfo(&info)
return fmt.Sprintf("Linux uptime: %d seconds", info.Uptime)
}
main_windows.go:
//go:build windows
// +build windows
package main
import "golang.org/x/sys/windows"
func getSystemInfo() string {
// Windows-specific implementation
return "Windows system info"
}
main_darwin.go:
//go:build darwin
// +build darwin
package main
func getSystemInfo() string {
return "macOS system info"
}
The Go compiler automatically includes only the file matching your target GOOS. When you build for Linux, only main_linux.go compiles. This keeps your binary size down and avoids importing unnecessary dependencies.
You can also combine constraints:
//go:build linux && amd64
// +build linux,amd64
Or use negation:
//go:build !windows
// +build !windows
CGO Considerations and Limitations
Here’s where things get complicated. CGO allows Go programs to call C code, but it breaks simple cross-compilation. When your code imports C libraries, you need a C cross-compiler for the target platform—defeating Go’s simplicity.
Check if your dependencies use CGO:
go list -f '{{.CgoFiles}}' ./...
If you see C files listed, you’re using CGO. Common culprits include database drivers (SQLite), image processing libraries, and system-level bindings.
The easiest solution: disable CGO.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o myapp
This produces a statically-linked binary with no external dependencies—perfect for Docker containers and environments where you can’t guarantee C library versions.
Trade-offs of disabling CGO:
- Pro: True static binaries, simple cross-compilation, no libc version issues
- Con: Can’t use packages that require C (some database drivers, certain crypto libraries)
For projects that must use CGO, you have two options:
-
Use platform-specific build environments: Build Linux binaries on Linux, Windows binaries on Windows, etc. CI/CD systems like GitHub Actions support multiple runners.
-
Set up cross-compilation toolchains: Install platform-specific C compilers and configure CC environment variables. This is complex and fragile—avoid unless absolutely necessary.
For most applications, disabling CGO is the right choice. Use pure Go database drivers (pgx for PostgreSQL, go-mysql-driver) and avoid C dependencies.
Automating Multi-Platform Builds
Building manually for each platform gets tedious. Automate it with a Makefile:
VERSION := $(shell git describe --tags --always --dirty)
BINARY_NAME := myapp
PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
BUILD_DIR := build
.PHONY: all clean
all: clean $(PLATFORMS)
$(PLATFORMS):
$(eval GOOS := $(word 1,$(subst /, ,$@)))
$(eval GOARCH := $(word 2,$(subst /, ,$@)))
$(eval OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(if $(filter windows,$(GOOS)),.exe,))
@mkdir -p $(BUILD_DIR)
GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 go build \
-ldflags="-s -w -X main.Version=$(VERSION)" \
-o $(OUTPUT) .
@echo "Built $(OUTPUT)"
clean:
rm -rf $(BUILD_DIR)
Run make to build all platforms:
make
This creates:
build/
├── myapp-linux-amd64
├── myapp-linux-arm64
├── myapp-darwin-amd64
├── myapp-darwin-arm64
└── myapp-windows-amd64.exe
The -ldflags="-s -w" flags strip debug information and reduce binary size. The -X main.Version=$(VERSION) injects the git version into your binary.
For more sophisticated needs, use GoReleaser. It handles building, archiving, checksums, and GitHub releases:
# .goreleaser.yaml
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.Version={{.Version}}
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
Run goreleaser build --snapshot --clean to build all platforms locally, or integrate it into your CI/CD pipeline for automated releases.
Testing and Distribution Best Practices
Cross-compiled binaries should work, but verify them. The best way: test on actual target platforms. Use GitHub Actions with multiple runners:
name: Build and Test
on: [push]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- run: go test ./...
- run: go build -o myapp
Organize release artifacts clearly:
releases/
├── v1.0.0/
│ ├── myapp-linux-amd64.tar.gz
│ ├── myapp-linux-arm64.tar.gz
│ ├── myapp-darwin-amd64.tar.gz
│ ├── myapp-darwin-arm64.tar.gz
│ ├── myapp-windows-amd64.zip
│ └── checksums.txt
Generate checksums for verification:
cd build
shasum -a 256 * > checksums.txt
Include installation instructions for each platform in your README. For CLI tools, consider publishing to package managers: Homebrew for macOS, apt/yum repositories for Linux, Chocolatey for Windows.
Archive binaries appropriately: .tar.gz for Unix-like systems, .zip for Windows. Include a README, LICENSE, and any required configuration files in each archive.
Version your binaries. Embed version information at compile time:
package main
var Version = "dev"
func main() {
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Println(Version)
return
}
// ...
}
Set it during build:
go build -ldflags="-X main.Version=v1.0.0" -o myapp
Cross-compilation in Go is straightforward when you avoid CGO and automate the process. Set up your build pipeline once, and you’ll generate binaries for all major platforms with every release—no separate build servers, no complex toolchains, just simple, reproducible builds.