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:

  1. Use platform-specific build environments: Build Linux binaries on Linux, Windows binaries on Windows, etc. CI/CD systems like GitHub Actions support multiple runners.

  2. 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.