My blog server runs Alpine containers that share the host's kernel. Their stack rlimit is identical to the host's: ulimit -s reports 8192 KiB. Ask getconf ARG_MAX in the host shell and the answer is 2,097,152. Ask the same question inside the container and the answer is 131,072. A sixteenfold gap, on one machine, in the same second.
The shortest way to find out which number is true is to try to falsify both. Inside the container, a call to /bin/true with a single 200,000-byte argument came back with Argument list too long. Then 1,500 arguments of 1,000 bytes each — 1.5 MB in total, eleven times the reported limit — went to the same place and ran without complaint. So the number the container reports is too generous in one direction and too stingy in the other. Both can be wrong at once, because there is no single limit here.
This article digs into where that number comes from, who actually computes the budget, and where your own repository sits inside it. By the end we will also see that macOS does not merely answer the same question differently, but with a nastier failure mode.
The measurement setup
Three environments. The first is my laptop: Apple M4 Pro, macOS 26.6.2, arm64, 16,384-byte pages, shell stack rlimit 8176 KiB. The second is Docker Desktop's linuxkit VM: kernel 6.10.14, arm64, 4096-byte pages — inside it ubuntu:24.04 (glibc) and postgres:17.5-alpine (musl) ran side by side. The third is my blog server: x86_64, glibc on the host, musl in the containers.
The instrument is deliberately dumb: a ten-line wrapper that calls execve() with an empty environment after fork(), tries to run /usr/bin/true, and buries the failing errno in the exit code. A binary search grows the argument length or count until it finds the largest value that still runs. The empty environment matters: environ spends from the same budget, and polluting the measurement with the shell's own variables would have muddied every number. Every threshold was reproduced from the shell as well, because a threshold found with one tool might be a property of that tool.
One kernel, two libcs, two answers
First question: where does getconf get the number? On Linux it is not asking the kernel. The answer to sysconf(_SC_ARG_MAX) comes from libc. glibc's sysdeps/unix/sysv/linux/sysconf.c reads RLIMIT_STACK, divides it by four, refuses to go below 131,072, and caps the result at 6 MiB. musl returns the constant.
Sweeping the stack rlimit across both images on the same linuxkit kernel:
stack rlimit glibc (ubuntu:24.04) musl (alpine)
512 KiB 131,072 131,072
1024 KiB 262,144 131,072
8192 KiB 2,097,152 131,072
32768 KiB 6,291,456 131,072
unlimited 6,291,456 131,072
glibc's curve has three regimes: a 128 KiB floor, a linear region tied to the stack rlimit, and a 6 MiB ceiling. musl's curve is a straight line. Both are POSIX-conformant, because _SC_ARG_MAX means "a lower bound you can rely on here", not "the limit of this system". In practice that explains the whole sixteenfold gap on my server: the host read glibc and divided the stack rlimit by four, the container read musl and recited the constant. The kernel hands both of them the same budget.
I would not call musl's answer more honest. A constant is a polite way of saying "don't ask me"; glibc's number at least consults the right variable. But neither tells you where you will stop when you try to copy a single string, and that place, as we are about to see, can be far lower.
Asking the question inside your own container needs no compiler either. A shell, awk and env are enough:
probe() {
env -i /bin/true $(awk -v n="$1" 'BEGIN{s=sprintf("%0999d",0); for(i=0;i<n;i++) print s}') 2>/dev/null
}
lo=1; hi=8192
while [ $((hi - lo)) -gt 1 ]; do
mid=$(( (lo + hi) / 2 ))
if probe "$mid"; then lo=$mid; else hi=$mid; fi
done
echo "getconf=$(getconf ARG_MAX) fits=$lo approx_budget=$(( lo * 1008 ))"
Each argument is 999 digits plus a terminator, so 1008 bytes with its pointer. Run in the Alpine image at two stack rlimits, getconf said 131,072 both times; the probe found 259 arguments at a 1024 KiB stack (about 261,072 bytes) and 2,080 at 8192 KiB (about 2,096,640). Underneath musl's constant, the kernel applies exactly the curve glibc describes — the number changes, the budget does not.
Who actually computes the budget
The arithmetic lives in bprm_stack_limits() in fs/exec.c. Its core is three lines, read from today's mainline tree (version tag v7.3 development series):
limit = _STK_LIM / 4 * 3;
limit = min(limit, bprm->rlim_stack.rlim_cur / 4);
limit = max_t(unsigned long, limit, ARG_MAX);
_STK_LIM is 8 MiB, so the first line caps the budget at 6 MiB. The second takes a quarter of the stack rlimit. The third guarantees what the source comment calls the historically supported 32 pages even with small stacks: ARG_MAX, that is 131,072 bytes. Then comes the pointer tax: (max(argc, 1) + envc) * sizeof(void *) bytes are reserved for the argument and environment pointers and subtracted from the budget.
To check the formula against reality, 1023-byte arguments with an empty environment were grown count by count. For the largest call that still ran, the total cost follows the kernel's own accounting: every string copied (including the program path /usr/bin/true and argv[0], each with its terminating zero) plus eight bytes times argc. The argc column below includes argv[0], and the environment is empty:
stack rlimit getconf measured argc measured total
512 KiB 131,072 127 130,068
1024 KiB 262,144 254 261,132
2048 KiB 524,288 508 523,260
8192 KiB 2,097,152 2,033 2,097,060
16384 KiB 4,194,304 4,065 4,194,084
20480 KiB 5,242,880 5,081 5,242,596
24576 KiB 6,291,456 6,097 6,291,108
32768 KiB 6,291,456 6,097 6,291,108
unlimited 6,291,456 6,097 6,291,108
On every row the measured total stays under the reported budget, and the remaining gap is always smaller than the cost of one more argument (1024 bytes of string plus an 8-byte pointer): 1,004, 1,012, 1,028, 92, 220 and 284 bytes, then 348 bytes on the last three rows. The plateau begins exactly at 24 MiB, because what hits the ceiling is not the stack itself but a quarter of it: a quarter of 24 MiB is 6,291,456, that is _STK_LIM / 4 * 3. Past that point, growing the stack buys nothing. The ceiling arrived in 2017 with patch da029c11e6b1, "exec: Limit arg stack to at most 75% of _STK_LIM"; Kees Cook's rationale was to avoid pathological stack usage and the need to special-case setuid execs. glibc repeats the same limit in its own source as the maximum_ARG_MAX constant, with a comment naming the version it has applied since: 4.13.
This arithmetic has its own unit test. Added in 2024, fs/tests/exec_kunit.c starts by asserting that _STK_LIM is 8 MiB and ARG_MAX is 32×4 KiB, then claims exactly the plateau the table above shows: raising a quarter of the stack rlimit to _STK_LIM / 4 * 3 yields more room, raising it further yields nothing. The table was corrected twice this year — duplicate vectors dropped in February, and in March a vector reading _STK_LIM / 4 * + sizeof(void *), with the multiplier missing, was removed as unusable; Guenter Roeck found it. The missing multiplier does not make the expression uncompilable, it just makes it compute a different number — walking the removed vector through the arithmetic on paper leaves the expected argmin unchanged, so the vector was most likely green while testing the wrong thing. That last step is an inference with no citation behind it; still, it echoes a lesson from my own repository: a test can be green for the wrong reason.
The single-string ceiling: one sixteenth of the reported number
The reason that 200,000-byte single argument was rejected while the budget stood at 2 MiB lives in include/uapi/linux/binfmts.h: MAX_ARG_STRLEN is PAGE_SIZE * 32. On a kernel with 4 KiB pages that is 131,072 bytes, and copy_strings() runs every string through that filter individually.
The binary search lands on it exactly: a 131,071-byte argument runs, a 131,072-byte argument gets E2BIG. One line earlier, the same shell answers 2,097,152 to getconf ARG_MAX. So a single string may never exceed one sixteenth of the total budget. Anyone trying to pass a long --filter expression or a base64-embedded configuration on the command line, not knowing this, hits a wall whose number the error message never mentions. Environment variables face the same filter: a single A=... variable accepts up to 131,069 bytes of value and then stops — exactly 131,072 with A= and the terminating zero.
A footnote, because this number is not fixed: MAX_ARG_STRLEN follows the page size, while the ARG_MAX floor is hardcoded as 32×4 KiB. On an arm64 kernel with 16 KiB pages I would expect the single-string ceiling to rise to 512 KiB while the floor stays at 128 KiB. All my Linux environments use 4 KiB pages, so that one is unmeasured — an inference derived from the source, not a result.
The pointer tax: a third of the budget is not string at all
The kernel does not only count strings. It also reserves a pointer slot on the new stack for every argument and every environment variable, and subtracts that up front. The source comment says why plainly: the pointers are written much later, at which point there is no way to tell the parent that the child ran out of stack, so the sum is computed ahead of time to let the failure return gracefully. Hold on to that sentence; we come back to it in the macOS section.
Saturating the budget with 16-byte arguments shows the size of the tax: 83,884 arguments went through in the 2 MiB environment. The strings add up to 1,426,028 bytes, or 1,426,056 with the program path and argv[0]; the pointers are 8 × 83,885 = 671,080, for 2,097,136 together. That leaves 16 bytes of the budget, and one more argument would cost 25, so it does not fit. So thirty-two percent of the budget carries no data at all, only the addresses of that data. In a find | xargs pipeline over short file names, that means splitting a third earlier than you expected.
macOS gets the same question wrong in another way
On the Mac, getconf ARG_MAX says 1,048,576, and that number does not budge when the stack rlimit changes: the same at 512 KiB as at 8176 KiB. The source is right there too; xnu's bsd/sys/syslimits.h defines ARG_MAX as 1 MiB for macOS and 256 KiB for Apple's other platforms. bsd/kern/kern_exec.c takes that number under the name NCARGS, puts it in the ip_argspace counter, and subtracts a pointer slot for every argv[] entry from the same counter — the pointer tax exists here as well.
With the default stack the measurement is clean: a single argument goes through up to 1,048,529 bytes, E2BIG arrives at 1,048,530, and the total cost stops 32 bytes short of 1 MiB. With 16-byte arguments, 41,941 of them fit while the strings total only 712,997 bytes; the pointers are 8 × 41,942 = 335,536, and together the two land just under 1 MiB — the rest, a third of the budget, is address again.
The real finding showed up after lowering the stack rlimit. With ulimit -s 1024, getconf still says 1,048,576, but the actual behaviour splits into three:
≤ 1,027,305 bytes the program runs
1,027,306 – 1,047,897 the child dies with SIGSEGV
1,047,898 – 1,048,529 the child dies with SIGKILL
≥ 1,048,530 execve returns E2BIG
There is a 21,224-byte window in the middle where execve succeeds. No error, no E2BIG; the new program is born and dies while laying out its stack. From the shell it looks the same: exit code 139 in zsh, that is 128 plus SIGSEGV. At the top end zsh itself steps in with argument list too long and never calls execve; in that middle window it calls it without a word.
The Linux comment quoted above was written precisely to prevent this failure mode. The kernel computes the budget up front and sets argmin, because a child whose stack is exhausted cannot explain itself to its parent. macOS does the same arithmetic without ever consulting the stack rlimit, and the child pays the difference. How often this bites you depends on who lowered ulimit -s — the setting here was lowered on purpose, and at the default 8 MiB the window is closed. But it is not hard to guess how many hours a segmentation fault instead of an E2BIG would cost in a build script: you go looking in the wrong place.
Where your own repository sits
This is where the theory touches your files. In the blog's origin/main tree, before this article was added, the pattern src/content/blog/*/*.mdx found 2,754 files. The relative paths total 233,211 bytes, or 255,243 with the 8 × 2,754 = 22,032 bytes of pointers. Converted to absolute paths, using my main working copy's 46-byte prefix, 359,895 plus the same pointers, 381,927.
Growing that real file list to the limit, with the stack rlimit as the variable and the list cycling so the average path length holds:
stack rlimit budget (getconf) files that fit real set headroom
1024 KiB 262,144 2,828 2,754 74 files
2048 KiB 524,288 5,657 2,754 2,903 files
8192 KiB 2,097,152 22,639 2,754 19,885 files
With the default 8 MiB stack I am comfortable: nineteen thousand files of room, a size this repository will not reach in my lifetime. But in a systemd unit carrying LimitSTACK=1M, or a container with the same setting, my headroom is 74 files. The blog targets three articles a day and every article is one Turkish and one English file, so six files a day; at that rate those 74 files are gone in twelve days. At a 512 KiB stack there was no ceiling left to measure: the glob itself fell over with /usr/bin/ls: Argument list too long, because the floor is 131,072 and the set is 255,243.
The same measurement on the Mac, carrying my real 3,872-byte environment, stops at 11,272 files with relative paths and 7,540 with absolute ones. Both are plenty for today. On the total budget the Mac's 1 MiB is half of Linux's default 2 MiB; but that comparison holds only for the total, and for a single string the direction reverses — more on that shortly.
xargs does not use this budget
One last trap: xargs limits itself by its own buffer, not the system's. GNU xargs --show-limits spells it out in the container — the maximum usable command length is 2,094,894 bytes, and the buffer actually in use is 131,072 bytes. It uses one sixteenth of the room available, and it does not hide that.
Handing my real file list to both: macOS's BSD xargs passed all 2,754 paths in a single call. GNU xargs in the container split it in two: 1,572 plus 1,182. To see the difference in criteria, two synthetic lists of 20,000 items each went through both: one of 9-character strings, one of 99-character strings (10 and 100 bytes with the terminator). BSD passed 5,000 arguments per call in both cases, so its criterion is count, not bytes. GNU's is bytes: the 10-byte list split into 13,105 plus 6,895, filling its buffer.
This is a nice case of "which tool is better" flipping direction with path length. With short names GNU makes fewer calls (two, against BSD's four); with long paths BSD pulls ahead. They break even somewhere around a 25-byte path name. With my 84.7-byte average, BSD makes one call and GNU two — but -s 262144 brings GNU down to a single call too. Push the buffer higher and it objects politely: asked for 4,000,000, it says the value for -s should be no greater than 2,094,999 and carries on with its own ceiling.
What to do in practice
Let me boil all of this into one checklist.
Do not read getconf ARG_MAX as an upper bound; read it as "there is at least this much here". If you want the real budget, take a quarter of ulimit -s, cap it at 6 MiB, floor it at 128 KiB, then subtract eight bytes per argument and per environment variable. Environment strings spend from the same purse; env | wc -c returns a four-figure number on most CI runners, and that number behaves as if it were appended to your list. In an Alpine-based image, do not trust the number at all; musl recites a constant that has nothing to do with the kernel's budget.
Accept up front that you cannot pass one long string — an embedded configuration, a long filter expression, a base64 blob — on the command line: the ceiling is 128 KiB and growing the total budget does not move it. Read it from a file, use --file or standard input.
When you touch LimitSTACK in a service unit, remember that it also sets the argument budget, divided by four. None of the units on my server writes that setting; LimitSTACKSoft is 8,388,608, the default inherited from systemd. Dropping it to 1 MiB would have broken my 2,754-file pattern twelve days later, with an error that would look entirely unrelated to stacks. The same knob in a container is docker run --ulimit stack=1048576:1048576: trying it dropped getconf ARG_MAX to 262,144 on the spot.
Pass lists, not globs — but do not conflate the two remedies. xargs does not leave the budget; it splits the list into pieces that fit and runs a separate execve for each. To actually get out of the budget, hand the command a file or standard input: git --pathspec-from-file, grep --file, tar --files-from. If you accept splitting, set the buffer yourself with GNU xargs -s and make sure the command survives it. grep -l does. sort -u does not: two calls produce two separately sorted sets and the duplicates come back. Neither does wc -c; each call prints its own "total" line and no overall sum ever appears. For both, the fix is to pipe the data rather than the list: xargs -0 cat | sort -u.
And when you claim portability, treat the two limits separately. On the total the Mac is the narrow one: 1 MiB against Linux's default 2 MiB, so a long file list blows up on the laptop before it does in CI. On a single string the direction is exactly reversed: a 500 KB argument goes through on the Mac and hits the 128 KiB ceiling on Linux with 4 KiB pages. "It worked on my machine" can be said in both directions; do not claim portability without knowing which of the two limits you are approaching.
Conclusion
What this dig left me with is not a number but a habit: never believe the limits a system reports about itself without measuring them. getconf ARG_MAX is not giving a wrong answer — it is giving the right answer to a different question. POSIX asks "what minimum can I rely on"; my question was "can I pass this list right now", and those two can diverge by a factor of sixteen.
The interesting part is that the kernel does not hide the ambiguity, it confesses it in the source. bprm_stack_limits() reconciles three different constants in three lines, the comments record which historical burden is carried and why, and all of that arithmetic has a unit test of its own. A test that was itself corrected twice this year. Even the dullest corner of the most-used syscall still needs maintenance; the job on our side is much simpler — measure.
A related read to finish with: fork: retry — Four Ceilings, One Error Message examines a problem from the same family on the process-creation side. And if you wonder why limits seen from inside a container disagree with the host's, who changed fs.file-max is this measurement's neighbour.
Top comments (0)