Argument list too long (E2BIG): fix execve argv limits
Argument list too long (E2BIG): fix execve argv limits
Argument list too long is not a filesystem error and not a shell option: the kernel's execve() refused the argument list before your program ever started. Nothing in the command ran, no partial work was done, and the exit status the shell reports is 126 — found but not executable by exec.
Two integers decide whether a command starts. Every argument and every environment string is copied into the new process's stack, and the kernel counts them together: strings, their NUL terminators, and eight bytes of pointer per string must fit under ARG_MAX. On this host that is 2,097,152 bytes, but a command that carries a fat environment cannot spend most of it, and no single argument may exceed 32 pages. Exceed either number and execve() returns E2BIG (errno 7).
This article is the measurement first and the fixes second. Everything below was executed on arm64 Linux 6.17.0-1019-oracle with bash 5.2.37, CPython 3.13.5, Node v26.5.1, GNU findutils and coreutils; every transcript is the unedited output of the block above it, captured twice and diffed so the numbers repeat on your machine.
TL;DR — the numbers, and the three fixes
E2BIG(7) comes fromexecve(), not from the program. There is no partial run and no output — the child never existed. bash reports exit 126; Python raises[Errno 7] Argument list too long; Node returnserror.code === "E2BIG".- A single argument is capped at 32 pages. Measured: 131,071 bytes is accepted, 131,072 is refused, whatever the rest of the argv looks like (
MAX_ARG_STRLEN= 32 × 4096). - argv and the environment share one budget. Measured: 2,088,960 bytes of arguments fit with an empty environment; the same binary accepted only 122,880 bytes once the environment grew to 1.9 MiB. A long
PATH, a CI matrix, or mounted secrets all shrink your argument budget before your command runs. - The shell builtin hides the bug.
echo *over 40,000 files prints nothing and exits 0;/bin/echo *on the same glob dies with exit 126. Only the second one callsexecve(). - Three fixes, in order of preference:
xargs -0 -nfor existing pipelines,find ... -exec cmd {} +for file walks, and a runner that sizes each batch from the live budget when the environment is not yours (cron, CI,docker exec). - Log the errno, not "no output". A failed
execve()and a command that legitimately prints nothing look identical in a cron mailer.
What E2BIG means at the syscall level
execve() does not start a process and then complain: it validates before the new image exists. The kernel walks the argument vector and the environment vector, sums their bytes plus the pointer array, and compares the total with ARG_MAX. If either the total or any single string is too large, the call returns -E2BIG and the old process is left untouched. That is why nothing is half-done, why no temporary file is left behind, and why the error arrives with no output from the command itself.
The total is not a per-command allowance. argv and environ are charged to the same account, so the ceiling moves with the shell you launch from. A login shell with kubernetes variables, a docker exec session with a dozen injected secrets, and a systemd unit with EnvironmentFile= each hand execve() a different amount of pre-spent budget.
| Where you see it | Exact output | Exit |
|---|---|---|
| bash, external command | /bin/echo: Argument list too long | 126 |
CPython subprocess | OSError: [Errno 7] Argument list too long | exception |
Node spawnSync | E2BIG | spawnSync /bin/echo E2BIG | error.code |
| kernel diagnostic | errno.E2BIG = 7 | — |
The shell's own wording is the one that sends people looking for a filesystem bug. It names the command, never the size, and it can appear on a command that has worked for a year — the trigger is usually one more file in a directory, or one more environment variable in the unit file.
Reproduce it with a real glob
Forty thousand files with 69-byte names are enough to break a glob on this host: the expansion is 2,880,000 bytes, and the budget with a normal shell environment is just over two million. The fixture below is idempotent and creates exactly the names the rest of the article uses.
#!/usr/bin/env bash
# repro.sh - reproduce "Argument list too long" (E2BIG) with a real glob.
# Linux, bash 5.2. Fixture is idempotent: 40000 files with 69-byte names.
set -u
FIX=/tmp/e2bigfixture
mkdir -p "$FIX"
cd "$FIX"
/usr/bin/python3 - <<'PY'
name = "lo" + "o" * 56 + "g-" # 60-byte stem
for i in range(40000): # + 5-digit index + ".txt" = 69 bytes
open(f"{name}{i:05d}.txt", "w").close()
PY
printf 'getconf ARG_MAX : %s bytes\n' "$(getconf ARG_MAX)"
printf 'files in the fixture : %s\n' "$(find . -maxdepth 1 -type f -name 'lo*g-*' | wc -l)"
printf 'bytes this glob needs : %s\n' "$(printf '%s\n' ./lo*g-* | wc -c)"
echo
echo "--- the glob expanded by the shell builtin (no execve) ---"
echo ./lo*g-* >/dev/null && echo "builtin echo: OK"
echo
echo "--- the same glob handed to an external binary (execve) ---"
/bin/echo ./lo*g-* >/dev/null 2>err.txt
printf 'exit=%s\n' "$?"
sed -e 's/^[^:]*: line [0-9]*: //' err.txt
Output, on a clean run:
getconf ARG_MAX : 2097152 bytes
files in the fixture : 40000
bytes this glob needs : 2880000
--- the glob expanded by the shell builtin (no execve) ---
builtin echo: OK
--- the same glob handed to an external binary (execve) ---
exit=126
/bin/echo: Argument list too long
Read the two failure lines the way the kernel sees them. The glob is expanded by the shell in both cases, so roughly 2.88 MB of strings exist in memory either way — but the builtin is executed in place, while /bin/echo must be handed to execve(), and that is the only step with a budget. The block strips bash's own script: line N: prefix with sed so the transcript is identical whether you run the file as repro.sh or paste the body into a terminal.
This is the shape that produces "it worked yesterday". A cleanup job that removes logs by glob, a chmod over a release directory, a git add after a build: all of them are ordinary commands whose argv grows with the data, and none of them warns you before it crosses the line.
Measure your own ceiling instead of trusting ARG_MAX
getconf ARG_MAX answers a narrower question than it appears to. It reports the ceiling for the process that asks, which is min(RLIMIT_STACK / 4, 6 MiB)-derived on Linux, and it says nothing about the two limits that actually bite: the 32-page cap on one string, and the environment eating the same budget. The probe below binary-searches all three instead of quoting a manual.
#!/usr/bin/env python3
"""Discover the real execve() limits instead of trusting ARG_MAX.
Measured on Linux 6.17 / aarch64, RLIMIT_STACK = 8 MiB, page size 4096.
Binary-searches: the largest single argv string, the largest total argv
payload with an empty environment, and the same with a ~1.9 MiB environment.
"""
import errno
import os
import resource
import subprocess
CHUNK = 8192
def fits(args, env):
"""True when execve() accepted the argv; False when it returned E2BIG."""
try:
subprocess.run(args, env=env, check=False, timeout=60,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except OSError as exc:
if exc.errno != errno.E2BIG:
raise
return False
def largest(fits_here):
low, high = 1, 1 << 22
while low < high:
mid = (low + high + 1) // 2
if fits_here(mid):
low = mid
else:
high = mid - 1
return low
page = os.sysconf("SC_PAGE_SIZE")
arg_max = os.sysconf("SC_ARG_MAX")
stack, _ = resource.getrlimit(resource.RLIMIT_STACK)
print(f"page size : {page} bytes")
print(f"getconf ARG_MAX : {arg_max} bytes")
print(f"RLIMIT_STACK : {stack} bytes")
print(f"errno.E2BIG : {errno.E2BIG}")
single = largest(lambda n: fits(["/bin/true", "x" * n], {}))
print(f"largest single arg : {single} bytes")
print(f" 32 * page size : {32 * page} bytes")
argv_only = largest(lambda n: fits(["/bin/true"] + ["y" * CHUNK] * n, {}))
print(f"max argv, empty env : {argv_only * CHUNK} bytes "
f"({argv_only} x {CHUNK})")
big_env = {f"PAD{i:03d}": "e" * 8192 for i in range(240)}
env_bytes = sum(len(k) + len(v) + 1 for k, v in big_env.items())
argv_big_env = largest(lambda n: fits(["/bin/true"] + ["y" * CHUNK] * n, big_env))
print(f"max argv, {env_bytes} B env: {argv_big_env * CHUNK} bytes "
f"({argv_big_env} x {CHUNK})")
try:
subprocess.run(["/bin/true", "x" * (single + 1)], env={})
except OSError as exc:
print(f"python child error : [Errno {exc.errno}] {exc.strerror}")
Output (arm64, 8 MiB stack, 4096-byte pages):
page size : 4096 bytes
getconf ARG_MAX : 2097152 bytes
RLIMIT_STACK : 8388608 bytes
errno.E2BIG : 7
largest single arg : 131071 bytes
32 * page size : 131072 bytes
max argv, empty env : 2088960 bytes (255 x 8192)
max argv, 1967760 B env: 122880 bytes (15 x 8192)
python child error : [Errno 7] Argument list too long
Three measured facts follow from that run:
- One string: 131,071 bytes. The accepted maximum is one byte under 32 pages; the 32nd page is the terminator. Any tool that builds a single argument from user data — a JSON blob on a command line, a base64 blob, a pasted SQL statement — hits this long before the total matters.
- Total with an empty environment: 2,088,960 bytes. That is 255 chunks of 8 KiB against a nominal
ARG_MAXof 2,097,152. The gap is the pointer array and the NUL terminators: budget the strings and eight bytes per argument, or you will size a batch that fails by a few kilobytes. - A 1.9 MiB environment leaves 122,880 bytes. Same binary, same kernel, same user — the budget fell by 94% because the environment had already spent it. If a command fails in CI and passes in your terminal, this is the first number to compare, not the last.
The practical consequence: treat "how many files can I pass" as a host-and-context question, never as a constant. The safe habit is to compute the batch, not to remember a magic count.
Why echo * passes and /bin/echo * fails
Shell builtins do not call execve(). echo, printf, test, cd, ulimit, export and read run inside the shell process, so the argument list never crosses the kernel boundary and the budget never applies. Every external binary is a different story, and the more arguments a command naturally takes, the sooner it fails:
- File-list commands:
rm,cp,mv,chmod,chown,ls,tar,gzip,md5sum. - VCS and container tooling:
git add,docker run,kubectl exec,curlwith a long URL list,ffmpegwith a filter graph built from many inputs. - Interpreters:
python3 script.pywith a huge argument,nodewith a long--requirechain.
Exit status is the fastest triage signal, and it is worth knowing the neighbours. 126 means the shell found the file and could not execute it — that covers E2BIG here, a permission problem, and the architecture and shebang failures that both land on Exec format error (ENOEXEC, architecture or shebang?). 127 means the command was not found at all. 1 means the command ran and chose to fail — which is the only one of the three that gives you real output to read.
Two 126s are easy to confuse in production because both are about an inode that cannot be used the way you asked. If the message is Text file busy, the inode is mapped as a running executable and the fix is unlink-then-replace (ETXTBSY, writes to a running binary). If the message is Argument list too long, there is nothing wrong with the binary at all.
command -V <name> settles builtin versus binary in one word, and it is the check to run before blaming a command the shell never executed.
Fix 1 — xargs and find exec: let the tool size the batch
For pipelines that already have a file list, the two idiomatic answers are xargs and find -exec ... +. Both exist precisely for this limit: they split the list into as many execve() calls as the kernel allows. The measured run below pins the batch size explicitly, which is the part most examples leave out — a fixed -n makes the transcript and the runtime repeatable instead of depending on the environment of the day.
#!/usr/bin/env bash
# batch.sh - the two batching fixes, run against the 40000-file fixture.
set -u
FIX=/tmp/e2bigfixture
mkdir -p "$FIX"
cd "$FIX"
/usr/bin/python3 - <<'PY'
name = "lo" + "o" * 56 + "g-"
for i in range(40000):
open(f"{name}{i:05d}.txt", "w").close()
PY
cat > argcount.sh <<'SH'
#!/bin/sh
# One execve() call per batch; reports how many arguments this one carried.
printf 'batch: %s args\n' "$#"
SH
/usr/bin/python3 - <<'PY' > list.txt
name = "lo" + "o" * 56 + "g-"
for i in range(40000):
print(f"{name}{i:05d}.txt")
PY
printf 'list: %s entries, %s bytes\n' "$(wc -l < list.txt)" "$(wc -c < list.txt)"
echo
echo "--- xargs: one exec, 1000 arguments (deterministic batch size) ---"
xargs -a list.txt -n 1000 sh argcount.sh | sort | uniq -c | sed -e 's/^ *//'
echo
echo "--- find -exec ... + : findutils sizes every exec itself ---"
find . -maxdepth 1 -type f -name 'lo*g-*' -exec /bin/true {} +
printf 'find exit=%s after %s files\n' "$?" \
"$(find . -maxdepth 1 -type f -name 'lo*g-*' | wc -l)"
Output:
list: 40000 entries, 2800000 bytes
--- xargs: one exec, 1000 arguments (deterministic batch size) ---
40 batch: 1000 args
--- find -exec ... + : findutils sizes every exec itself ---
find exit=0 after 40000 files
Forty execs of a thousand arguments each, then a walk where findutils sizes its own batches and returns 0 over all 40,000 files. Three details make these fixes survive contact with real filenames:
- Use
-0withfind -print0. A newline in a filename splits a single path into two arguments, and the batch you sized no longer matches the batch you sent.find . -print0 | xargs -0 -n 1000 cmdis the form to memorise. - Keep
-nor-sexplicit. Unqualifiedxargsconsults the environment to compute its buffer, which is correct but invisible.-n 1000is a contract you can read in the code review. -exec cmd {} +, not\;. The+terminator is what batches;\;forks once per file and will turn a 40,000-file sweep into 40,000 processes.
When the list is not files — a database export, a set of URLs, a generated release matrix — xargs is still the right shape, because it applies the same splitting to stdin that find applies to the filesystem.
Fix 2 — a runner that computes the budget it has
There is a case the two idioms do not cover: a job that must not fail regardless of what the caller's environment looks like. A cron entry, a CI step and docker exec each arrive with a different amount of pre-spent budget, and "divide into batches of 1,000" is a guess on every one of them. The runner below measures ARG_MAX, subtracts the bytes its own environment occupies, reserves eight bytes per pointer and a margin, and only then packs the batch. If a batch is still refused, it halves the batch and retries instead of failing the job.
#!/usr/bin/env python3
"""execlists.py - run one command over a huge file list without E2BIG.
Usage:
execlists.py --list FILE [--dry-run] [--margin BYTES] COMMAND [ARG ...]
execlists.py # no arguments: build a 40000-entry fixture and run
# the plan with an emptied environment, so the
# printed numbers are reproducible on any host.
The list is read as a NUL-delimited (or newline-delimited) file and split into
batches that fit the kernel's real limit: ARG_MAX minus what this process
already spends on its own environment, minus 8 bytes of pointer per string,
minus a margin. One batch is one execve() call, so the child never receives an
oversized argv. If a batch is refused with E2BIG anyway, it is halved and
retried instead of failing the job.
Exit codes: 0 = every batch ran, 1 = a child exited non-zero, 2 = usage error.
POSIX only: it reads os.environb to measure the environment in bytes.
"""
import errno
import os
import subprocess
import sys
POINTER = 8 # one pointer per argv/env string on 64-bit Linux
TERMINATOR = 1 # each string also costs its NUL terminator
MARGIN = 8192 # headroom for argument-count limits and kernel rounding
FIXTURE = "/tmp/e2bigbatchfixture"
ENTRIES = 40000
NAME = "lo" + "o" * 56 + "g-"
def environment_bytes(environ):
"""What the kernel charges this process for the environment it passes on."""
return sum(len(k) + len(v) + TERMINATOR for k, v in environ.items())
def usable_budget(environ, margin):
arg_max = os.sysconf("SC_ARG_MAX")
return arg_max - environment_bytes(environ) - POINTER * (len(environ) + 1) - margin
def read_list(path):
with open(path, "rb") as handle:
data = handle.read()
parts = data.split(b"\0") if b"\0" in data else data.split(b"\n")
return [part for part in parts if part]
def batches(items, head, budget):
"""Yield item groups whose combined argv bytes stay under `budget`."""
current, used = [], head
for item in items:
cost = len(item) + TERMINATOR + POINTER
if current and used + cost > budget:
yield current
current, used = [], head
current.append(item)
used += cost
if current:
yield current
def run(argv, batch, retries=8):
"""Run one batch; halve and retry on E2BIG rather than abort the job."""
while True:
try:
return subprocess.run([*argv, *batch]).returncode
except OSError as exc:
if exc.errno != errno.E2BIG or len(batch) < 2 or retries == 0:
raise
cut = len(batch) // 2
for part in (batch[:cut], batch[cut:]):
code = run(argv, part, retries - 1)
if code != 0:
return code
return 0
def execute(items, command, margin, dry_run):
"""Plan the batches for `items`, print the plan, then run it."""
budget = usable_budget(os.environb, margin)
head = sum(len(a) + TERMINATOR + POINTER for a in command)
plan = list(batches(items, head, budget))
if not plan:
print("execlists: nothing to run", file=sys.stderr)
return 0
sizes = [sum(len(i) + TERMINATOR + POINTER for i in batch) for batch in plan]
print(f"arg_max={os.sysconf('SC_ARG_MAX')} "
f"env_bytes={environment_bytes(os.environb)} margin={margin} "
f"budget={budget}")
print(f"entries={len(items)} batches={len(plan)} "
f"largest_batch={len(max(plan, key=len))} args "
f"largest_batch_bytes={max(sizes)}")
if dry_run:
return 0
for position, batch in enumerate(plan, 1):
code = run(command, batch)
if code != 0:
print(f"execlists: batch {position}/{len(plan)} exited {code}",
file=sys.stderr)
return 1
print(f"ran {len(plan)} batches, all children exited 0")
return 0
def parse(args):
"""Split the argument list into (list_path, margin, dry_run, command)."""
list_path, margin, dry_run, command, index = None, None, False, [], 0
while index < len(args):
token = args[index]
if token == "--list":
if index + 1 >= len(args):
print("execlists: --list needs a path", file=sys.stderr)
return None
list_path, index = args[index + 1], index + 2
elif token == "--margin":
if index + 1 >= len(args):
print("execlists: --margin needs a byte count", file=sys.stderr)
return None
try:
margin = int(args[index + 1])
except ValueError:
print(f"execlists: --margin wants an integer, got "
f"{args[index + 1]!r}", file=sys.stderr)
return None
if margin < 0:
print("execlists: --margin must not be negative", file=sys.stderr)
return None
index += 2
elif token == "--dry-run":
dry_run, index = True, index + 1
else:
command.append(token)
index += 1
return list_path, margin, dry_run, command
def demo():
"""Self-test with an emptied environment, so every printed number repeats."""
os.environ.clear()
os.makedirs(FIXTURE, exist_ok=True)
for index in range(ENTRIES):
open(os.path.join(FIXTURE, f"{NAME}{index:05d}.txt"), "w").close()
items = [os.fsencode(f"{NAME}{index:05d}.txt") for index in range(ENTRIES)]
return execute(items, ["/bin/true"], MARGIN, dry_run=False)
def main(argv):
if len(argv) == 1:
return demo()
parsed = parse(argv[1:])
if parsed is None:
return 2
list_path, margin, dry_run, command = parsed
if list_path is None or not command:
print(__doc__.strip(), file=sys.stderr)
return 2
if not os.path.exists(list_path):
print(f"execlists: no such list: {list_path}", file=sys.stderr)
return 2
items = read_list(list_path)
if not items:
print("execlists: the list is empty", file=sys.stderr)
return 0
return execute(items, command, MARGIN if margin is None else margin, dry_run)
if __name__ == "__main__":
sys.exit(main(sys.argv))
Run with no arguments it builds the 40,000-entry fixture, empties its own environment so the numbers are reproducible on any host, and drives the plan with /bin/true:
arg_max=2097152 env_bytes=0 margin=8192 budget=2088952
entries=40000 batches=2 largest_batch=26781 args largest_batch_bytes=2088918
ran 2 batches, all children exited 0
Two execs instead of 40: with an empty environment the same budget fits 26,781 arguments per call, and the runner says so before it runs anything. That is the whole point of computing rather than configuring — the batch size is an output of the measurement, not an input you maintain by hand.
The halving branch is the part to keep when the environment is hostile. E2BIG is one of the few errors where retrying smaller is strictly correct: nothing ran, nothing is half-written, and a batch that fits is guaranteed to succeed for the same reason the previous call failed. Both exit codes are deliberate: 1 for a child that exited non-zero, 2 for a malformed invocation, so a wrapper can tell "your command failed" from "your job description is wrong".
⚠ Do not raise the limit to fix this. Raising ulimit -s grows the derived ARG_MAX on Linux, but the kernel still caps a single string at 32 pages and the platform still charges the environment to the same account. Bigger batches also make the failure harder to reproduce: the job passes on a quiet host and dies under a container runtime that injects a few hundred kilobytes of metadata.
Other runtimes report it differently
The errno is portable; the message is not. A Node service that shells out sees neither bash's wording nor Python's, and a monitoring rule that greps for "Argument list too long" will miss it entirely.
// node-e2big.mjs - what Node reports when execve() refuses the argument list.
import { spawnSync } from "node:child_process";
function report(label, args) {
const result = spawnSync(args[0], args.slice(1), { encoding: "utf8", env: {} });
const outcome = result.error
? `${result.error.code} | ${result.error.message}`
: `ok, status=${result.status}`;
console.log(`${label.padEnd(24)}: ${outcome}`);
}
report("single 140000-byte arg", ["/bin/echo", "x".repeat(140000)]);
report("400 x 8192-byte args", ["/bin/echo", ...Array(400).fill("y".repeat(8192))]);
report("10 x 1024-byte args", ["/bin/echo", ...Array(10).fill("z".repeat(1024))]);
console.log(`${"node version".padEnd(24)}: ${process.version}`);
Output:
single 140000-byte arg : E2BIG | spawnSync /bin/echo E2BIG
400 x 8192-byte args : E2BIG | spawnSync /bin/echo E2BIG
10 x 1024-byte args : ok, status=0
node version : v26.5.1
Node surfaces the spawn failure as an error object on the result — error.code === "E2BIG" — with the command in the message and result.status left null, so a wrapper that only checks status !== 0 treats a refused execve() as a success. Check result.error first. The same shape appears in Python: subprocess.run() raises OSError instead of returning a non-zero code, which is why except OSError belongs around any call that passes user data on a command line.
Not E2BIG — the lookalikes worth ruling out
Resource-exhaustion errors cluster, and the four below are the ones that get misdiagnosed as an argument-length problem because they surface in the same cron job or the same container start.
| Message | errno | Actually about |
|---|---|---|
Argument list too long | E2BIG 7 | argv + environment bytes, or one string over 32 pages |
fork: Resource temporarily unavailable | EAGAIN 11 | process or thread count — see the limit that is not file descriptors |
Cannot allocate memory | ENOMEM 12 | address space for the new process, not the size of the argv |
Exec format error | ENOEXEC 8 | wrong architecture, CRLF shebang, binary without an interpreter |
Text file busy | ETXTBSY 26 | writing an inode that is mapped executable |
The distinction matters because the fixes are disjoint. Halving a batch does nothing for EAGAIN, and switching to xargs does nothing for ENOEXEC. Identify the errno before touching the command.
Verification checklist
- Measure, do not remember: run the probe block, or at minimum compare
getconf ARG_MAXwithenv -0 | wc -cin the same shell that fails. - Preflight the glob before running it:
printf '%s\n' ./x* | wc -cprints the exact byte cost of the expansion you are about to hand toexecve(). - Use
-0and an explicit-n; preferfind ... -exec cmd {} +over a shell glob for anything directory-sized. - Check the exit status against the meaning: 126 is "could not execute", 127 is "not found", 1 is the command's own failure.
- In a cron or CI wrapper, log the environment size once per run; the argument budget is a property of the calling process, and it changes when the platform injects variables.
- Re-run the block above after any change to the environment of the failing job — the fix has to hold under the environment that broke it.
All transcripts in this article come from the block that precedes them, executed twice and diffed for determinism on arm64 Linux 6.17.0-1019-oracle (bash 5.2.37, CPython 3.13.5, Node v26.5.1, 4096-byte pages, 8 MiB stack). The fixtures are created by the blocks themselves, so every number can be reproduced on your own host.
Related C2CZ posts
- Exec format error (ENOEXEC): architecture or shebang? — the other exit-126 branch, measured with a relabelled ELF and a CRLF shebang.
- Text file busy (ETXTBSY): fix writes to a running binary — why
cpfails wheremvsucceeds. - Too many open files (EMFILE): ulimit, systemd, Docker — the same "resource limit" family, one syscall later.