Exec format error (ENOEXEC): architecture or shebang?
Exec format error (ENOEXEC): architecture or shebang?
Exec format error is errno 8, ENOEXEC, and it means what the man page says: the kernel walked its list of executable-format handlers, found none willing to accept the file, and executed nothing. It is not a permissions problem, not a missing shared library, and usually not a corrupt download — in a container it is almost always a CPU-architecture mismatch between the image you built and the machine that runs it, and in a script it is a shebang line the kernel cannot resolve. The expensive part is not the diagnosis, it is that bash deliberately hides it: when execve() returns ENOEXEC, the shell retries the file as a shell script, so a broken artifact can run perfectly in your terminal and fail instantly under Docker, systemd, cron, or any supervisor written in Go or Python.
Everything below was executed on one arm64 Linux host (aarch64, kernel 6.17.0, GNU bash 5.2.37, Python 3.13.5, binutils readelf 2.44) and the transcripts are the stdout of the exact blocks shown, unedited. The fixtures that need a foreign platform are built locally rather than downloaded: the “wrong architecture” binary is /bin/true with one byte of its ELF header rewritten (e_machine, 0xB7 0x00 to 0x3E 0x00), which is the same condition as running an amd64 image on an arm64 node. The container-side line the reader will actually see is cited from runc’s source instead of reproduced, because this host runs the Docker client without a daemon — that is stated again where it matters.
TL;DR — four causes, four checks
Exec format error=ENOEXEC(8). The kernel’sexecve(2)names it exactly: “An executable is not in a recognized format, is for the wrong architecture, or has some other format error that means it cannot be executed.”- The shell lies to you. bash catches
ENOEXECand re-runs text files as shell scripts, so a shebang-less script exits0in your terminal and fails with errno 8 the moment Docker callsexecve()directly. - Check the CPU first.
readelf -h ./app | grep Machine. Two binaries that differ in a single byte can printAArch64andAdvanced Micro Devices X86-64. - Then check the first bytes.
head -c 2 ./app | od -ctells you whether a shebang exists;od -An -tx1 -N4 ./appseparates ELF from Mach-O (cf fa ed fe) and Java class files (ca fe ba be). - A missing interpreter is not this error. A shebang pointing at an interpreter that does not exist fails later, inside the interpreter, with exit
127— measure the errno before you “fix” the architecture. - In Docker the same condition has a different string:
standard_init_linux.go:228: exec user process caused: exec format error, raised by runc when it re-executes your entrypoint. - The fix is a re-build, not a
chmod.docker buildx build --platform linux/amd64 …, or run the image under--platformwith a QEMU handler registered.
The shell hides half of the failure modes
Start by removing bash from the picture. The shell is a convenience that also swallows the evidence: on ENOEXEC from execve() it assumes the file is a shell script and runs it with /bin/sh semantics, and if the file is binary it prints its own sentence instead of the kernel’s. Call execve() from a program instead and you get the raw errno back.
This probe builds the same six fixtures a container might ship and runs each one directly. The last row is the control: a correct script that must succeed, so a run of five failures cannot be mistaken for a broken harness.
#!/usr/bin/env python3
"""What execve() itself returns for each broken executable - no shell in the way.
bash hides these failure modes from each other: it turns ENOEXEC into
"run it as a shell script" for text files, and prints its own message for
binaries. Python calls execve() directly, so the raw errno is visible.
"""
import errno
import os
import shutil
import subprocess
import tempfile
D = tempfile.mkdtemp(prefix="enoexec-")
def put(name, data):
path = os.path.join(D, name)
with open(path, "wb") as handle:
handle.write(data)
os.chmod(path, 0o755)
return path
# aarch64 binary relabelled as x86-64 (e_machine at offset 18 -> 0x3e)
shutil.copy("/bin/true", os.path.join(D, "wrongarch"))
with open(os.path.join(D, "wrongarch"), "r+b") as handle:
handle.seek(18)
handle.write(bytes([0x3E, 0x00]))
os.chmod(os.path.join(D, "wrongarch"), 0o755)
put("noshebang", b"echo ran-as-a-shell-script\n")
put("macho", bytes([0xCF, 0xFA, 0xED, 0xFE, 7, 0, 0, 1, 3, 0, 0, 0, 2, 0, 0, 0]))
put("missinginterp", b"#!/usr/bin/env python9\nprint(1)\n")
put("crlf", b"#!/usr/bin/env python3\r\nprint(1)\n")
put("okscript", b"#!/bin/sh\necho ran-as-a-shell-script\n")
for name in ("wrongarch", "noshebang", "macho", "missinginterp", "crlf", "okscript"):
path = os.path.join(D, name)
try:
res = subprocess.run([path], capture_output=True, text=True, timeout=5)
said = (res.stdout + res.stderr).strip().replace("\r", "^M").splitlines()
print(f"{name:13s} execve OK -> exit {res.returncode} {said[0] if said else ''}")
except OSError as exc:
print(f"{name:13s} OSError {exc.errno} {errno.errorcode.get(exc.errno)}: {exc.strerror}")
The distinction that matters is not where the message comes from but which errno you get. Three fixtures return ENOEXEC; the two that look like the same problem — a shebang that does not resolve — return zero, because execve() succeeded: it started the interpreter (/usr/bin/env), and the interpreter was the thing that failed, with exit code 127.
wrongarch OSError 8 ENOEXEC: Exec format error
noshebang OSError 8 ENOEXEC: Exec format error
macho OSError 8 ENOEXEC: Exec format error
missinginterp execve OK -> exit 127 env: ‘python9’: No such file or directory
crlf execve OK -> exit 127 env: ‘python3\r’: No such file or directory
okscript execve OK -> exit 0 ran-as-a-shell-script
Read that table again: exists and executable are irrelevant to all six results. The only question the kernel asks is whether some handler recognises the bytes.
All of it in one script
The same six fixtures, this time executed by bash — which is what makes the error so confusing in practice. Compare the noshebang row (exit 0, script ran) with the wrongarch row (exit 126, refused by the kernel). Nothing about the file changed the behaviour of the shell; the shell changed the behaviour of the failure.
#!/usr/bin/env bash
# Five ways to get "Exec format error" from execve(), reproduced end to end.
# The sed strips the shell's own "<script>: line N: " prefix so the transcript
# below does not depend on this file's name or line numbering.
set -u
D=$(mktemp -d)
cd "$D" || exit 1
cp /bin/true ./wrongarch
printf '\x3e\x00' | dd of=./wrongarch bs=1 seek=18 conv=notrunc status=none
chmod 755 ./wrongarch
printf 'echo ran-as-a-shell-script\n' > ./noshebang
chmod 755 ./noshebang
printf '\xcf\xfa\xed\xfe\x07\x00\x00\x01\x03\x00\x00\x00\x02\x00\x00\x00' > ./macho
chmod 755 ./macho
printf '#!/usr/bin/env python9\nprint(1)\n' > ./missinginterp
chmod 755 ./missinginterp
printf '#!/usr/bin/env python3\r\nprint(1)\n' > ./crlf
chmod 755 ./crlf
printf '#!/bin/sh\necho ran-as-a-shell-script\n' > ./okscript
chmod 755 ./okscript
for f in wrongarch noshebang macho missinginterp crlf okscript; do
./"$f" > "$f.out" 2>&1
rc=$?
echo "--- ./$f (exit $rc)"
sed -e 's/^[^:]*: line [0-9]*: //' "$f.out" | sed 's/^/ /' | head -3
done
The block strips bash’s own <script>: line N: prefix with sed before printing, so the transcript below stays reproducible no matter what the file is called or how many lines precede the loop. Everything else is the kernel’s and the interpreter’s own wording.
--- ./wrongarch (exit 126)
./wrongarch: cannot execute binary file: Exec format error
--- ./noshebang (exit 0)
ran-as-a-shell-script
--- ./macho (exit 126)
./macho: cannot execute binary file: Exec format error
--- ./missinginterp (exit 127)
env: ‘python9’: No such file or directory
--- ./crlf (exit 127)
env: ‘python3\r’: No such file or directory
env: use -[v]S to pass options in shebang lines
--- ./okscript (exit 0)
ran-as-a-shell-script
Six rows, three outcomes: binary files are refused with 126, the shebang-less text file is silently reinterpreted (0), and the two broken interpreters fail inside env with 127. Docker, ENTRYPOINT, CMD, supervisord and every Go or Rust launcher sit on the left-hand side of that table — they call execve() and they do not retry.
Cause 1: the ELF header names a different CPU
This is the cause behind nearly every exec format error that appears on the first docker run of an image built on a developer laptop. The instruction set is not a build flag you can override at runtime, and it is recorded in the file: e_machine, a two-byte field at offset 18 of the ELF header. Two files that are identical apart from that field are, as far as the kernel is concerned, two different platforms.
#!/usr/bin/env bash
# Read the ELF header instead of guessing: e_machine is the field the kernel
# refuses. Two files, identical except for one byte.
set -u
D=$(mktemp -d)
cd "$D" || exit 1
cp /bin/true ./good
cp /bin/true ./wrongarch
printf '\x3e\x00' | dd of=./wrongarch bs=1 seek=18 conv=notrunc status=none
chmod 755 ./good ./wrongarch
echo "uname -m -> $(uname -m)"
echo "--- readelf -h ./good"
readelf -h ./good | grep -E 'Class:|Type:|Machine:'
echo "--- readelf -h ./wrongarch"
readelf -h ./wrongarch | grep -E 'Class:|Type:|Machine:'
echo "--- cmp -l ./good ./wrongarch (1-based offset, octal values)"
cmp -l ./good ./wrongarch
uname -m -> aarch64
--- readelf -h ./good
Class: ELF64
Type: DYN (Position-Independent Executable file)
Machine: AArch64
--- readelf -h ./wrongarch
Class: ELF64
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
--- cmp -l ./good ./wrongarch (1-based offset, octal values)
19 267 76
cmp -l prints the differing byte in 1-based offsets and octal values: 19 267 76 means byte 19 holds 0o267 (0xB7, the low half of AArch64’s 0x00B7) in the good file and 0o76 (0x3E, EM_X86_64) in the relabelled one. One byte, and the file stops being executable — there is no partial-credit path in the kernel.
The production version of this is an amd64 image on an arm64 host (Apple Silicon, Graviton, Ampere, an arm64 CI runner). The architecture is a property of the image manifest, not of the Dockerfile, so the failure appears at run time even though the build succeeded. Confirm the host with uname -m and the image manifest with docker buildx imagetools inspect — the local image store cannot answer that question, because a --push build leaves nothing local — then rebuild with --platform or run with an emulation handler.
Cause 2: no shebang — it runs in your terminal and dies in the container
A text file with the execute bit set and no #! line is not an executable format. POSIX gives the shell permission to paper over that, and bash does: it reads the file itself and interprets it, which is why ./noshebang printed ran-as-a-shell-script and exited 0. That fallback lives in bash, not in the kernel, and no container runtime implements it.
The failure mode is nasty because it is environment-dependent. A CMD ["./entrypoint"], a Kubernetes command:, a systemd ExecStart= or a Python subprocess.run(["./entrypoint"]) all reach execve() directly, get ENOEXEC, and stop. The same file, in the same directory, with the same permissions, works when a human types it. The fix is one line at the top of the file, and it must be the first line with no leading whitespace or BOM.
#!/bin/sh
set -eu
exec /usr/local/bin/api --port "${PORT:-8080}"
Do not paper over it with sh ./entrypoint in the Dockerfile. That works until someone pushes the image into a supervisor that does not shell out, and it hides the missing declaration from every linter that would have caught it.
Cause 3: the interpreter is missing — or the shebang ends in CRLF
Both of these produce a message containing the words “No such file or directory”, and both are commonly described as an exec format error. Only the first one is close: when the shebang names an interpreter that does not exist on the image, the kernel returns ENOENT for the interpreter, the shell reports bad interpreter or required file not found, and the exit code is 127.
The CRLF case is the one that wastes afternoons, because the file looks correct in every editor and to every reviewer. A shebang line that was saved with Windows line endings hands the kernel the string python3\r, and env then searches for a program whose name ends in a carriage return:
$ ./crlf
env: ‘python3\r’: No such file or directory
env: use -[v]S to pass options in shebang lines
$ printf '#!/usr/bin/env python3\nprint(1)\n' > crlf && ./crlf
1
Two things to notice. The example is not hypothetical — that is the crlf row of the transcript above, where the same file with \n endings executes normally. And the kernel does not reject the file: it launches /usr/bin/env successfully, which is why the errno is 0 and the failure arrives 127 exit codes later, inside the interpreter. A git repository with core.autocrlf=true on one workstation and *.sh text eol=crlf in a stale .gitattributes will keep regenerating this file, so fix the attribute file as well as the script.
# Show the WHOLE shebang line: od -c | head -1 hides a CR past byte 16
head -n 1 ./entrypoint | od -c
# Fail the build on a CR anywhere in that line, then fix it in place
head -n 1 ./entrypoint | grep -q $'\r' && { echo "CRLF shebang"; exit 1; }
sed -i 's/\r$//' entrypoint
printf 'entrypoint text eol=lf\n' >> .gitattributes
Cause 4: neither ELF nor a script
The remaining family is anything that is genuinely not an executable format for this kernel: a macOS Mach-O binary (cf fa ed fe), a Windows PE (4d 5a), a Java class file (ca fe ba be), or a container image corrupted by a truncated pull. All of them land on the same ENOEXEC, and the first four bytes are enough to tell them apart.
The macho row above is a 16-byte fixture with the Mach-O magic, and it is worth keeping in mind for a different reason: chmod +x on a macOS build artifact will make it look runnable in a directory listing and it will still be refused by the kernel. If a build script copies a binary from a mounted host directory into an image, the architecture check you want is on the file that arrives, not on the platform the build ran on.
od -An -tx1 -N4 ./app
# 7f 45 4c 46 ELF (Linux) cf fa ed fe Mach-O (macOS)
# 4d 5a PE (Windows) ca fe ba be Java class
The Docker message: exec user process caused: exec format error
Inside a container the kernel’s errno is wrapped by the runtime, and the string people search for comes from runc. When a container starts, runc re-executes the entrypoint in the container’s namespaces; that call is unix.Exec(), and its failure is reported with the fixed prefix exec user process. In runc v1.0.0 that is line 228 of libcontainer/standard_init_linux.go:
return newSystemErrorWithCause(err, "exec user process")
— opencontainers/runc,libcontainer/standard_init_linux.go:228(v1.0.0)
Logrus prints the file, line and cause around it, which is where the familiar line comes from: standard_init_linux.go:228: exec user process caused: exec format error. Newer runc refactored the wording, so the numeric line number is a fingerprint of the version rather than a fixed address; the exec user process prefix and the wrapped exec format error are the parts to grep for. This host runs the Docker client 26.1.5 without a daemon, so that line is source-cited here rather than reproduced — the underlying condition is the measured ENOEXEC from the transcripts above.
Two fixes, and they are not interchangeable:
- Build for the right platform. With BuildKit,
--platformchooses the target architecture, and one build can cover several of them. This is the correct fix when you control the build. - Run with emulation.
--platformondocker runselects the image variant, but the node still needs abinfmt_mischandler that hands foreign instructions to QEMU. Without it the runtime launches the amd64 binary, the kernel refuses, and you are back at errno 8 — with a success message from the registry.
# The architecture is a property of the image, not of the daemon
docker buildx build --platform linux/amd64,linux/arm64 \
-t registry.example.com/api:1.4.2 --push .
# Read the manifest the registry publishes: after --push nothing is in the local store
docker buildx imagetools inspect registry.example.com/api:1.4.2
# Register QEMU handlers on the node (one-off, needs privileges)
docker run --privileged --rm tonistiigi/binfmt --install amd64,arm64
# Verify: under emulation this prints x86_64, not aarch64
docker run --rm --platform linux/amd64 registry.example.com/api:1.4.2 uname -m
Note what the third command does not do: it does not change the image. A large fraction of “fixed by QEMU” incidents are really the fourth command proving that the emulation path now works, while the production deployment is still resolving the wrong manifest. Pin the digest once the correct architecture is confirmed.
Triage table
Four checks, in the order that costs least. Each one either names the cause or eliminates a whole branch.
| Symptom | Command | Reading |
|---|---|---|
Refused everywhere, including the shell (exit 126) | readelf -h ./app | grep Machine | Compare with uname -m. Different instruction set → rebuild or emulate. |
Runs when you type it, fails under Docker or systemd | head -c 2 ./app | od -c | No #! → the shell was interpreting it; add a shebang. |
No such file or directory although the file is right there | head -n 1 ./app | od -c | \r \n at the end of the shebang → CRLF; the interpreter name is wrong by one byte. |
| The interpreter itself is absent from the image | ls -l "$(head -n 1 ./app | sed 's/^#!//')" | “No such file” for the interpreter → install it in the image, do not strip it later. |
| Bytes are not ELF at all | od -An -tx1 -N4 ./app | cf fa ed fe / 4d 5a → the wrong artifact was copied in. |
Verification is the part that stops the loop repeating. After a rebuild, run the artifact the same way the supervisor does — not through a shell — and check both the exit code and the output. A green build proves nothing about the architecture; the probe at the top of this article is the smallest version of that check, and it is the one to keep in CI.
Related reading
- Text file busy (ETXTBSY): fix writes to a running binary — the other errno you meet when replacing a binary that is already executing, and why
cpfails wheremvsucceeds. - Cannot connect to the Docker daemon: four branches behind one message — how to separate a client problem from a daemon problem before you touch the container.
- [Errno 24] Too many open files: fix the right limit — errno triage in the same style, with the soft and hard limits measured separately.
Last verified: 2026-10-04 on aarch64 Linux 6.17 with GNU bash 5.2.37, Python 3.13.5 and GNU readelf. The execve(2) wording is quoted from the Linux man-pages, and the runc line from the v1.0.0 source tree.