The universal translator in your pocket
You spent the weekend building a CLI tool that parses log files and sends alerts. It works perfectly on your MacBook. You zip the binary and email it to a teammate who runs Windows. They reply with a screenshot: "This app can't run on your PC." You didn't break the code. You broke the platform.
Go makes fixing this trivial. You can build a Windows executable, a Linux binary, and an ARM version for a Raspberry Pi from your single machine without installing virtual machines or setting up complex toolchains. The Go compiler includes backends for almost every major operating system and CPU architecture. You just tell it which one you want.
How cross-compilation works
Go compiles to machine code, not bytecode. Machine code is specific to the CPU and operating system. An x86 processor cannot execute ARM instructions. A Windows loader cannot parse a Linux ELF file. Cross-compilation means the Go toolchain generates the right machine code for a target you aren't currently running.
Think of the Go compiler as a master chef who can cook for any diet. You give the chef the ingredients (your source code). The chef reads the card that says "Vegan" or "Gluten-Free" (the target OS and architecture) and prepares the meal accordingly. The ingredients don't change. The preparation adapts.
The compiler controls this via two environment variables: GOOS and GOARCH. GOOS specifies the target operating system. GOARCH specifies the target CPU architecture. When you run go build, the toolchain checks these variables. If they are set, the compiler generates object code for that target. The linker produces the final executable format. Windows gets a .exe file in PE format. Linux gets an ELF binary. macOS gets a Mach-O binary. The source code stays identical.
Cross-compilation is built into the compiler. You don't need extra packages. You don't need a separate toolchain for most targets. Go was designed for this from day one.
Minimal example
Start with a simple program. The code doesn't care about the OS.
// main.go
package main
import "fmt"
// Main prints a greeting.
// The logic is platform-agnostic.
// Go handles the translation to machine code.
func main() {
fmt.Println("Hello from cross-compile")
}
Build a Windows binary on macOS or Linux. Set GOOS to windows and GOARCH to amd64.
# GOOS tells the compiler the target operating system.
# GOARCH tells the compiler the target CPU architecture.
# The -o flag names the output file.
# The compiler generates a Windows PE executable.
GOOS=windows GOARCH=amd64 go build -o hello.exe
Run file hello.exe to verify the output. The command shows the binary format.
hello.exe: PE32+ executable (console) x86-64, for MS Windows
The binary is ready to run on Windows. You can copy it to a Windows machine and execute it. No installation required.
Build configuration and verification
You can check your current build environment with go env. The command prints all compiler settings. Look for GOOS and GOARCH to see the defaults.
# go env shows the current build configuration.
# GOOS and GOARCH default to your host machine.
# Override them to cross-compile.
go env GOOS GOARCH
To see every supported target, run go tool dist list. The output is a matrix of os/arch pairs. If you see linux/arm64 in the list, you can build for it.
# go tool dist list prints all supported OS/Arch combinations.
# Pipe to grep to filter for a specific OS.
# This helps you verify support before building.
go tool dist list | grep linux
The list includes common targets like linux, darwin, windows, freebsd, and android. It also includes architectures like amd64, arm64, arm, 386, and riscv64. If a combination isn't in the list, the compiler rejects it with unsupported GOOS/GOARCH pair.
Realistic build script
In production, you build for multiple targets. A shell script automates the process. The script loops over OS and architecture combinations. It injects version information and strips debug data.
#!/bin/bash
# build.sh
# This script builds binaries for multiple platforms.
# It uses ldflags for versioning and trimpath for reproducibility.
set -euo pipefail
# Define the binary name and version.
# These variables are injected into the binary at link time.
BINARY_NAME="myapp"
VERSION="1.0.0"
# Build for Linux and Windows on AMD64 and ARM64.
# The loop generates a binary for each combination.
for GOOS in linux windows; do
for GOARCH in amd64 arm64; do
echo "Building $GOOS/$GOARCH..."
# Set the output filename based on OS and arch.
# Windows binaries need the .exe extension.
OUTPUT="${BINARY_NAME}_${GOOS}_${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
# GOOS and GOARCH are set inline for this command.
# -ldflags injects version info and strips symbols.
# -s removes the symbol table.
# -w removes DWARF debugging info.
# -trimpath removes local file paths from the binary.
# -o sets the output filename.
GOOS=$GOOS GOARCH=$GOARCH go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-trimpath \
-o "$OUTPUT" \
./cmd/myapp
done
done
The -ldflags flag passes arguments to the linker. -X main.version=${VERSION} sets the value of a string variable in your code. Define var version string in your main package. The linker patches the binary to include the version. This lets you version releases without changing the source code.
The -trimpath flag removes file system paths from the binary. Stack traces and debug info won't leak your home directory or project structure. This makes builds reproducible and keeps secrets safe. Always use -trimpath in CI/CD pipelines.
Go binaries are statically linked by default. The executable includes the standard library and all dependencies. You can copy the binary to a bare-metal server and it runs. You don't need to worry about missing shared libraries on the target machine. This static linking is why cross-compilation works so smoothly. The binary is self-contained.
Pitfalls and errors
Cross-compilation is reliable, but a few traps exist.
CGO dependencies
The biggest trap is CGO. If your code imports C or uses packages that depend on C libraries, the compiler needs a C toolchain for the target. Go can cross-compile Go code easily. C toolchains are fragmented and platform-specific.
Building for Windows on macOS requires the mingw-w64 toolchain. Building for Linux ARM requires an ARM GCC cross-compiler. If you forget, the build fails.
The compiler rejects the build with exec: "gcc": executable file not found in $PATH or runtime/cgo: C compiler "gcc" failed. The error points to a missing C compiler.
Fix this by disabling CGO if you don't need C. Set CGO_ENABLED=0. The compiler uses pure Go implementations for networking and other features.
# CGO_ENABLED=0 disables C bindings.
# This avoids the need for a cross-compiler.
# Use this when your code has no C dependencies.
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o myapp-linux-arm64
If you need CGO, install the appropriate toolchain. On macOS, brew install mingw-w64 provides Windows cross-compilation. On Linux, package managers offer gcc-arm-linux-gnueabihf for ARM targets.
Hardcoded paths
Cross-compilation changes the binary format. It doesn't fix logic bugs. If your code assumes Unix paths, it breaks on Windows.
Don't hardcode / as a separator. Use path/filepath. The package handles separators correctly for the target OS. Don't assume /tmp exists. Use os.TempDir(). The function returns the correct temporary directory for the OS.
ARM variants
32-bit ARM processors have different instruction sets. The GOARM variable controls this. Valid values are 5, 6, and 7. The default is 6.
Old Raspberry Pi models use ARMv6. Newer models use ARMv8 (64-bit). If you target an old device, set GOARM=5 or GOARM=6.
# GOARM sets the ARM architecture version.
# Use 5 for very old devices.
# Use 6 for Raspberry Pi 1/Zero.
# The default is 6.
GOOS=linux GOARCH=arm GOARM=6 go build -o myapp-arm
The compiler rejects invalid values with invalid GOARM value. Check the documentation for your target device.
WebAssembly surprise
Go can compile to WebAssembly. Set GOOS=js and GOARCH=wasm. The output is a .wasm file that runs in the browser. You can run Go code client-side without a server.
# GOOS=js GOARCH=wasm compiles to WebAssembly.
# The output runs in the browser or Node.js.
# This is useful for client-side logic.
GOOS=js GOARCH=wasm go build -o app.wasm
This works for pure Go code. CGO is not supported for WebAssembly. Use CGO_ENABLED=0.
Decision matrix
Choose the right approach based on your needs.
Use GOOS and GOARCH environment variables when you need to build for a different OS or architecture from your current machine.
Use CGO_ENABLED=0 when your project has no C dependencies and you want a fully static binary without installing cross-compilers.
Use GOARM when you target 32-bit ARM devices and need to match the specific instruction set of the hardware.
Use GOOS=js GOARCH=wasm when you want to run Go code in the browser or a JavaScript runtime.
Use a Docker container with the target OS when you need to test the binary in an environment that matches the production runtime exactly.
Use a CI/CD pipeline matrix when you release to multiple platforms and want automated builds for every supported combination.
Cross-compilation is built-in. Trust the toolchain. Static binaries are the gift that keeps on giving. Trim paths. Protect your secrets.