fork() safety — curated references for a forking pytest worker plugin¶
2026-07-14. Source-grounded research for a pytest plugin that forks a pre-warmed parent to create test workers. Every claim below is a verbatim quote from a primary source with a URL. Follows the [[search-first-for-infra]] rule: design from the docs, cite them, then verify with a canary — never discover documented behavior by canary.
Bottom line: the design "fork a pre-warmed parent that has already opened resources" is the exact
pattern every primary source warns against. The salvage is forkserver +
set_forkserver_preload — stdlib-blessed pre-warming that is single-threaded by construction — with
the hard rule that preloaded modules import code but open no connections, loops, or threads.
1. The two independent hazard classes¶
They compound but are distinct — fixing one does not fix the other:
| Class | Trigger | Failure mode |
|---|---|---|
| Shared fd / duplicated state | fork with a connection, epoll fd, or socket open | Two processes on one TCP socket → interleaved protocol messages, corrupted session |
| Locks held at fork instant | fork while >1 thread alive | Child deadlocks forever on a lock no surviving thread will release (malloc, logging, import) |
2. PostgreSQL / libpq — the authority under every driver¶
https://www.postgresql.org/docs/current/libpq-connect.html (§ Database Connection Control Functions):
On Unix, forking a process with open libpq connections can lead to unpredictable results because the parent and child processes share the same sockets and operating system resources. For this reason, such usage is not recommended, though doing an
execfrom the child process to load a new executable is safe.
Note the carve-out: exec from the child is safe (it discards the inherited address space). Fork
without exec is the unsafe case — and that is precisely what a forking worker plugin does.
psycopg2¶
https://www.psycopg.org/docs/usage.html (§ Thread and process safety):
In DB API 2.0 parlance, Psycopg is level 2 thread safe.
The above observations are only valid for regular threads: they don't apply to forked processes nor to green threads. libpq connections shouldn't be used by a forked processes, so when using a module such as multiprocessing or a forking web deploy method such as FastCGI make sure to create the connections after the fork.
The concrete symptom, from the FAQ — https://www.psycopg.org/docs/faq.html:
Why do I get the error
current transaction is aborted, commands ignored until end of transaction blockwhen I use multiprocessing (or any other forking system) and not when use threading? Psycopg's connections can't be shared across processes (but are thread safe). If you are forking the Python process make sure to create a new connection in each forked child.
This is the fingerprint to watch for in worker logs: a transaction-aborted error storm, not a clean crash. Sharing corrupts the session, it does not fail fast.
psycopg3¶
The fork warning is not on the pool page (verified: advanced/pool.html and api/pool.html
contain zero occurrences of "fork"). It lives on the Concurrent operations page —
https://www.psycopg.org/psycopg3/docs/advanced/async.html:
Warning: Connections are not process-safe and cannot be shared across processes, for instance using the facilities of the
multiprocessingmodule. If you are using Psycopg in a forking framework (for instance in a web server that implements concurrency using multiprocessing), you should make sure that the database connections are created after the worker process is forked. Failing to do so you will probably find the connection in broken state.
psycopg3's pool adds nothing that makes a pool fork-safe; the guidance is unchanged from psycopg2.
SQLAlchemy — the clearest mechanism statement¶
https://docs.sqlalchemy.org/en/20/core/pooling.html § Using Connection Pools with Multiprocessing or os.fork():
It's critical that when using a connection pool, and by extension when using an
Enginecreated viacreate_engine, that the pooled connections are not shared to a forked process. TCP connections are represented as file descriptors, which usually work across process boundaries, meaning this will cause concurrent access to the file descriptor on behalf of two or more entirely independent Python interpreter states.Depending on specifics of the driver and OS, the issues that arise here range from non-working connections to socket connections that are used by multiple processes concurrently, leading to broken messaging (the latter case is typically the most common).
Four sanctioned approaches; #2 is marked "This is the recommended approach":
engine = create_engine("...")
def initializer():
"""ensure the parent proc's database connections are not touched
in the new connection pool"""
engine.dispose(close=False)
with Pool(10, initializer=initializer) as p:
p.map(run_in_process, data)
close=False (added 1.4.33) replaces the child's pool without closing the parent's live
connections — closing them from the child would tear down sockets the parent is still using. Getting
this wrong is a classic: dispose() (the default, close=True) in a child sends protocol close
messages down the shared socket and breaks the parent.
The other three: (1) NullPool; (3) engine.dispose() before forking; (4) a connect/checkout
event-listener pair that stamps os.getpid() and raises DisconnectionError on mismatch.
The hard limit — the escape hatch does not extend to live objects:
The above strategies will accommodate the case of an
Enginebeing shared among processes. The above steps alone are not sufficient for the case of sharing a specificConnectionover a process boundary; prefer to keep the scope of a particularConnectionlocal to a single process (and thread). It's additionally not supported to share any kind of ongoing transactional state directly across a process boundary, such as an ORMSessionobject that's begun a transaction and references activeConnectioninstances; again prefer to create newSessionobjects in new processes.
⚠️ Direct consequence for a pre-warmed test-worker design: a pre-warmed parent holding an open
transaction (the standard "wrap each test in a rollback" fixture) is in exactly the unsupported
category. An Engine can be forked with discipline; an open transaction cannot.
3. fork + threads — CPython's own warning¶
https://docs.python.org/3/library/os.html#os.fork, verbatim:
Changed in version 3.12: If Python is able to detect that your process has multiple threads,
os.fork()now raises aDeprecationWarning.We chose to surface this as a warning, when detectable, to better inform developers of a design problem that the POSIX platform specifically notes as not supported. Even in code that appears to work, it has never been safe to mix threading with
os.fork()on POSIX platforms. The CPython runtime itself has always made API calls that are not safe for use in the child process when threads existed in the parent (such asmallocandfree).Users of macOS or users of libc or malloc implementations other than those typically found in glibc to date are among those already more likely to experience deadlocks running such code.
Also on the same page:
Note that some platforms including FreeBSD <= 6.3 and Cygwin have known issues when using
fork()from a thread.
Trigger: the warning fires on os.fork() when CPython detects more than one thread alive — it
is not about which thread forks. Issue: https://github.com/python/cpython/issues/100228, "raise a
Warning when os.fork() is called and the process has multiple threads", opened by gpshead (Gregory
P. Smith), closed 2023-01-29, implemented in gh-100229. Not a PEP.
Why — from the discussion the docs themselves link, https://discuss.python.org/t/33555
("Concerns regarding deprecation of fork() with alive threads"): POSIX permits only
async-signal-safe operations in the child until exec; pthread_mutex_lock/unlock are not
async-signal-safe. Only the forking thread survives into the child — any lock held by any other
thread at the fork instant is left locked forever, with no thread alive to release it. The child
then deadlocks the first time it touches malloc, logging, or the import lock. gpshead's framing:
the CPython runtime "is by definition unsafe for use after fork()."
This is the trap for a pre-warmed parent. Warming is exactly what starts threads — a driver
keepalive, a ThreadPoolExecutor, an OTel/logging exporter, gRPC. The parent looks healthy; the
children deadlock non-deterministically, mostly under load, mostly in CI.
TLS — a separate fork bug¶
os.fork cross-references https://docs.python.org/3/library/ssl.html:
If using this module as part of a multi-processed application (using, for example the
multiprocessingorconcurrent.futuresmodules), be aware that OpenSSL's internal random number generator does not properly handle forked processes. Applications must change the PRNG state of the parent process if they use any SSL feature withos.fork(). Any successful call ofRAND_add()orRAND_bytes()is sufficient.
Relevant if workers talk TLS to Postgres (sslmode=require): forked children otherwise share PRNG
state.
4. asyncio + fork¶
There is no official statement in the asyncio docs. Verified: asyncio-dev.rst,
asyncio-eventloop.rst, asyncio-runner.rst contain zero occurrences of "fork". The authority is
the issue tracker and the source.
Guido van Rossum, closing https://github.com/python/cpython/issues/85789 ("What's the by-design
behavior when os.fork() is invoked in an asyncio loop?") as not a bug, 2020-08-24 (typos his):
As you,can tell from thencode this by design. The child,must create a new event loop explicitly,ifmitmwants to use asyncio, and all,existing Futures are off limits.
Fork is dangerous. Don't use unless you understand it.
The fd-sharing mechanism, demonstrated in https://github.com/python/cpython/issues/66285 — the self-pipe is duplicated, not recreated:
loop = asyncio.get_event_loop()
pid = os.fork()
# parent 6 5
# child 6 5 <-- same _csock/_ssock fds in both processes
Tracked as bpo-21998 → https://github.com/python/cpython/issues/66197, "asyncio: support fork" — "a new self-pipe should be created in the child process after fork". The epoll/kqueue selector fds are inherited the same way; a forked child manipulating them affects the parent's epoll set.
What CPython actually does today — Lib/asyncio/events.py, verbatim:
if hasattr(os, 'fork'):
def on_fork():
# Reset the loop and wakeupfd in the forked child process.
global _local
_local = _Local()
_set_running_loop(None)
signal.set_wakeup_fd(-1)
os.register_at_fork(after_in_child=on_fork)
Shipped via gh-99539 ("GH-66285: fix forking in asyncio", kumaraditya303, merged 2022-11-24 → 3.12),
which per its author does two things: reset the event-loop registry so parent and child don't share a
loop, and reset the wakeupfd so signals to the child don't run the parent's handler.
Scope this precisely — it is easy to over-read. The handler clears the loop registry and the
wakeup fd. It does not close the inherited epoll fd or the parent's self-pipe socketpair, and it
does not make inherited Future/Task objects usable. It makes get_event_loop() in the child
return a fresh loop instead of the parent's. Guido's constraint stands: the child must build a new
loop, and existing Futures are off limits. Note the 3.12 handler landed only after the 2018 fix was
reverted (Yury: "We should strive to implement a proper solution, not commit some half-working
code") and the issue sat open from 2014 to 2022 — this area has a long history of partial fixes.
5. The documented safe discipline¶
Uniform across every source: fork first, open second.
- libpq:
execfrom the child is safe; forking with open connections is "not recommended". - psycopg2/3: "make sure to create the connections after the fork".
- SQLAlchemy:
engine.dispose(close=False)in the child's initialize phase. - asyncio: the child must create a new event loop explicitly.
- CPython: don't fork with >1 thread alive.
os.register_at_fork — the mechanism, and its limits¶
https://docs.python.org/3/library/os.html#os.register_at_fork (added 3.7), verbatim:
Register callables to be executed when a new child process is forked using
os.fork()or similar process cloning APIs. The parameters are optional and keyword-only. Each specifies a different call point.
- before is a function called before forking a child process.
- after_in_parent is a function called from the parent process after forking a child process.
- after_in_child is a function called from the child process.
These calls are only made if control is expected to return to the Python interpreter. A typical
subprocesslaunch will not trigger them as the child is not going to re-enter the interpreter.Functions registered for execution before forking are called in reverse registration order. Functions registered for execution after forking (either in the parent or in the child) are called in registration order.
Note that
fork()calls made by third-party C code may not call those functions, unless it explicitly callsPyOS_BeforeFork(),PyOS_AfterFork_Parent()andPyOS_AfterFork_Child().There is no way to unregister a function.
Two teeth here for a plugin author: no unregister (a plugin registering per-session leaks handlers
across runs), and third-party C code may bypass it entirely. register_at_fork is a repair
hook — it cannot make a lock that was held at the fork instant safe, because the child is already in
the async-signal-unsafe world by the time after_in_child runs. It is correct for "drop the inherited
pool / reset the loop"; it is not a fix for fork-with-threads.
6. forkserver — the middle ground, and the actual answer¶
https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods, verbatim:
forkserver When the program starts and selects the forkserver start method, a server process is spawned. From then on, whenever a new process is needed, the parent process connects to the server and requests that it fork a new process. The fork server process is single threaded unless system libraries or preloaded imports spawn threads as a side-effect so it is generally safe for it to use
os.fork(). No unnecessary resources are inherited.Available on POSIX platforms which support passing file descriptors over Unix pipes such as Linux. The default on those.
And fork, for contrast:
The parent process uses
os.fork()to fork the Python interpreter. The child process, when it begins, is effectively identical to the parent process. All resources of the parent are inherited by the child process. Note that safely forking a multithreaded process is problematic.
Python 3.14 default change — VERIFIED¶
Confirmed verbatim from the 3.14 docs, two independent notes:
Changed in version 3.14: On POSIX platforms the default start method was changed from fork to forkserver to retain the performance but avoid common multithreaded process incompatibilities. See gh-84559.
fork — Changed in version 3.14: This is no longer the default start method on any platform. Code that requires fork must explicitly specify that via
get_context()orset_start_method().forkserver — Changed in version 3.14: This became the default start method on POSIX platforms.
Tracking issue https://github.com/python/cpython/issues/84559: "multiprocessing's default posix
start method of 'fork' is broken: change to 'forkserver' || 'spawn'" — filed by itamarst
2020-04-24, closed 2024-11-11, implemented in gh-101556 ("Change the multiprocessing start method
default to forkserver"). The user's recollection is correct, with a nuance worth keeping: the change
is POSIX-wide, not Linux-only (macOS was already spawn since 3.8), and the chosen default is
forkserver, not spawn — explicitly "to retain the performance".
Pre-warming is a first-class, supported feature¶
set_forkserver_preload is the stdlib's own answer to "pre-warmed parent" —
https://docs.python.org/3/library/multiprocessing.html#multiprocessing.set_forkserver_preload:
Set a list of module names for the forkserver main process to attempt to import so that their already imported state is inherited by forked processes. This can be used as a performance enhancement to avoid repeated work in every process.
For this to work, it must be called before the forkserver process has been launched (before creating a
Poolor starting aProcess).
(3.15 adds on_error='ignore'|'warn'|'fail'; default "ignore" silently swallows ImportError
— a preload that fails to import degrades to a cold worker with no signal. Use "warn"/"fail" when
available.)
The catch, stated in the docs themselves: single-threadedness holds "unless system libraries or preloaded imports spawn threads as a side-effect". So preloading is safe for import cost (SQLAlchemy, pytest plugins, your app's module graph — the bulk of test-suite warm-up) and unsafe for live resources. A preloaded module that starts a thread or opens a connection at import time silently reintroduces both hazard classes into the fork server.
7. Design verdict for the plugin¶
Fork-the-warmed-parent, as stated, is the anti-pattern. Each source independently condemns it, and they compound: warming imports → threads; warming connections → shared sockets; warming an async runtime → shared epoll + self-pipe.
Split "warm" into two kinds and the design resolves:
| Warm state | Fork-safe? | Discipline |
|---|---|---|
| Imported modules, parsed config, collected test tree, in-memory fixtures | ✅ Yes | set_forkserver_preload — this is the sanctioned mechanism, and it's most of the win |
| DB connections / pools | ❌ No | Open after fork; engine.dispose(close=False) in child init |
Open transactions, Session, Connection |
❌ No — explicitly unsupported | Recreate in child; never inherit |
| Event loop, epoll fd, self-pipe | ❌ No | New loop in child; existing Futures off limits |
| Threads (pools, exporters, keepalives) | ❌ No | Must not exist in the forking process |
Fastest defensible design: forkserver + set_forkserver_preload([...]) for the import graph,
workers opening their own connections post-fork. This is the stdlib's blessed shape for exactly this
goal, it's the 3.14 default, and it sidesteps the thread hazard by construction.
Verification, before trusting any of it (the canary verifies, it must not discover):
PYTHONWARNINGS=error::DeprecationWarning→ any fork-with-threads becomes a hard, loud failure rather than a rare CI deadlock. This is the single highest-value guard.- Assert
threading.active_count() == 1immediately before the fork, and log the thread names when it isn't — that turns "mystery hang" into a named culprit. - Grep worker logs for
current transaction is aborted, commands ignored until end of transaction block— the documented fingerprint of a shared libpq socket.
8. Source index¶
| # | Source | URL |
|---|---|---|
| 1 | libpq — Database Connection Control Functions | https://www.postgresql.org/docs/current/libpq-connect.html |
| 2 | psycopg2 — Thread and process safety | https://www.psycopg.org/docs/usage.html |
| 3 | psycopg2 — FAQ (transaction-aborted fingerprint) | https://www.psycopg.org/docs/faq.html |
| 4 | psycopg3 — Concurrent operations (fork warning) | https://www.psycopg.org/psycopg3/docs/advanced/async.html |
| 5 | SQLAlchemy — Pools with Multiprocessing or os.fork() | https://docs.sqlalchemy.org/en/20/core/pooling.html |
| 6 | CPython — os.fork / os.register_at_fork |
https://docs.python.org/3/library/os.html#os.fork |
| 7 | CPython — multiprocessing contexts and start methods | https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods |
| 8 | CPython — ssl Multi-processing (OpenSSL PRNG) |
https://docs.python.org/3/library/ssl.html |
| 9 | gh-100228 — warn on fork with multiple threads (3.12) | https://github.com/python/cpython/issues/100228 |
| 10 | discuss.python.org t/33555 — fork vs threads rationale | https://discuss.python.org/t/33555 |
| 11 | gh-84559 — default start method fork → forkserver (3.14) | https://github.com/python/cpython/issues/84559 |
| 12 | gh-85789 — Guido: "Fork is dangerous" | https://github.com/python/cpython/issues/85789 |
| 13 | gh-66285 / gh-66197 — asyncio fork + self-pipe | https://github.com/python/cpython/issues/66285 |
| 14 | gh-99539 — asyncio fork handler (3.12) | https://github.com/python/cpython/pull/99539 |
| 15 | CPython source — Lib/asyncio/events.py on_fork() |
https://github.com/python/cpython/blob/main/Lib/asyncio/events.py |
Labeled speculation (not doc-supported, flagged as such): that forkserver + preload will be
fast enough for the plugin's warm-up budget is an assumption — the docs claim performance parity as
the rationale for the 3.14 default, but the actual saving depends on how much of the warm state is
imports (inheritable) vs. connections (not). Measure it. Also unverified: whether the specific test
suite's plugins spawn threads at import time — check with threading.active_count() after preload.
FIXED 2026-07-16: getfixturevalue during "teardown"¶
Fixed by _borrowed_item_active — the borrowed item is pushed onto the setup stack
for the duration of the hoist, so is_node_active(request.node) is true and the
deprecation path is never taken. It comes straight back off, because SetupState
asserts every stack entry is in the next item's parent chain.
It was worse than "a future deadline", which is how this was first written up. Under
filterwarnings = error the warning is an exception, so it hit
setup_session_fixtures' except branch: every hoist recorded as failed, every fixture
left to the workers, container booted once per worker exactly like xdist — on a green
run. Anyone with that setting had the feature silently inert. Measured before the fix:
4 boots with filterwarnings=error, 1 without. Now 1 in both, on pytest 8.1/8.4/9.0/9.1.
Regression test: TestHoistingUnderStrictWarnings. It asserts the boot count, not the
warning, so it stays honest whatever pytest renames things to.
The original analysis¶
session_scope.setup_session_fixtures hoists fixtures by borrowing a real item's request
and calling request.getfixturevalue(name) from pytest_runtestloop — before any test has
run. pytest reads that state as teardown, because the item was never pushed onto the
setup stack, and since 9.x it warns:
PytestRemovedIn10Warning: Calling request.getfixturevalue("postgres_server") during
teardown is deprecated.
It still works today only by a technicality. From _pytest/fixtures.py:
if not self.session._setupstate.is_node_active(self.node):
# TODO(pytest10.1): Remove the `warn` and `if` and call
# _raise_teardown_lookup_error unconditionally.
warnings.warn(FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN.format(argname=argname), ...)
if subrequest.node not in self.session._setupstate.stack:
self._raise_teardown_lookup_error(argname)
self.node is session.items[0], which is not active — so the warning fires. The raise
is skipped only because subrequest.node for a session-scoped fixture is the Session, and
_activate_session_on_setup_stack put the Session on the stack. In 10.1 the if goes
away and the error is raised unconditionally, which takes the whole hoisting feature with
it — the one thing this project is for.
The shape of the fix is to make the borrowed item genuinely active rather than to make the
Session merely present, so is_node_active(item) is true while the hoisted fixtures are
built. Worth doing before pytest 10.1, not after.