Skip to main content

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: --dist as with xdist, or --lanes-dist load|loadscope|loadfile|loadgroup (default load).
  • 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 or monkeypatch patch of shared state in a test that is not lanes_exclusive fails the test and says what to do (the patch guard; see --lanes-allow-patches).
  • contextlib.redirect_stdout/redirect_stderr redirect only the lane that entered them.
  • Replacing sys.stdout directly, as click's CliRunner does, cannot be made per lane: the run fails and names the tests. Mark them lanes_exclusive.
  • A test reading stdin fails at once, as under pytest's capture.
  • PYTEST_CURRENT_TEST is 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 pass env=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_timeout is 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 -n for 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_call and recwarn change process-wide warning state, so a test using them must be lanes_exclusive. Otherwise it fails and says so. warnings.catch_warnings used directly is not guarded.
  • --pdb is unsupported, as it is under xdist, and so is --trace in single-process mode; a breakpoint() 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:

  1. 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.py and capture.py re-key each of these by the current lane, using a contextvar (LANE) that is set on each lane thread.
  2. Reporters expect one thread and xdist's hook split. xdist forwards exactly four hooks from workers to the controller: pytest_runtest_logstart, logreport, logfinish and warning_recorded. hookrouting.py intercepts 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): LanesController wraps whatever scheduler is returned in a LaneMux. The scheduler sees P×M LaneProxy nodes, while xdist's DSession still sees P workers. LaneProxy.send_runtest_some becomes a lanes_runtests command to the owning process.
  • Worker (worker.py): HybridWorkerSession takes over xdist's worker loop, feeds those commands to M lanes, and after each item sends xdist's normal runtest_protocol_complete event. 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 patched FixtureDef class 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

  1. build: builds the wheel and sdist, checks them with twine check --strict, loads the plugin from the installed wheel, and takes the version's CHANGELOG.md section as release notes (a missing section stops the release here);
  2. test: runs the suite once (Python 3.12, pytest 9.1.1, xdist 3.8.0) against the built wheel;
  3. publish: uploads to PyPI with Trusted Publishing (no token is stored in GitHub);
  4. github-release: tags the merged commit vX.Y.Z and 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, owner Heknon, repository pytest-threadlanes, workflow release.yml, environment pypi.
  • 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)

Source distribution for pytest-threadlanes 0.1.0
File Size Uploaded
pytest_threadlanes-0.1.0.tar.gz 128.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pytest-threadlanes 0.1.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

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