The suite passes, the coverage step prints TOTAL 0%, and the only change was adding -n 8. Nothing about the tests moved — the measurement did. Under pytest-xdist each worker is a separate operating-system process with its own interpreter and its own trace function, so coverage is no longer one measurement but N, and something has to put them back together.
Prerequisites
- pytest 7.0+, pytest-cov 4.1+, coverage 7.3+, pytest-xdist 3.3+.
- The measurement model described in coverage measurement and enforcement.
Solution
Three settings, applied together, make parallel coverage correct. Each fixes a distinct failure.
# pyproject.toml
[tool.coverage.run]
branch = true
source = ["src/myapp"]
parallel = true # 1. per-process data files, suffixed by pid
concurrency = ["multiprocessing"] # 2. trace code that runs in spawned children
data_file = "build/.coverage" # 3. one known location, outside the repo root
[tool.pytest.ini_options]
addopts = "--cov --cov-report=term-missing --cov-report=xml"
$ pytest -n 8 -q # pytest-cov starts inside each worker
The single most important change is the last line: pytest --cov, never coverage run -m pytest. pytest-cov implements the xdist hooks that start measurement inside each worker process and collect the data back to the controller at session end. coverage run wraps only the controller, which spawns workers that do no measuring at all — hence exactly zero.
parallel = true makes each process write build/.coverage.hostname.pid.random instead of all of them writing build/.coverage. Without it the processes race, the last writer wins, and the report reflects one worker's view — which looks like a plausible but wrong percentage rather than a zero.
concurrency = ["multiprocessing"] extends the same treatment to processes your tests spawn themselves. It is unrelated to xdist workers, but the two are almost always needed together in suites that shell out.
Why this works
coverage.py records data in the process where its trace function is installed, and writes it to a file when that process exits. parallel = true makes those filenames unique per process, and coverage combine merges the arc data from all of them into a single dataset. pytest-cov automates both halves: it registers an xdist hook that starts a Coverage instance in each worker before tests begin, and a session-finish hook in the controller that combines whatever the workers wrote. Removing either half produces a report — it is just a report of a dataset nobody populated.
Diagnosing which half is broken
The reported number tells you which stage failed, and one command confirms it.
$ coverage debug data
-- data ------------------------------------------------------
path: /srv/app/build/.coverage
has_arcs: True
8 files:
gw0: 1122 lines
gw1: 1098 lines
...
Eight files for eight workers means both halves worked. One file means the workers never measured — the run was wrapped by coverage run, or --cov was missing. No such file means the data file path differs between the workers and the report step, which happens when the working directory changes or data_file is relative and a worker runs from elsewhere. Eight files but a low percentage means the combine happened before some workers finished writing, which in practice means a custom CI step is running coverage report in parallel with the pytest process rather than after it.
# The three symptoms, and the check that distinguishes them
$ pytest -n 8 --cov -q && coverage debug data | head -3
Run that once when setting parallel coverage up, and again whenever the number moves without a corresponding change in the tests.
Coverage in CI with parallel jobs
A second layer of the same problem appears when CI shards the suite across machines rather than across processes. Each job produces its own combined data file, and the final report needs one more combine — this time across artefacts rather than across processes.
# .github/workflows/test.yml (excerpt)
jobs:
test:
strategy:
matrix: { shard: [1, 2, 3, 4] }
steps:
- run: pytest -n 4 --cov --cov-report= --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
with: { name: cov-${{ matrix.shard }}, path: build/.coverage.* }
report:
needs: test
steps:
- uses: actions/download-artifact@v4
- run: |
coverage combine build/.coverage.* # merge every shard's files
coverage report --show-missing
coverage xml
Two details matter. --cov-report= with an empty value suppresses per-shard reporting, which is meaningless in isolation and misleading in logs. And the artefacts must preserve the per-process suffixes: uploading a file renamed to .coverage from each shard makes the four artefacts collide on download, which reintroduces the original bug at a higher level.
The same shape applies to any CI system — the requirement is only that the raw data files, not the rendered reports, are what travel between jobs.
Keeping the parallel number trustworthy
Once the combine works, the remaining risk is that it quietly stops working again. Coverage configuration is exactly the kind of setting nobody reads until the number looks wrong, so three guards are worth adding while the fix is fresh.
Assert the data-file count in CI. The check is two lines and it fails loudly at the point of regression:
$ pytest -n 8 --cov -q
$ python - <<'EOF'
import sqlite3, glob, sys
files = glob.glob("build/.coverage.*")
print(f"{len(files)} data file(s)")
sys.exit(0 if len(files) >= 8 else 1) # one per worker, at minimum
EOF
Pin the plugin versions. pytest-cov and pytest-xdist coordinate through hooks whose names have changed across majors, and an unpinned upgrade in a CI image is the classic cause of a coverage number that halves overnight with no code change. Pin both in the lockfile and upgrade them deliberately, together.
Compare serial and parallel once per release. Running the suite with and without -n should produce the same percentage within rounding; a persistent gap means some tests are not running in the parallel configuration at all, which is a bigger problem than the coverage report. That comparison takes one extra CI job on a schedule, and it is the only check that catches a worker crashing silently mid-run.
A last operational note: keep the raw data files as CI artefacts for a few days. When someone asks why coverage moved between two commits, having both datasets makes the answer a coverage combine and a diff rather than an archaeology exercise.
Edge cases and failure modes
--covwith no argument measures everything importable. Combined with xdist that produces a large, slow, meaningless report; always setsourcein the configuration.- A stale data file from a previous run inflates the result.
coverage erase(or a clean build directory) before the run makes each measurement independent. -p no:cacheproviderbreaks nothing, but a read-only filesystem does. Workers must be able to write their data files; a container with a read-only working directory fails silently at exit.--dist loadgroupdoes not change the coverage story. Distribution mode affects which worker runs which test, never how measurement is combined.- Subprocess coverage needs
COVERAGE_PROCESS_STARTin addition to everything above, because a spawned interpreter starts with no trace function at all. - Reporting inside the pytest run and again in CI double-counts nothing but wastes time. Pick one place to render the report; the data file is the artefact that matters.
One related surprise: coverage under -n 0 is not the same code path as coverage with no -n at all. -n 0 still loads xdist and runs tests in the controller process, which exercises the plugin's serial path; omitting the flag skips xdist entirely. When debugging a combine problem, compare against a run with the flag removed rather than set to zero, so the comparison isolates the parallel machinery.
Frequently Asked Questions
Why does coverage report 0% only when I add -n?
Because each xdist worker is a separate process writing its own coverage data file. Unless pytest-cov owns the run and combines those files, the report is generated from a data file that no worker wrote to, which reads as zero.
Can I run coverage run -m pytest with xdist?
Not reliably. That form starts coverage in the controller process only, and the workers inherit nothing. Use pytest --cov so the plugin can start measurement inside each worker and combine the results at the end.
Does parallel = true alone fix it?
No. parallel = true makes each process write a uniquely suffixed data file, which prevents them overwriting each other, but something still has to combine them. pytest-cov does that automatically; a manual coverage run does not.
Why is coverage lower rather than zero under xdist?
Usually a partial combine: some data files were written after the combine ran, or a subprocess wrote a file the combine never saw. Check the file count with coverage debug data before assuming the tests themselves changed.
Related
- Coverage measurement and enforcement — branch coverage, contexts and what the number is worth.
- Enforcing diff coverage on pull requests — what to do with the combined report once it is correct.
- pytest-xdist vs pytest-parallel performance comparison — the worker model behind the per-process data files.
- Cutting collection time with norecursedirs — the other per-worker cost that multiplies with
-n.
← Back to Coverage Measurement and Enforcement