ulimit and PHP: When the OS Says No
You set memory_limit and max_execution_time in php.ini and feel in control. You are, in fact, but you are also not the only one in control. Underneath PHP, the kernel keeps its own ledger of what a process is allowed to do, and it does not negotiate. That ledger is ulimit, and it has opinions PHP never gets to hear about, even as PHP has limits in place to mimic these same resources.
After running into one too many error because my PHP application opened too many files on Mac OSX, I learned about ulimit and its opened file soft limit. There were many others, that are worth learning about.
Two Rooms, Two Atmospheres
PHP’s directives are the polite layer. When you hit memory_limit, you get a catchable fatal error with a message telling you exactly what happened. When you hit a ulimit, and the kernel sends a signal, usually SIGKILL or SIGXCPU or SIGXFSZ, and your process is simply gone. No stack trace, no log line, no register_shutdown_function call in most cases. One process, formerly running: a ghost that never was.
| Concern | PHP directive | ulimit flag | What happens when you cross it |
|---|---|---|---|
| CPU time | max_execution_time |
-t |
Catchable timeout, then SIGXCPU/SIGKILL |
| Memory | memory_limit |
-v (address space), -d(data segment) |
Catchable fatal, then process killed |
| Upload size | upload_max_filesize |
-f |
SIGXFSZ, then killed on repeat |
| Stack depth | none (until 8.3) | -s |
Catchable Error since 8.3, SIGSEGV before |
| Open files | none | -n |
fopen()/fsockopen()return false |
| Child processes | none | -u |
pcntl_fork() fails, EAGAIN |
| Core dumps | none | -c |
Core file written or suppressed |
That table is the short version of this article. Everything below is what happens in the gap between the second and third column.
This is most useful in three places: shared hosting, where one tenant’s runaway script cannot be allowed to starve the others; containers, where the orchestrator enforces limits from outside PHP entirely; and development, where I’d rather discover a resource assumption on my laptop than in production. A dev environment that is more permissive than prod is a dev environment making you way softer than you should.
CPU Time: A Warning Shot, Then None
max_execution_time throws a catchable fatal error, and PHP still runs your registered shutdown functions afterward. You get a chance to log the timeout, flush a buffer, close a connection. ulimit -t gives you none of that by default: cross the soft limit and the kernel sends SIGXCPU; cross the hard limit and it’s SIGKILL, uncatchable. On a Docker container running a worker script, this is the difference between “we logged a timeout” and the container just restarted, for no apparent reason.
The one point to note: SIGXCPU is catchable, if you bother.
<?php
// Register a handler for the soft CPU-time warning before the hard kill lands
pcntl_async_signals(true);
pcntl_signal(SIGXCPU, function () {
error_log('CPU soft limit reached, cleaning up before SIGKILL arrives');
// you get one shot at this; make it count
});
while (true) {
// burn CPU on purpose for this illustration
}
?>
ulimit -t 5 # soft limit hit at 5s, SIGXCPU fires; hard kill follows shortly after php burn.php
File Size: Not as Silent as It Looks
upload_max_filesize caps what PHP accepts over HTTP. ulimit -f caps what the process may write to disk, and it is stricter than the “fopen just returns false” story usually told about it. A write() that starts past the limit fails with EFBIG and delivers SIGXFSZ to the process, which terminates it by default, same as any other unhandled signal. Only a write that starts below the limit and crosses it gets the gentler treatment: a short write, fewer bytes than requested, no signal at all.
<?php
$fh = fopen('/tmp/big.log', 'w');
$written = fwrite($fh, str_repeat('x', 50 * 1024 * 1024));
// under ulimit -f 10240 (10MB), this either short-writes and returns
// an integer smaller than you asked for, or the process is gone —
// there's rarely a clean false to check
?>
If you want the false-return behavior people expect, ignore the signal first:
<?php
pcntl_async_signals(true);
pcntl_signal(SIGXFSZ, SIG_IGN);
// now fwrite() past the limit returns false instead of killing you
var_dump(file_put_contents('/tmp/big.log', str_repeat('x', 50 * 1024 * 1024)));
?>
The writing basically limit the total size of the file. If the application proceeds with bite-size appends, it will still stop once the file pass the threshold set by the operating system.
On the other hand, ulimit -f does not affect reading from files.
Memory: Two Different Ceilings Wearing the Same Name
memory_limit tracks what the Zend engine allocates for PHP values: arrays, objects, strings, resources. ulimit -d, for data segment, and ulimit -v, for virtual address space, are OS-level ceilings on the whole process: PHP binary, extensions, shared libraries, everything. Hit memory_limit and you get Allowed memory size exhausted. Hit -v and the kernel kills the process outright.
One nuance worth knowing before you rely on -d: modern glibc malloc() routes large allocations through mmap() rather than growing the classic data segment, so ulimit -d can undercount what your process is actually holding. ulimit -v, which bounds total address space regardless of how it got mapped, is the one that reliably catches PHP’s big allocations.
<# belt and suspenders: PHP warns first, the OS enforces the hard ceiling ulimit -v 524288 # 512MB in KB php -d memory_limit=256M script.php
Stack Depth: PHP Caught Up in 8.3
Deep recursion used to mean a segfault once you hit ulimit -s. This is not a PHP error, just a dead process, often mid-request. PHP 8.3 added zend.max_allowed_stack_size, and now the engine checks its own call stack against that budget and throws a catchable Error before the OS ever gets involved:
<?php
function recurse(int $n): int
{
return recurse($n + 1);
}
try {
recurse(0);
} catch (\Error $e) {
// "Maximum call stack size of X bytes ... reached"
echo $e->getMessage(), PHP_EOL;
}
?>
ulimit -s is still there underneath as the hard backstop, set it too low and you can still starve the Zend stack before PHP’s own guard has room to react, but for the ordinary “someone forgot a final condition” bug, PHP now catches its own mistake before the kernel has to.
File Descriptors: The Most Popular Of All
ulimit -n is the limit you will meet first, because macOS ships a default soft limit of 256, and any script that opens a handful of files, a couple of database connections, and a cURL handle or two can get there faster than seems reasonable.
ulimit -n # check the current soft limit ulimit -n 10000 # raise it for this shell session
<?php
$handles = [];
for ($i = 0; $i < 1000; $i++) {
$fh = fopen("/tmp/fd-test-$i", 'w');
if ($fh === false) {
printf("fopen failed after %d successful opens\n", count($handles));
break;
}
$handles[] = $fh;
}
?>
No exception is thrown. fopen(), fsockopen(), a fresh PDO connection: they all just hand back false or an exception you may not be catching. Sometimes, there is also a warning message telling you the file could not be opened. That is true, and that is misleading. It produces the most confused bug reports. The fix is almost always that someone forgot to check a return value, and the person debugging it wasn’t the person who wrote that line. At best, it was himself, six months prior.
Processes and Core Dumps, Briefly
ulimit -u caps how many processes your user may run at once, relevant the moment your app forks workers or shells out repeatedly pcntl_fork(), proc_open(), a queue worker spawning children. Cross it and you get EAGAIN, “resource temporarily unavailable,” which reads like a transient network error and isn’t.
ulimit -c controls core dump size. Core dumps are gold for debugging a segfault and a liability if they land on disk in production with a database password sitting in memory. Set it to 0 outside of a debugging session:
ulimit -c 0 # no core dumps ulimit -c unlimited # full core dumps, for a debugging box only
Asking the OS From Inside PHP
posix_getrlimit() reads every limit at once; posix_setrlimit() also exists, to set the same limits, within whatever ceiling the hard limit already allows. So you can’t break free from them, but make them even tighter: it reminds me of certain knots.
<?php
$limits = posix_getrlimit();
foreach (['Max open files', 'Max processes', 'Max data size'] as $key) {
echo $key, ': ', $limits[$key]['soft limit'], "\n";
}
?>
<?php // lower the soft file-descriptor limit for a subprocess that should // misbehave in a contained way, without touching the parent's limit posix_setrlimit(POSIX_RLIMIT_NOFILE, 64, 64); ?>
An unprivileged process can only move its soft limit up to its hard limit, and can only ever lower the hard limit. If you need a higher ceiling than the hard limit allows, that change happens outside PHP, in /etc/security/limits.conf, the systemd unit, or the --ulimit flag on docker run.
Breaking It on Purpose
The most useful thing you can do with ulimit has nothing to do with production. Set -n to 10 and run your test suite. Set -v to something your app should never approach. Watch what falls over.
( ulimit -n 10 && ulimit -v 65536 && php vendor/bin/phpunit )
The subshell keeps the tightened limits from leaking into your regular terminal. What breaks under this is usually not a bug in the strict sense: it’s every place your code assumed a resource would always be there, written down as a stack trace instead of as a comment.
Watching What PHP Has Open
PHP has no built-in inventory of its own open file descriptors. get_included_files() tells you what got required; php_ini_loaded_file() and php_ini_scanned_files() tell you which .ini files were read. None of that is the same as the list of files descriptors currently open, and extensions open handles of their own that never surface in any PHP-level list. Actually, exceptions may also be an opened file by themselves. For the real picture, ask the kernel:
lsof -p $(pgrep -f 'php-fpm: pool')
May be we could request such a function as a native PHP one. It could be useful to improve performances, by reducing the number of opened files.
Where the Two Resource Constraints Actually Meet
Use ulimit for the limit that must never be crossed, no matter what PHP thinks it’s doing, including bugs or by-pass: a worker script that must die rather than eat the box, a shared host where one tenant cannot be allowed to take the others down. Use PHP directives for the limit you want a chance to react to. Although, in practice, it is rare to take time to climb out of such holes.
In practice you want both, tuned so the soft one fires first:
; php.ini — the polite warning memory_limit = 256M
# the hard backstop, set where the process actually starts ulimit -v 524288 # 512MB, comfortably above 256M php-fpm
The gap between the two numbers is your reaction time. Make it wide enough to matter and narrow enough that the OS limit isn’t just decorative.
The pitfalls, in short: don’t assume fopen(), file_get_contents(), or PDO‘s constructor will always succeed. Check what they return, especially in code that runs under a tightened ulimit. Don’t tune limits only in staging and assume production behaves the same under real load. And close what you open; a script that never calls fclose() is a script slowly negotiating its own file-descriptor limit downward.
Conclusion
ulimit is the layer PHP’s error messages were never going to warn you about, because PHP doesn’t get informed before the kernel acts. It finds out the same way you do, by no longer running. Treat the two layers as complementary: PHP’s directives for the failure you want to observe, ulimitfor the one you want to guarantee.
The open question is the one from the “breaking it on purpose” section, generalized: if a resource assumption is worth catching in production, why is ulimit -n 10 not part of the test matrix already, next to the PHP version and the database driver?
Further Reading
The PHP manual’s posix_getrlimit() and posix_setrlimit() pages cover the full set of resource constants. man getrlimit (section 2) is the actual kernel contract everything above is built on, signals included. Docker’s --ulimit flag documentation and systemd’s LimitNOFILE=/LimitCPU=-style unit directives cover the two places most PHP processes inherit their limits from without anyone setting them explicitly.

