Skip to content
shellhacks.dev
Go

Go Build Error Exec Format Error When Cross Compiling

Fix exec format error with Go binaries: GOOS and GOARCH mismatches, building on Mac for Linux, Docker platform flags, and how to check a binary with file.

6 min read
Summarize in AI
Illustration for "Go Build Error Exec Format Error When Cross Compiling": a terminal session, text before and after a transform, a pipeline of commands.

You built a Go tool on a Mac and copied it to a server, and running it gave bash: ./tool: cannot execute binary file: Exec format error. The binary is not corrupted; it is for a different platform than the machine it ran on. The kernel recognises the file format at launch and refuses a mismatch. The fix is rebuilding for the right target, which Go makes easy. The steps are tied to cross compiling, and the examples assume Go 1.23.

Find the mismatch

Shell
file tool
uname -sm
Text
tool: Mach-O 64-bit executable arm64
Linux x86_64

The binary is a macOS (Mach-O) program for ARM64, and the server is Linux on x86-64. Both parts differ: operating system and processor. Common pairs are linux/amd64 (most cloud VMs), linux/arm64 (Graviton, Raspberry Pi 4/5, Apple Silicon containers), darwin/arm64 (M-series Macs) and windows/amd64. For a mismatch in only the architecture, file reports ELF 64-bit with ARM aarch64 against the x86-64 host.

Build for the target

Shell
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o tool-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o tool-linux-arm64 .
file tool-linux-amd64
go version -m tool-linux-amd64 | head -2
Text
tool-linux-amd64: ELF 64-bit LSB executable, x86-64, statically linked
tool-linux-amd64: go1.23.2

Go cross-compiles without extra toolchains as long as the code uses no C. CGO_ENABLED=0 makes sure of that and produces a static binary that runs on any Linux distribution, including minimal ones. If your dependencies need CGO, such as SQLite drivers, cross-compiling requires a C cross-compiler, or build natively in a container for the target. See static binaries and Docker.

Containers and emulation

Shell
docker build --platform linux/amd64 -t tool:amd64 .
docker run --rm --platform linux/amd64 tool:amd64
docker buildx build --platform linux/amd64,linux/arm64 -t example/tool:1.0 --push .

On Apple Silicon, Docker builds arm64 images by default, and pushing one to an amd64 cluster ends with exec format error or exec /app/tool: exec format error in the container logs. Pass --platform or build a multi-architecture image; see multi-platform builds with Buildx. Running a foreign architecture locally works through QEMU emulation, much slower, and only if binfmt support is installed.

Other causes of the same message

A shell script without a shebang line launched through exec instead of a shell fails with the same message. A file that has been truncated or is an HTML error page saved as the binary, from a failed download, also does. Check the first bytes with head -c 4 tool | xxd: an ELF file starts with 7f 45 4c 46. And a Windows CRLF in the shebang gives "no such file or directory", a related but different error covered in line ending problems.

What the kernel checks when you run a file

When a program is executed, the kernel reads the first bytes of the file to decide how to run it. If they are the ELF signature, it loads the program according to the headers, which name the machine type, the word size and the byte order. If they start with #!, it runs the interpreter named on that line. If neither is recognised, execution fails with the error "exec format error", which the shell may display as "cannot execute binary file". The message is accurate and unhelpful: it says the format is not one this kernel can run, without saying why.

For a compiled program, the commonest reason is the machine type. The same source compiled for ARM64 and for x86-64 produces two different files, and the processor cannot run the other's instructions. Operating system matters too: a macOS executable has a different container format from a Linux one, and Windows uses another. Go records the target in the file, which is why file and go version -m reveal the mismatch immediately.

Making the target explicit in your build

Surprises come from building on one machine and deploying to another without naming the target. Put the target in the build script or Makefile, rather than relying on whatever the build machine is. A rule such as build-linux-amd64 that sets both variables documents what is shipped, and a CI job that builds each supported platform catches a broken target before a release. Add a test that runs file on each output and compares it with the expected string, which costs a line and prevents shipping the wrong binary under the right name.

Name the files after the platform. A directory of binaries called tool, tool2 and tool-new invites exactly this error, and one named tool_1.4.0_linux_arm64 does not. Release tools follow the convention, and downloading users can pick the right one by reading the name.

Containers and multi-architecture images

In container workflows the failure moves into the image. The runtime pulls an image for the host's architecture when the registry offers a manifest list, and falls back to whatever is available when it does not, with a warning that the platform differs. A single-architecture image pushed from an M-series laptop then fails on x86-64 nodes. Publish multi-platform images with buildx, and have the CI pipeline build them on every release. Check what a registry holds with docker buildx imagetools inspect, which lists each platform entry.

The base image has to match as well. A FROM line without a platform pulls the build host's architecture, and a copied binary of another architecture lands in the image and fails at start. Using a multi-stage build that compiles inside the image, with --platform=$BUILDPLATFORM on the builder stage and the target platform variables passed to go build, produces the right output for each target from one Dockerfile.

Diagnosing from the server side

If you only have access to the failing machine, collect three facts: uname -m for the processor, file on the binary for what it was built for, and ldd or readelf -l if the file looks right and still fails to start. A missing dynamic loader or library gives a different error, "no such file or directory", which looks wrong because the file plainly exists; it is the loader that is missing. Static builds avoid that class of problem entirely, which is the reason to prefer CGO_ENABLED=0 for tools you hand to others.

Where this does not apply

If the file command says the architecture matches, the error is something else: a missing dynamic linker (Alpine without glibc, for a binary linked against it) shows "not found" instead. For releases, build every target in one go with GoReleaser rather than by hand. See the Go environment variable list, which holds the supported GOOS and GOARCH pairs, and go tool dist list for the exact set in your version.

For a quick look at which platforms your toolchain can target, run go tool dist list | grep linux. The file manual page explains how it recognises formats from the magic bytes at the start of a file.

Also inDebugging
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.