---
title: "ulimit and PHP: When the OS Says No"
url: https://www.exakat.io/ulimit-and-php-when-the-os-says-no/
date: 2026-09-29
modified: 2026-09-29
lang: en
author: "dams"
description: "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..."
categories:
  - "Code auditing"
  - "Technology"
tags:
  - "directive"
  - "limit"
  - "php"
image: https://www.exakat.io/wp-content/uploads/2026/09/limit.640.jpg
word_count: 2078
---

# ulimit and PHP: When the OS Says No

# 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.

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.

If you want the false-return behavior people expect, ignore the signal first:

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:

`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

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.

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 `require`d; `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, `ulimit`for 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()`](https://www.php.net/manual/en/function.posix-getrlimit.php) and [`posix_setrlimit()`](https://www.php.net/manual/en/function.posix-setrlimit.php) 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.