Skip to main content

pytest-zygote

Run pytest in parallel without paying for the imports N times.

pip install pytest-zygote
pytest --workers 4

Tested on Python 3.9-3.14 and pytest 7.0-9.1.

The problem

A pytest-xdist worker is a fresh interpreter. execnet's popen gateway starts each one as python -u -B -c ..., so every worker imports your whole dependency tree from scratch and keeps its own copy in memory. If importing your app costs 400 MB — a few ML or LLM libraries will do it — then -n 4 costs 2 GB before a single assertion runs, and almost none of it is shareable: it is private anonymous heap, not file-backed pages.

That is what puts a ceiling on parallelism in a container. You want more workers because your tests wait on the network; you can't have them because each one costs another 400 MB.

What this does

Collect in the parent, gc.freeze(), then fork() a worker per slot. Workers inherit the already-imported heap as copy-on-write pages, so the imports are paid once for the whole run.

Measured on a suite of 1089 tests whose imports cost ~430 MB (litellm, pydantic-ai, pandas, matplotlib), peak memory of the whole container:

wall peak memory
one process 28.3 s 510 MB
pytest -n 4 (xdist) 21.3 s 1945 MB
pytest --workers 4 16.1 s 810 MB
pytest --workers 8 14.7 s 1004 MB
pytest --workers 12 13.8 s 1104 MB

A worker costs ~46 MB here; an xdist worker costs ~490 MB. It is also faster than xdist, because a forked worker starts warm and skips the import that each xdist worker repeats.

The saving is real only because of gc.freeze(). CPython's generational collector writes to every object header when it runs, which dirties copy-on-write pages and un-shares them one collection at a time. gc.freeze() moves everything imported so far into a permanent generation the collector never walks. Without it a worker's private dirty memory here is ~195 MB instead of ~22 MB. The technique is Instagram's, upstreamed as gc.freeze() in Python 3.7.

Compared with pytest-xdist

The difference is entirely in how a worker is born.

pytest-xdist pytest-zygote
how a worker starts a new interpreter (python -u -B -c ... via execnet) fork() of the process that just collected
who imports your code every worker, separately the controller, once
who collects every worker repeats collection the controller only
memory per worker* ~490 MB ~46 MB
time before the first test each worker pays the full import workers start warm
distribution load, loadscope, loadfile, loadgroup, worksteal, each dynamic, per test, with a one-test lookahead
workers on other machines yes, over ssh or a socket no, this box only
Windows yes no — fork() only, so Linux and macOS
--looponfail yes no
a worker that dies --max-worker-restart replaced, and the test it killed is reported as failed
maturity since 2010, used everywhere new

* On the suite in the table above, whose imports cost ~430 MB. If your imports are cheap, both numbers collapse and the distinction stops mattering.

Use xdist when you need workers on other machines, a --dist mode that pins related tests to one worker, Windows, or simply when it already works for you. It is the mature, general answer and this plugin does not try to replace it.

Use zygote when your imports are expensive and memory is what caps your parallelism — a container with a hard limit, CI with a small runner, a laptop that swaps at -n 8. The worse your import cost, the bigger the gap. The suite above needs 1945 MB to run four xdist workers; it runs twelve forked workers in 1104 MB.

They are mutually exclusive in one run: passing --workers and -n together is an error, not a silent choice of one.

Options

--workers N run tests in N forked workers, or auto for one per core. Default 0, which leaves pytest alone
--workers-max-restarts N cap how many dead workers get replaced. Default: one per worker; 0 disables
--workers-no-freeze skip gc.freeze(). Diagnostic — it costs most of the saving

The worker count is capped at the number of collected tests. Each worker gets PYTEST_ZYGOTE_WORKER=gw<n> in its environment, like xdist's PYTEST_XDIST_WORKER.

When a worker dies

A worker killed mid-test — in a container, usually the memory cgroup — would otherwise take its test down silently, and with -q you would not even learn which one. The controller sees the socket close, reports that test as failed with the signal name, puts the worker's queued work back, and forks a replacement:

zygote worker gw0 killed by signal 9 (SIGKILL) while running this test.
In a container SIGKILL here is almost always the memory cgroup.

How reports get back

Workers run runtestprotocol(item, log=False) and stream their reports to the controller as length-prefixed JSON over a socketpair(). Only the controller calls pytest_runtest_logreport, so the terminal reporter, --junit-xml, coverage and any plugin of your own that listens to reports all see one ordered stream from one process. Nothing needs locking and nothing is written twice.

Each worker is also told the test after the one it is running. pytest finalizes a fixture when the next test no longer needs it, so without that lookahead every module- and class-scoped fixture would tear down after each test.

Limits

  • Needs os.fork() — Linux and macOS. Not Windows.
  • Mutually exclusive with xdist's -n. Pass one or the other.
  • --workers is also pytest-parallel's flag. That plugin was archived in May 2024; if you have both installed, argparse fails at startup and you have to drop one.
  • Scheduling is per test, so a module-scoped fixture is set up once per worker that draws a test from that module — the same trade xdist makes without --dist loadfile.
  • -x can overshoot by a test or two, since several are in flight when the first failure lands.
  • Your conftest must not open things at import time. A module-level DB connection, event loop or thread pool created during collection is inherited by every fork. Anything created inside a fixture is fine, because that runs in the worker.

macOS

CI runs the suite on macOS, but the numbers above and the day-to-day use are Linux. macOS treats forking a process that has already initialized certain Objective-C frameworks as unsafe; if you hit __NSPlaceholderDictionary initialize crashes, set OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES, the same workaround xdist users apply.

License

MIT.

Metadata

Release files for pytest-zygote 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pytest-zygote 0.1.0
File Size Uploaded
pytest_zygote-0.1.0.tar.gz 11.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pytest-zygote 0.1.0
File Interpreter ABI Platform
pytest_zygote-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 22.3 kB

Release files / pytest_zygote-0.1.0.tar.gz

Download URL pytest_zygote-0.1.0.tar.gz
Size 11.9 kB
Tags Source
SHA-256 checksum
How to use checksums
ed54a98890dc9c7cf991b8ed005ce087991409b4965c7d19dbb19faca7749a30
BLAKE2b-256 checksum
How to use checksums
4c407d07f1e4c71601cb6c833687be847340e84e08ae4fe680286430d59cee0b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release files / pytest_zygote-0.1.0-py3-none-any.whl

Download URL pytest_zygote-0.1.0-py3-none-any.whl
Size 10.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
693a7b732c0018e6cdbd31e69f5fd3f8ad5d35cd9a688aa33fe751d6bebd3097
BLAKE2b-256 checksum
How to use checksums
0f82ddeb220471fb30789a1a42444e0c20e01fb9793e64619d697f03b0c7f47f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page