pytest-threadlanes
Run pytest-xdist's own schedulers on thread lanes: in one process, or across many xdist processes. Long, I/O-bound tests can then run thousands-wide without paying 150–500 MB of memory per concurrent test.
pytest -n 8 # plain xdist: 8 processes (untouched by this plugin)
pytest --lanes 200 # 1 process x 200 lanes
pytest -n 8 --lanes 25 # 8 xdist processes x 25 lanes each = 200 lanes
A lane behaves like an xdist worker. Scheduling decisions come from xdist's real scheduler, fixtures of every scope are cached per lane, and reporters see the same hook stream they would see under -n. For the same suite, report-log output is identical in all three modes, and the contract tests enforce that.
Contents:
Quick start
uv pip install -e ".[test]" # or: pip install -e ".[test]"
pytest -p no:warnings --lanes 8 # Python 3.13 or earlier
pytest -X context_aware_warnings=1 --lanes 8 # Python 3.14 (3.14t: on by default)
Required flags, which the plugin checks and refuses to run without:
| Condition | Pass |
|---|---|
| Python 3.13 or earlier | -p no:warnings, because warnings.catch_warnings is not thread-safe there |
| Python 3.14 or later | -X context_aware_warnings=1 or PYTHON_CONTEXT_AWARE_WARNINGS=1. It is the default on free-threaded 3.14t |
| pytest 8.3.5 or earlier | -p no:threadexception -p no:unraisableexception, because those versions swap global hooks around every test |
Supported: Python 3.12 and later, pytest 8.x–9.x and pytest-xdist 3.6–3.x (pyproject.toml). Tested on CPython 3.12, 3.13, 3.14 and 3.14t, with pytest 8.0.2 / 8.3.5 / 8.4.2 / 9.1.1 and pytest-xdist 3.6.1 / 3.7.0 / 3.8.0; other versions in range are untested, and a changed internal makes the plugin refuse to start rather than run incorrectly.
Using it
Scheduling
Scheduling works exactly as in xdist, and one scheduler definition serves all three modes. The recommended one for environments:
# conftest.py
from xdist.scheduler import LoadScopeScheduling
class EnvScheduling(LoadScopeScheduling):
def _split_scope(self, nodeid): # 'test_x.py::test_step[envB-2]' -> 'envB'
return nodeid.rsplit("[", 1)[1].split("-", 1)[0]
def _reschedule(self, node):
# Queue the next environment only behind the lane's last test, not its last two.
if node.shutting_down or not self.workqueue or self._pending_of(self.assigned_work[node]) <= 1:
super()._reschedule(node)
def pytest_xdist_make_scheduler(config, log):
return EnvScheduling(config, log)
Tests in one scope run sequentially, in order, on one lane; different scopes run in parallel.
Why _reschedule: xdist's loadscope gives a lane (or worker) its next environment as soon as its current one is down to two tests, so that environment waits behind both while other lanes may sit idle. With hour-long tests that is hours. The override waits until one test is left. It cannot wait for zero: a lane, like an xdist worker, starts its last queued test only once it knows what comes next (to decide which fixtures to tear down), so at zero it would never finish. The override behaves the same under plain -n. It uses loadscope's private _reschedule, _pending_of and assigned_work; tests/test_contract.py runs it verbatim in all three modes, so a change in xdist shows up in scripts/matrix.sh.
Without a custom scheduler, choose a built-in one:
- Single-process mode:
--distas with xdist, or--lanes-dist load|loadscope|loadfile|loadgroup(defaultload). - Hybrid mode: xdist's own
--dist.
each and worksteal are not supported yet, and are refused at startup. Note that xdist's worksteal moves single tests between workers, so it would split an environment's steps across lanes; for environments only a steal of whole, not-yet-started scopes would be safe (backlog 7).
Options
| Option / ini / marker | Meaning |
|---|---|
--lanes N |
N lanes in this process. In hybrid mode, N lanes per xdist process, so the total is -n × --lanes. --lanes 0 turns lanes off |
--lanes-dist MODE |
Built-in scheduler for single-process mode, when no pytest_xdist_make_scheduler returns one. Defaults to xdist's --dist if given, else load |
@pytest.mark.lanes_exclusive |
Run this test alone within its process. Doctests always are, since doctest swaps sys.stdout for the whole process |
ini lanes_exclusive_fixtures |
Fixtures that make a test exclusive. Default: capsys, capsysbinary, capfd, capfdbinary, capteesys, recwarn, and pytest-cov's no_cover (it pauses coverage for the whole process; the no_cover marker makes a test exclusive too). Requesting one at run time (request.getfixturevalue) from a test that is not exclusive fails that test with instructions |
-s / --capture=no |
As under xdist: test output goes straight to the terminal. Log records are still captured per test |
--lanes-allow-patches, ini lanes_allow_patches, @pytest.mark.lanes_allow_patches |
Turn off the patch guard for the run, or for one test. The guard fails a test that is not lanes_exclusive when it patches process-wide state through mock.patch/pytest-mock/monkeypatch (a module or class attribute, a dotted path, the environment, chdir, sys.path); patches of instances are allowed. Direct environment writes (os.environ[k] = v) and os.chdir are guarded too: besides being seen by every lane, an environment write while another lane starts a subprocess makes that spawn fail. Set values the whole run needs in pytest_configure, not in a session-scoped fixture: each lane tears its own fixture down when it finishes |
ini lanes_interrupt_grace |
Seconds to wait after Ctrl-C for the interrupted lanes to run their teardown (default 30). A second Ctrl-C stops waiting |
Each lane is its own xdist worker: worker_id, testrun_uid, xdist.get_xdist_worker_id(request) and config.workerinput["workerid"] give the lane, such as ln3 or gw2.ln3, so resources named after the worker do not collide. os.environ["PYTEST_XDIST_WORKER"] (and _COUNT) read on a lane name the lane too; a subprocess sees them only if you pass env=os.environ.copy(). Each test report carries report.lane_id. In single-process mode report.node is the lane, just as it is the worker under xdist, and every lane goes through xdist's controller hooks (pytest_configure_node, pytest_testnodeready, pytest_xdist_node_collection_finished, pytest_testnodedown), so plugins built on them, and on node.workerinput/node.workeroutput, work under --lanes alone as under -n.
What you must know before pointing it at a real suite
Lanes are threads, so anything process-global is shared between concurrently running tests. That includes mock.patch, monkeypatching shared modules, os.environ, chdir, signals, logging levels, random.seed, socket.setdefaulttimeout and locale.setlocale. Mark such tests lanes_exclusive, or fix them; pytest --lanes-detect (below) finds them. Handled for you:
- A
mock.patch, pytest-mock ormonkeypatchpatch of shared state in a test that is notlanes_exclusivefails the test and says what to do (the patch guard; see--lanes-allow-patches). contextlib.redirect_stdout/redirect_stderrredirect only the lane that entered them.- Replacing
sys.stdoutdirectly, as click'sCliRunnerdoes, cannot be made per lane: the run fails and names the tests. Mark themlanes_exclusive. - A test reading stdin fails at once, as under pytest's capture.
PYTEST_CURRENT_TESTis per lane:os.environ["PYTEST_CURRENT_TEST"]names the test running on that lane. It is not written to the process environment (that broke other lanes' subprocess spawns), so a subprocess sees it only if you passenv=os.environ.copy().- Ctrl-C interrupts the running tests and runs their teardown (see
lanes_interrupt_grace). It lands in the tests' own code: a lane that is inside the standard library, pytest or xdist finishes its current test first.
Other limits:
- A hung thread cannot be killed. pytest-timeout is refused in single-process mode (on a timeout it would end the whole process) but works in hybrid mode, where xdist replaces the worker.
faulthandler_timeoutis refused in both modes. - A crash takes down every lane in its process: every test in flight there is reported as crashed, as xdist reports the one test of a crashed worker. Under a loadscope-based scheduler each of them is then run again, from the step that was running, as xdist does for its crashed test. Choose
-nfor the blast radius you accept (see DESIGN.md → Sizing). - Output from threads your tests start is attributed to the test only on Python 3.14 with
-X thread_inherit_context=1. - Before Python 3.14,
pytest.warns,pytest.deprecated_callandrecwarnchange process-wide warning state, so a test using them must belanes_exclusive. Otherwise it fails and says so.warnings.catch_warningsused directly is not guarded. --pdbis unsupported, as it is under xdist, and so is--tracein single-process mode; abreakpoint()on a lane cannot read the terminal either. To debug a test, run it without--lanes(and without-n): the same scheduler, fixtures and code, in plain pytest.
The full list, with workarounds, is in DESIGN.md → Limitations.
Finding shared state: --lanes-detect
Before running a suite on lanes, find the tests that change process-wide state:
pytest --lanes-detect --lanes-detect-report=shared-state.json # sequential; no --lanes, no -n
Tests run one at a time, as in plain pytest. Around each test the detector snapshots process state (environment variables, cwd, sys.path, logging levels, signal handlers) and, recursively, everything reachable from your modules' globals, their classes' attributes, and every registered plugin object (including plugins held inside other plugins). It also records every mock.patch/patch.dict/pytest-mock patch, monkeypatch call, os.environ write and os.chdir made inside a test body. A session, module or class fixture's changes are reported under the fixture's name, not the test that ran it. A value set and restored inside one test body (for example by a context manager) is not seen, unless it goes through mock or monkeypatch. The report sorts each changed path:
| Kind | Meaning | Action |
|---|---|---|
UNSAFE per-test |
Changes while tests run: a "current test" field, a global set by a fixture | Make it per lane (a contextvar), or mark the tests lanes_exclusive |
UNSAFE patched |
Patched inside a test | Mark the test lanes_exclusive |
CHECK grows |
A container that grows with every test | Check it is thread-safe |
OK set-once |
Set once, then stable: a cache, lazy initialisation | Nothing, if it is safe to share between threads |
| ini | Meaning |
|---|---|
lanes_detect_ignore |
Paths shared on purpose (fnmatch patterns, one per line), e.g. module:myinfra.clients._CACHE |
lanes_detect_modules |
Installed packages to inspect as well (by default: modules under the rootdir) |
lanes_detect_max_depth, lanes_detect_max_nodes |
Walk limits (12 levels, 200,000 values per snapshot) |
The walk is read-only: it never evaluates properties or __getattr__, and keeps no string values (only their length and hash).
How it works
The idea
xdist splits a run into a controller, which owns the scheduler and the reporters, and workers, which run tests. They talk over a small protocol. pytest-threadlanes keeps that protocol and swaps what sits at each end:
pytest --lanes 3 pytest -n 2 --lanes 3
┌────────────── one process ──────────────┐ ┌──────── controller (xdist DSession) ────────┐
│ main thread = controller │ │ LaneMux wraps your scheduler: │
│ xdist scheduler, reporters │ │ DSession sees 2 workers, │
│ │ send_runtest_some ▲ reports │ │ scheduler sees 6 LaneProxy nodes │
│ ▼ │ (replayed) │ └───────┬─────────────────────────┬───────────┘
│ ln0 thread ln1 thread ln2 thread │ │ runtests ▼ ▲ reports │
│ (ThreadNode = one "worker") │ ┌───────▼────────┐ ┌───────▼────────┐
└─────────────────────────────────────────┘ │ gw0: 3 lanes │ │ gw1: 3 lanes │
└────────────────┘ └────────────────┘
Two problems have to be solved to run many pytest tests at once in one process:
- pytest keeps per-run state that assumes one test at a time. This covers
SetupState, fixture caches, capture, log handlers and a couple of races.isolation.pyandcapture.pyre-key each of these by the current lane, using a contextvar (LANE) that is set on each lane thread. - Reporters expect one thread and xdist's hook split. xdist forwards exactly four hooks from workers to the controller:
pytest_runtest_logstart,logreport,logfinishandwarning_recorded.hookrouting.pyintercepts those four on lanes, queues them, and the main thread replays them in order. Every other hook runs on the lane, as it would in a worker.
Doing this touches pytest, pluggy and xdist internals. Each one is a numbered touchpoint (P1–P15, C1–C2, X1–X4), is checked at startup by probes.py, and makes the plugin refuse to run if it has changed. That is the fail-closed rule. The list and the reasons are in DESIGN.md → Private touchpoints.
The life of one test (--lanes N)
| Step | Where | What happens |
|---|---|---|
| 1 | plugin.pytest_configure |
Chooses the mode, runs the startup probes, and registers SingleProcessSession |
| 2 | runner.LaneRunner.pytest_sessionstart |
isolate_lanes() installs the per-lane patches; ControllerHookRouter starts intercepting the 4 routed hooks |
| 3 | single.pytest_collection_modifyitems |
scheduling.make_scheduler builds the scheduler through xdist's own pytest_xdist_make_scheduler hook. Under loadgroup it adds xdist's @group nodeid suffix |
| 4 | single.pytest_runtestloop |
Splits out exclusive tests, creates N ThreadNodes, and does what xdist's DSession does: add_node, add_node_collection, schedule() |
| 5 | lane.ThreadNode.send_runtest_some |
The scheduler hands item indices to a lane, which puts them on the lane's queue |
| 6 | runner.LaneRunner._node_loop (lane thread) |
xdist's worker loop: it takes an item, looks one ahead to learn nextitem, and runs pytest_runtest_protocol under the exclusivity lock. Captured output becomes report sections (capture.capture_phase) |
| 7 | hookrouting → runner._pump (main thread) |
The routed hooks are queued as HookCalls and replayed to reporters. The lane then queues ItemDone and waits |
| 8 | runner._pump |
Calls scheduler.mark_test_complete, which may send more work; stops lanes on -x/--maxfail or when tests_finished; then releases the lane |
| 9 | single._run_serial |
Exclusive tests run alone on one extra lane, ln-serial |
| 10 | runner.LaneRunner.pytest_sessionfinish |
Undoes every patch, in reverse order |
Hybrid (-n P --lanes M) keeps real xdist processes, so crash detection and worker replacement are xdist's own:
- Controller (
controller.py):LanesControllerwraps whatever scheduler is returned in aLaneMux. The scheduler sees P×MLaneProxynodes, while xdist's DSession still sees P workers.LaneProxy.send_runtest_somebecomes alanes_runtestscommand to the owning process. - Worker (
worker.py):HybridWorkerSessiontakes over xdist's worker loop, feeds those commands to M lanes, and after each item sends xdist's normalruntest_protocol_completeevent. Reports travel through xdist's own serialization, so pytest-cov and pytest-metadata see an ordinary xdist run.
Finding your way around
src/pytest_threadlanes/
plugin.py entry point: options, mode selection (no logic)
lane.py ThreadNode (one lane) and the LANE contextvar
runner.py LaneRunner: lane threads, main-thread pump, capture hooks, exclusivity (P9)
single.py --lanes N
worker.py -n P --lanes M, worker process (X2)
controller.py -n P --lanes M, controller: LanesController, LaneMux (X3, X4)
scheduling.py building xdist's scheduler; loadgroup suffix (X1, P5)
isolation.py per-lane pytest state, worker identity and environment,
warns guard, patch guard (P1, P2, P6, P7, P10, P11, P12, P14)
capture.py per-lane stdout/stderr, redirects and logging (P3, P8, P13, P15)
hookrouting.py the 4 controller hooks, replayed on the main thread (P4)
compat.py shims for third-party plugins (pytest-rerunfailures) (C1, C2)
integrity.py run-time check: reports match what lanes ran, else INTERNALERROR
detector/ --lanes-detect, a separate debugging tool: walk, sources, recorder (D1),
classify, report, plugin
probes.py fail-closed startup checks
tests/ the spec: pytester subprocess tests (contract, parity against plain -n,
robustness, isolation, integrity, patch guard, detector)
scripts/matrix.sh contract suite across Python x pytest/xdist versions (uv)
demo/ manual smoke run (see demo/README.md)
| If you want to… | Look at |
|---|---|
| Understand why a test ran on a given lane | scheduling.make_scheduler; the decision itself is xdist's scheduler |
Change what a lane does per item, or how -x stops |
runner.LaneRunner._node_loop, _pump |
| Fix output or log lines attributed to the wrong test | capture.py |
| Fix fixture or teardown state leaking between lanes | isolation.py (P1 SetupState, P2 fixture caches) |
| Fix a reporter that sees wrong or out-of-order hooks | hookrouting.py |
| Understand an "integrity check failed" INTERNALERROR | integrity.py |
| Change hybrid-mode messaging or crash handling | controller.py (controller side), worker.py (process side) |
| Add support for a new pytest or xdist version | Run scripts/matrix.sh; a failing startup probe names the touchpoint, and the touchpoint's ID leads to its module |
| Touch a new pytest/xdist internal | Don't, unless unavoidable. Otherwise: a context manager in the owning module, a check in probes.py, a row in the touchpoint tables (CLAUDE.md, DESIGN.md), and a contract test |
Developing
uv venv -p 3.12 .venv && uv pip install -p .venv -e ".[test]"
.venv/bin/python -m pytest tests -q -p no:cacheprovider -p no:warnings -n 4 # ~300 tests, ~2 min
scripts/matrix.sh # 3.12 3.13 3.14 3.14t x 4 pytest/xdist combos (needs PyPI)
RUNS=20 scripts/matrix.sh 3.14t # repeat runs on one interpreter
Rules that keep it correct:
- The contract tests are the spec. Every fix starts with a contract test that fails without it.
- Run on at least two pytest versions before calling anything done. Free-threaded 3.14t is the best race detector.
- Contract tests use
runpytest_subprocess, never in-process pytester. In-process runs would share the patchedFixtureDefclass and the global hooks. - Never weaken the invariants. They are listed in CLAUDE.md: indistinguishable from an xdist worker, report parity, fail closed, xdist-observing plugins keep working, no new process-global state in the runner, and silent corruption made loud.
Releasing
The version lives only in pyproject.toml. Merging a new version into master releases it: .github/workflows/release.yml sees that pyproject.toml's version has no vX.Y.Z tag yet, and then
- build: builds the wheel and sdist, checks them with
twine check --strict, loads the plugin from the installed wheel, and takes the version'sCHANGELOG.mdsection as release notes (a missing section stops the release here); - test: runs the suite once (Python 3.12, pytest 9.1.1, xdist 3.8.0) against the built wheel;
- publish: uploads to PyPI with Trusted Publishing (no token is stored in GitHub);
- github-release: tags the merged commit
vX.Y.Zand creates the GitHub Release with the wheel, the sdist and the notes.
A merge that does not change the version stops after the first check. To release:
# on a branch: bump version in pyproject.toml, add a "## X.Y.Z" section to CHANGELOG.md
scripts/matrix.sh # the real gate: 3.12-3.14t x 4 pytest/xdist combos
# then merge the branch into master: the merge publishes X.Y.Z
A manual run of the workflow (Actions → release → Run workflow) builds and tests without publishing or tagging: a dry run.
One-time setup, before the first release:
- On pypi.org → Your account → Publishing → Add a new pending publisher: project
pytest-threadlanes, ownerHeknon, repositorypytest-threadlanes, workflowrelease.yml, environmentpypi. - In the GitHub repository → Settings → Environments, create
pypi. Adding yourself as a required reviewer there makes every publish wait for your approval.
Documents
| File | For |
|---|---|
| README.md | This overview: using the plugin, how it works, where things are |
| DESIGN.md | Rationale and evidence: touchpoints and why each exists, what was found and fixed, sizing processes × lanes, plugin compatibility, known limitations |
| LICENSE | MIT |
| CHANGELOG.md | What changed in each release |
| CLAUDE.md | Maintainer and agent brief: invariants, touchpoint table, verified status, prioritized backlog with acceptance criteria |
Release files for pytest-threadlanes 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pytest_threadlanes-0.1.0.tar.gz | 128.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pytest_threadlanes-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 208.1 kB
Release files / pytest_threadlanes-0.1.0.tar.gz
| Download URL | pytest_threadlanes-0.1.0.tar.gz |
|---|---|
| Size | 128.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2d79df8abd43eca6e0aff604a783c1111b47cab0ede914229ff2b542fec8684b
|
|
BLAKE2b-256 checksum How to use checksums |
6809acaedb426bc4e4baf7f4adddc49357a313dc8366cd5c0f32ea13b8bee105
|
| 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 25, 2026.
Transparency logRelease files / pytest_threadlanes-0.1.0-py3-none-any.whl
| Download URL | pytest_threadlanes-0.1.0-py3-none-any.whl |
|---|---|
| Size | 79.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c09d44157df70c4237e07b024a210dba58b0cb4bfda66975524dc10f4e25ec7f
|
|
BLAKE2b-256 checksum How to use checksums |
e2a0e8e572c041cba79fbc534a67e8ddc879ba45cacf0ec99194cb1e33e4b254
|
| 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 25, 2026.
Transparency log