[{"data":1,"prerenderedAt":1192},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fwhy-pytest-cov-reports-zero-under-xdist\u002F":3},{"id":4,"title":5,"body":6,"description":1156,"extension":1157,"meta":1158,"navigation":125,"path":1188,"seo":1189,"stem":1190,"__hash__":1191},"content\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fwhy-pytest-cov-reports-zero-under-xdist\u002Findex.md","Why pytest-cov Reports 0% Under xdist",{"type":7,"value":8,"toc":1145},"minimark",[9,26,31,60,64,67,139,150,169,183,189,357,361,381,385,388,433,463,478,481,582,586,589,796,807,810,814,817,823,863,875,885,891,895,951,964,1053,1057,1066,1075,1090,1100,1104,1135,1141],[10,11,12,13,17,18,21,22,25],"p",{},"The suite passes, the coverage step prints ",[14,15,16],"code",{},"TOTAL 0%",", and the only change was adding ",[14,19,20],{},"-n 8",". Nothing about the tests moved — the measurement did. Under ",[14,23,24],{},"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.",[27,28,30],"h2",{"id":29},"prerequisites","Prerequisites",[32,33,34,52],"ul",{},[35,36,37,41,42,41,45,41,48,51],"li",{},[38,39,40],"strong",{},"pytest 7.0+",", ",[38,43,44],{},"pytest-cov 4.1+",[38,46,47],{},"coverage 7.3+",[38,49,50],{},"pytest-xdist 3.3+",".",[35,53,54,55,51],{},"The measurement model described in ",[56,57,59],"a",{"href":58},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002F","coverage measurement and enforcement",[27,61,63],{"id":62},"solution","Solution",[10,65,66],{},"Three settings, applied together, make parallel coverage correct. Each fixes a distinct failure.",[68,69,74],"pre",{"className":70,"code":71,"language":72,"meta":73,"style":73},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.coverage.run]\nbranch = true\nsource = [\"src\u002Fmyapp\"]\nparallel = true                      # 1. per-process data files, suffixed by pid\nconcurrency = [\"multiprocessing\"]    # 2. trace code that runs in spawned children\ndata_file = \"build\u002F.coverage\"        # 3. one known location, outside the repo root\n\n[tool.pytest.ini_options]\naddopts = \"--cov --cov-report=term-missing --cov-report=xml\"\n","toml","",[14,75,76,84,90,96,102,108,114,120,127,133],{"__ignoreMap":73},[77,78,81],"span",{"class":79,"line":80},"line",1,[77,82,83],{},"# pyproject.toml\n",[77,85,87],{"class":79,"line":86},2,[77,88,89],{},"[tool.coverage.run]\n",[77,91,93],{"class":79,"line":92},3,[77,94,95],{},"branch = true\n",[77,97,99],{"class":79,"line":98},4,[77,100,101],{},"source = [\"src\u002Fmyapp\"]\n",[77,103,105],{"class":79,"line":104},5,[77,106,107],{},"parallel = true                      # 1. per-process data files, suffixed by pid\n",[77,109,111],{"class":79,"line":110},6,[77,112,113],{},"concurrency = [\"multiprocessing\"]    # 2. trace code that runs in spawned children\n",[77,115,117],{"class":79,"line":116},7,[77,118,119],{},"data_file = \"build\u002F.coverage\"        # 3. one known location, outside the repo root\n",[77,121,123],{"class":79,"line":122},8,[77,124,126],{"emptyLinePlaceholder":125},true,"\n",[77,128,130],{"class":79,"line":129},9,[77,131,132],{},"[tool.pytest.ini_options]\n",[77,134,136],{"class":79,"line":135},10,[77,137,138],{},"addopts = \"--cov --cov-report=term-missing --cov-report=xml\"\n",[68,140,144],{"className":141,"code":142,"language":143,"meta":73,"style":73},"language-console shiki shiki-themes github-light github-dark","$ pytest -n 8 -q                     # pytest-cov starts inside each worker\n","console",[14,145,146],{"__ignoreMap":73},[77,147,148],{"class":79,"line":80},[77,149,142],{},[10,151,152,153,156,157,160,161,164,165,168],{},"The single most important change is the last line: ",[14,154,155],{},"pytest --cov",", never ",[14,158,159],{},"coverage run -m pytest",". ",[14,162,163],{},"pytest-cov"," implements the xdist hooks that start measurement inside each worker process and collect the data back to the controller at session end. ",[14,166,167],{},"coverage run"," wraps only the controller, which spawns workers that do no measuring at all — hence exactly zero.",[10,170,171,174,175,178,179,182],{},[14,172,173],{},"parallel = true"," makes each process write ",[14,176,177],{},"build\u002F.coverage.hostname.pid.random"," instead of all of them writing ",[14,180,181],{},"build\u002F.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.",[10,184,185,188],{},[14,186,187],{},"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.",[190,191,194,353],"figure",{"className":192},[193],"diagram",[195,196,203,204,203,208,203,212,203,242,203,250,203,258,203,267,203,273,203,279,203,282,203,285,203,288,203,291,203,295,203,298,203,304,203,310,203,314,203,320,203,325,203,330,203,333,203,337,203,344,203,348],"svg",{"viewBox":197,"role":198,"ariaLabelledBy":199,"xmlns":202},"0 0 760 430","img",[200,201],"xdistcov-t","xdistcov-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[205,206,207],"title",{"id":200},"Where measurement starts and where it is combined",[209,210,211],"desc",{"id":201},"A sequence diagram with three lanes: the pytest controller, a worker process, and the coverage data files. The controller starts workers, pytest-cov starts a coverage instance inside each worker, each worker writes its own suffixed data file at exit, and the controller combines the files into one report at session end.",[213,214,215,216,215,230,215,236,203],"defs",{},"\n    ",[217,218,225],"marker",{"id":219,"viewBox":220,"refX":221,"refY":222,"markerWidth":223,"markerHeight":223,"orient":224},"xdistcov-a","0 0 10 10","9","5","7","auto-start-reverse",[226,227],"path",{"d":228,"fill":229},"M0 0 L10 5 L0 10 z","#3d405b",[217,231,233],{"id":232,"viewBox":220,"refX":221,"refY":222,"markerWidth":223,"markerHeight":223,"orient":224},"xdistcov-c",[226,234],{"d":228,"fill":235},"#e07a5f",[217,237,239],{"id":238,"viewBox":220,"refX":221,"refY":222,"markerWidth":223,"markerHeight":223,"orient":224},"xdistcov-s",[226,240],{"d":228,"fill":241},"#81b29a",[243,244],"rect",{"x":245,"y":245,"width":246,"height":247,"rx":248,"fill":249},"0","760","430","14","#fffdf8",[251,252,207],"text",{"x":253,"y":254,"textAnchor":255,"fontSize":256,"fontWeight":257,"fill":229},"380","30","middle","15","700",[243,259],{"x":260,"y":261,"width":262,"height":263,"rx":264,"fill":265,"stroke":229,"strokeWidth":266},"34","52","220","40","10","#f4f1de","1.8",[251,268,272],{"x":269,"y":270,"textAnchor":255,"fontSize":271,"fontWeight":257,"fill":229},"144","76","12","controller",[79,274],{"x1":269,"y1":275,"x2":269,"y2":276,"stroke":229,"strokeWidth":277,"strokeDashArray":278},"98","414","1.2",[222,222],[243,280],{"x":281,"y":261,"width":262,"height":263,"rx":264,"fill":265,"stroke":229,"strokeWidth":266},"270",[251,283,284],{"x":253,"y":270,"textAnchor":255,"fontSize":271,"fontWeight":257,"fill":229},"worker gw0",[79,286],{"x1":253,"y1":275,"x2":253,"y2":276,"stroke":229,"strokeWidth":277,"strokeDashArray":287},[222,222],[243,289],{"x":290,"y":261,"width":262,"height":263,"rx":264,"fill":265,"stroke":229,"strokeWidth":266},"506",[251,292,294],{"x":293,"y":270,"textAnchor":255,"fontSize":271,"fontWeight":257,"fill":229},"616","data files",[79,296],{"x1":293,"y1":275,"x2":293,"y2":276,"stroke":229,"strokeWidth":277,"strokeDashArray":297},[222,222],[79,299],{"x1":300,"y1":301,"x2":302,"y2":301,"stroke":229,"strokeWidth":266,"markerEnd":303},"152","126","372","url(#xdistcov-a)",[251,305,309],{"x":306,"y":307,"textAnchor":255,"fontSize":308,"fill":229},"262","117","11","start worker",[226,311],{"d":312,"fill":313,"stroke":229,"strokeWidth":266,"markerEnd":303},"M380 180 h 46 v 22 h -40","none",[251,315,319],{"x":316,"y":317,"textAnchor":318,"fontSize":308,"fill":229},"436","184","start","pytest-cov starts tracing",[79,321],{"x1":322,"y1":323,"x2":324,"y2":323,"stroke":229,"strokeWidth":266,"markerEnd":303},"388","234","608",[251,326,329],{"x":327,"y":328,"textAnchor":255,"fontSize":308,"fill":229},"498","225","write .coverage.pid",[79,331],{"x1":300,"y1":332,"x2":324,"y2":332,"stroke":229,"strokeWidth":266,"markerEnd":303},"288",[251,334,336],{"x":253,"y":335,"textAnchor":255,"fontSize":308,"fill":229},"279","combine all files",[79,338],{"x1":324,"y1":339,"x2":300,"y2":339,"stroke":241,"strokeWidth":266,"strokeDashArray":340,"markerEnd":343},"342",[341,342],"6","4","url(#xdistcov-s)",[251,345,347],{"x":253,"y":346,"textAnchor":255,"fontSize":308,"fill":229},"333","one dataset to report",[251,349,352],{"x":253,"y":350,"textAnchor":255,"fontSize":308,"fontStyle":351,"fill":229},"418","italic","coverage run -m pytest wraps only the leftmost lane, which is why the result is zero.",[354,355,356],"figcaption",{},"The plugin has to be inside the worker for anything to be recorded, and inside the controller for anything to be combined.",[27,358,360],{"id":359},"why-this-works","Why this works",[10,362,363,366,367,369,370,373,374,376,377,380],{},[14,364,365],{},"coverage.py"," records data in the process where its trace function is installed, and writes it to a file when that process exits. ",[14,368,173],{}," makes those filenames unique per process, and ",[14,371,372],{},"coverage combine"," merges the arc data from all of them into a single dataset. ",[14,375,163],{}," automates both halves: it registers an xdist hook that starts a ",[14,378,379],{},"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.",[27,382,384],{"id":383},"diagnosing-which-half-is-broken","Diagnosing which half is broken",[10,386,387],{},"The reported number tells you which stage failed, and one command confirms it.",[68,389,391],{"className":141,"code":390,"language":143,"meta":73,"style":73},"$ coverage debug data\n-- data ------------------------------------------------------\npath: \u002Fsrv\u002Fapp\u002Fbuild\u002F.coverage\nhas_arcs: True\n8 files:\n  gw0: 1122 lines\n  gw1: 1098 lines\n  ...\n",[14,392,393,398,403,408,413,418,423,428],{"__ignoreMap":73},[77,394,395],{"class":79,"line":80},[77,396,397],{},"$ coverage debug data\n",[77,399,400],{"class":79,"line":86},[77,401,402],{},"-- data ------------------------------------------------------\n",[77,404,405],{"class":79,"line":92},[77,406,407],{},"path: \u002Fsrv\u002Fapp\u002Fbuild\u002F.coverage\n",[77,409,410],{"class":79,"line":98},[77,411,412],{},"has_arcs: True\n",[77,414,415],{"class":79,"line":104},[77,416,417],{},"8 files:\n",[77,419,420],{"class":79,"line":110},[77,421,422],{},"  gw0: 1122 lines\n",[77,424,425],{"class":79,"line":116},[77,426,427],{},"  gw1: 1098 lines\n",[77,429,430],{"class":79,"line":122},[77,431,432],{},"  ...\n",[10,434,435,436,439,440,442,443,446,447,450,451,454,455,458,459,462],{},"Eight files for eight workers means both halves worked. ",[38,437,438],{},"One file"," means the workers never measured — the run was wrapped by ",[14,441,167],{},", or ",[14,444,445],{},"--cov"," was missing. ",[38,448,449],{},"No such file"," means the data file path differs between the workers and the report step, which happens when the working directory changes or ",[14,452,453],{},"data_file"," is relative and a worker runs from elsewhere. ",[38,456,457],{},"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 ",[14,460,461],{},"coverage report"," in parallel with the pytest process rather than after it.",[68,464,466],{"className":141,"code":465,"language":143,"meta":73,"style":73},"# The three symptoms, and the check that distinguishes them\n$ pytest -n 8 --cov -q && coverage debug data | head -3\n",[14,467,468,473],{"__ignoreMap":73},[77,469,470],{"class":79,"line":80},[77,471,472],{},"# The three symptoms, and the check that distinguishes them\n",[77,474,475],{"class":79,"line":86},[77,476,477],{},"$ pytest -n 8 --cov -q && coverage debug data | head -3\n",[10,479,480],{},"Run that once when setting parallel coverage up, and again whenever the number moves without a corresponding change in the tests.",[190,482,484,579],{"className":483},[193],[195,485,203,490,203,493,203,496,203,498,203,500,203,506,203,510,203,514,203,518,203,522,203,527,203,530,203,533,203,536,203,540,203,543,203,546,203,549,203,553,203,556,203,559,203,562,203,566,203,569,203,572,203,576],{"viewBox":486,"role":198,"ariaLabelledBy":487,"xmlns":202},"0 0 760 270",[488,489],"xdistsym-t","xdistsym-d",[205,491,492],{"id":488},"Reading the symptom back to the cause",[209,494,495],{"id":489},"A table matching four coverage symptoms under xdist - exactly zero percent, a plausible but low number, a missing data file, and coverage that varies between runs - to the cause and the fix for each.",[243,497],{"x":245,"y":245,"width":246,"height":281,"rx":248,"fill":249},[251,499,492],{"x":253,"y":254,"textAnchor":255,"fontSize":256,"fontWeight":257,"fill":229},[243,501],{"x":502,"y":261,"width":503,"height":263,"rx":264,"fill":504,"stroke":229,"strokeWidth":505},"24","712","#f2cc8f","1.5",[251,507,509],{"x":508,"y":270,"fontSize":271,"fontWeight":257,"fill":229},"38","Criterion",[251,511,513],{"x":512,"y":270,"textAnchor":255,"fontSize":271,"fontWeight":257,"fill":229},"390","Cause",[251,515,517],{"x":516,"y":270,"textAnchor":255,"fontSize":271,"fontWeight":257,"fill":229},"620","Fix",[243,519],{"x":502,"y":520,"width":503,"height":263,"fill":265,"stroke":229,"strokeWidth":521},"92","1",[251,523,526],{"x":508,"y":524,"fontSize":525,"fill":229},"116","11.5","exactly 0%",[251,528,529],{"x":512,"y":524,"textAnchor":255,"fontSize":525,"fill":229},"workers never measured",[251,531,532],{"x":516,"y":524,"textAnchor":255,"fontSize":525,"fill":229},"use pytest --cov",[243,534],{"x":502,"y":535,"width":503,"height":263,"fill":249,"stroke":229,"strokeWidth":521},"132",[251,537,539],{"x":508,"y":538,"fontSize":525,"fill":229},"156","plausible but low",[251,541,542],{"x":512,"y":538,"textAnchor":255,"fontSize":525,"fill":229},"one worker reported",[251,544,545],{"x":516,"y":538,"textAnchor":255,"fontSize":525,"fill":229},"set parallel = true",[243,547],{"x":502,"y":548,"width":503,"height":263,"fill":265,"stroke":229,"strokeWidth":521},"172",[251,550,552],{"x":508,"y":551,"fontSize":525,"fill":229},"196","no data file",[251,554,555],{"x":512,"y":551,"textAnchor":255,"fontSize":525,"fill":229},"path differs per process",[251,557,558],{"x":516,"y":551,"textAnchor":255,"fontSize":525,"fill":229},"absolute data_file",[243,560],{"x":502,"y":561,"width":503,"height":263,"fill":249,"stroke":229,"strokeWidth":521},"212",[251,563,565],{"x":508,"y":564,"fontSize":525,"fill":229},"236","varies between runs",[251,567,568],{"x":512,"y":564,"textAnchor":255,"fontSize":525,"fill":229},"combine ran too early",[251,570,571],{"x":516,"y":564,"textAnchor":255,"fontSize":525,"fill":229},"report after pytest exits",[79,573],{"x1":574,"y1":261,"x2":574,"y2":575,"stroke":229,"strokeWidth":521},"274","252",[79,577],{"x1":578,"y1":261,"x2":578,"y2":575,"stroke":229,"strokeWidth":521},"505",[354,580,581],{},"The four symptoms map one-to-one onto the four configuration mistakes, so the number itself is the diagnostic.",[27,583,585],{"id":584},"coverage-in-ci-with-parallel-jobs","Coverage in CI with parallel jobs",[10,587,588],{},"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.",[68,590,594],{"className":591,"code":592,"language":593,"meta":73,"style":73},"language-yaml shiki shiki-themes github-light github-dark","# .github\u002Fworkflows\u002Ftest.yml (excerpt)\njobs:\n  test:\n    strategy:\n      matrix: { shard: [1, 2, 3, 4] }\n    steps:\n      - run: pytest -n 4 --cov --cov-report= --shard=${{ matrix.shard }}\u002F4\n      - uses: actions\u002Fupload-artifact@v4\n        with: { name: cov-${{ matrix.shard }}, path: build\u002F.coverage.* }\n  report:\n    needs: test\n    steps:\n      - uses: actions\u002Fdownload-artifact@v4\n      - run: |\n          coverage combine build\u002F.coverage.*      # merge every shard's files\n          coverage report --show-missing\n          coverage xml\n","yaml",[14,595,596,602,612,619,626,660,667,682,694,728,735,746,753,765,778,784,790],{"__ignoreMap":73},[77,597,598],{"class":79,"line":80},[77,599,601],{"class":600},"sJ8bj","# .github\u002Fworkflows\u002Ftest.yml (excerpt)\n",[77,603,604,608],{"class":79,"line":86},[77,605,607],{"class":606},"s9eBZ","jobs",[77,609,611],{"class":610},"sVt8B",":\n",[77,613,614,617],{"class":79,"line":92},[77,615,616],{"class":606},"  test",[77,618,611],{"class":610},[77,620,621,624],{"class":79,"line":98},[77,622,623],{"class":606},"    strategy",[77,625,611],{"class":610},[77,627,628,631,634,637,640,643,645,648,650,653,655,657],{"class":79,"line":104},[77,629,630],{"class":606},"      matrix",[77,632,633],{"class":610},": { ",[77,635,636],{"class":606},"shard",[77,638,639],{"class":610},": [",[77,641,521],{"class":642},"sj4cs",[77,644,41],{"class":610},[77,646,647],{"class":642},"2",[77,649,41],{"class":610},[77,651,652],{"class":642},"3",[77,654,41],{"class":610},[77,656,342],{"class":642},[77,658,659],{"class":610},"] }\n",[77,661,662,665],{"class":79,"line":110},[77,663,664],{"class":606},"    steps",[77,666,611],{"class":610},[77,668,669,672,675,678],{"class":79,"line":116},[77,670,671],{"class":610},"      - ",[77,673,674],{"class":606},"run",[77,676,677],{"class":610},": ",[77,679,681],{"class":680},"sZZnC","pytest -n 4 --cov --cov-report= --shard=${{ matrix.shard }}\u002F4\n",[77,683,684,686,689,691],{"class":79,"line":122},[77,685,671],{"class":610},[77,687,688],{"class":606},"uses",[77,690,677],{"class":610},[77,692,693],{"class":680},"actions\u002Fupload-artifact@v4\n",[77,695,696,699,701,704,706,709,712,715,718,720,722,725],{"class":79,"line":129},[77,697,698],{"class":606},"        with",[77,700,633],{"class":610},[77,702,703],{"class":606},"name",[77,705,677],{"class":610},[77,707,708],{"class":680},"cov-$",[77,710,711],{"class":610},"{{ ",[77,713,714],{"class":680},"matrix.shard",[77,716,717],{"class":610}," }}, ",[77,719,226],{"class":606},[77,721,677],{"class":610},[77,723,724],{"class":680},"build\u002F.coverage.*",[77,726,727],{"class":610}," }\n",[77,729,730,733],{"class":79,"line":135},[77,731,732],{"class":606},"  report",[77,734,611],{"class":610},[77,736,738,741,743],{"class":79,"line":737},11,[77,739,740],{"class":606},"    needs",[77,742,677],{"class":610},[77,744,745],{"class":680},"test\n",[77,747,749,751],{"class":79,"line":748},12,[77,750,664],{"class":606},[77,752,611],{"class":610},[77,754,756,758,760,762],{"class":79,"line":755},13,[77,757,671],{"class":610},[77,759,688],{"class":606},[77,761,677],{"class":610},[77,763,764],{"class":680},"actions\u002Fdownload-artifact@v4\n",[77,766,768,770,772,774],{"class":79,"line":767},14,[77,769,671],{"class":610},[77,771,674],{"class":606},[77,773,677],{"class":610},[77,775,777],{"class":776},"szBVR","|\n",[77,779,781],{"class":79,"line":780},15,[77,782,783],{"class":680},"          coverage combine build\u002F.coverage.*      # merge every shard's files\n",[77,785,787],{"class":79,"line":786},16,[77,788,789],{"class":680},"          coverage report --show-missing\n",[77,791,793],{"class":79,"line":792},17,[77,794,795],{"class":680},"          coverage xml\n",[10,797,798,799,802,803,806],{},"Two details matter. ",[14,800,801],{},"--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 ",[14,804,805],{},".coverage"," from each shard makes the four artefacts collide on download, which reintroduces the original bug at a higher level.",[10,808,809],{},"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.",[27,811,813],{"id":812},"keeping-the-parallel-number-trustworthy","Keeping the parallel number trustworthy",[10,815,816],{},"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.",[10,818,819,822],{},[38,820,821],{},"Assert the data-file count in CI."," The check is two lines and it fails loudly at the point of regression:",[68,824,826],{"className":141,"code":825,"language":143,"meta":73,"style":73},"$ pytest -n 8 --cov -q\n$ python - \u003C\u003C'EOF'\nimport sqlite3, glob, sys\nfiles = glob.glob(\"build\u002F.coverage.*\")\nprint(f\"{len(files)} data file(s)\")\nsys.exit(0 if len(files) >= 8 else 1)     # one per worker, at minimum\nEOF\n",[14,827,828,833,838,843,848,853,858],{"__ignoreMap":73},[77,829,830],{"class":79,"line":80},[77,831,832],{},"$ pytest -n 8 --cov -q\n",[77,834,835],{"class":79,"line":86},[77,836,837],{},"$ python - \u003C\u003C'EOF'\n",[77,839,840],{"class":79,"line":92},[77,841,842],{},"import sqlite3, glob, sys\n",[77,844,845],{"class":79,"line":98},[77,846,847],{},"files = glob.glob(\"build\u002F.coverage.*\")\n",[77,849,850],{"class":79,"line":104},[77,851,852],{},"print(f\"{len(files)} data file(s)\")\n",[77,854,855],{"class":79,"line":110},[77,856,857],{},"sys.exit(0 if len(files) >= 8 else 1)     # one per worker, at minimum\n",[77,859,860],{"class":79,"line":116},[77,861,862],{},"EOF\n",[10,864,865,868,869,871,872,874],{},[38,866,867],{},"Pin the plugin versions."," ",[14,870,163],{}," and ",[14,873,24],{}," 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.",[10,876,877,880,881,884],{},[38,878,879],{},"Compare serial and parallel once per release."," Running the suite with and without ",[14,882,883],{},"-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.",[10,886,887,888,890],{},"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 ",[14,889,372],{}," and a diff rather than an archaeology exercise.",[27,892,894],{"id":893},"edge-cases-and-failure-modes","Edge cases and failure modes",[32,896,897,909,918,927,936,945],{},[35,898,899,904,905,908],{},[38,900,901,903],{},[14,902,445],{}," with no argument measures everything importable."," Combined with xdist that produces a large, slow, meaningless report; always set ",[14,906,907],{},"source"," in the configuration.",[35,910,911,868,914,917],{},[38,912,913],{},"A stale data file from a previous run inflates the result.",[14,915,916],{},"coverage erase"," (or a clean build directory) before the run makes each measurement independent.",[35,919,920,926],{},[38,921,922,925],{},[14,923,924],{},"-p no:cacheprovider"," breaks 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.",[35,928,929,935],{},[38,930,931,934],{},[14,932,933],{},"--dist loadgroup"," does not change the coverage story."," Distribution mode affects which worker runs which test, never how measurement is combined.",[35,937,938,944],{},[38,939,940,941],{},"Subprocess coverage needs ",[14,942,943],{},"COVERAGE_PROCESS_START"," in addition to everything above, because a spawned interpreter starts with no trace function at all.",[35,946,947,950],{},[38,948,949],{},"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.",[10,952,953,954,957,958,960,961,963],{},"One related surprise: coverage under ",[14,955,956],{},"-n 0"," is not the same code path as coverage with no ",[14,959,883],{}," at all. ",[14,962,956],{}," 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.",[190,965,967,1050],{"className":966},[193],[195,968,203,973,203,976,203,979,203,986,203,989,203,991,203,996,203,1002,203,1006,203,1012,203,1015,203,1019,203,1022,203,1026,203,1029,203,1033,203,1036,203,1040,203,1043,203,1047],{"viewBox":969,"role":198,"ariaLabelledBy":970,"xmlns":202},"0 0 760 154",[971,972],"covdiag-t","covdiag-d",[205,974,975],{"id":971},"Narrowing a parallel coverage problem",[209,977,978],{"id":972},"A left-to-right diagnostic sequence: run serially to establish the true number, run with one worker to test the plugin path, run with eight workers to test the combine, and compare the data file counts at each step.",[213,980,215,981,203],{},[217,982,984],{"id":983,"viewBox":220,"refX":221,"refY":222,"markerWidth":223,"markerHeight":223,"orient":224},"covdiag-a",[226,985],{"d":228,"fill":504},[243,987],{"x":245,"y":245,"width":246,"height":988,"rx":248,"fill":249},"154",[251,990,975],{"x":253,"y":254,"textAnchor":255,"fontSize":256,"fontWeight":257,"fill":229},[243,992],{"x":993,"y":994,"width":995,"height":270,"rx":271,"fill":265,"stroke":504,"strokeWidth":266},"26","62","142",[251,997,1001],{"x":998,"y":999,"textAnchor":255,"fontSize":1000,"fontWeight":257,"fill":229},"97","96","12.5","serial run",[251,1003,1005],{"x":998,"y":1004,"textAnchor":255,"fontSize":308,"fill":229},"113","the true baseline",[79,1007],{"x1":1008,"y1":1009,"x2":1010,"y2":1009,"stroke":504,"strokeWidth":266,"markerEnd":1011},"176","100","208","url(#covdiag-a)",[243,1013],{"x":1014,"y":994,"width":995,"height":270,"rx":271,"fill":249,"stroke":504,"strokeWidth":266},"214",[251,1016,1018],{"x":1017,"y":999,"textAnchor":255,"fontSize":1000,"fontWeight":257,"fill":229},"286","one worker",[251,1020,1021],{"x":1017,"y":1004,"textAnchor":255,"fontSize":308,"fill":229},"plugin path only",[79,1023],{"x1":1024,"y1":1009,"x2":1025,"y2":1009,"stroke":504,"strokeWidth":266,"markerEnd":1011},"364","396",[243,1027],{"x":1028,"y":994,"width":995,"height":270,"rx":271,"fill":265,"stroke":504,"strokeWidth":266},"403",[251,1030,1032],{"x":1031,"y":999,"textAnchor":255,"fontSize":1000,"fontWeight":257,"fill":229},"474","eight workers",[251,1034,1035],{"x":1031,"y":1004,"textAnchor":255,"fontSize":308,"fill":229},"combine under load",[79,1037],{"x1":1038,"y1":1009,"x2":1039,"y2":1009,"stroke":504,"strokeWidth":266,"markerEnd":1011},"552","584",[243,1041],{"x":1042,"y":994,"width":995,"height":270,"rx":271,"fill":249,"stroke":504,"strokeWidth":266},"592",[251,1044,1046],{"x":1045,"y":999,"textAnchor":255,"fontSize":1000,"fontWeight":257,"fill":229},"663","compare counts",[251,1048,1049],{"x":1045,"y":1004,"textAnchor":255,"fontSize":308,"fill":229},"where it diverges",[354,1051,1052],{},"Each step adds one variable, so the step where the number changes names the broken stage.",[27,1054,1056],{"id":1055},"frequently-asked-questions","Frequently Asked Questions",[10,1058,1059,1062,1063,1065],{},[38,1060,1061],{},"Why does coverage report 0% only when I add -n?","\nBecause each xdist worker is a separate process writing its own coverage data file. Unless ",[14,1064,163],{}," owns the run and combines those files, the report is generated from a data file that no worker wrote to, which reads as zero.",[10,1067,1068,1071,1072,1074],{},[38,1069,1070],{},"Can I run coverage run -m pytest with xdist?","\nNot reliably. That form starts coverage in the controller process only, and the workers inherit nothing. Use ",[14,1073,155],{}," so the plugin can start measurement inside each worker and combine the results at the end.",[10,1076,1077,1080,1081,1083,1084,1086,1087,1089],{},[38,1078,1079],{},"Does parallel = true alone fix it?","\nNo. ",[14,1082,173],{}," makes each process write a uniquely suffixed data file, which prevents them overwriting each other, but something still has to combine them. ",[14,1085,163],{}," does that automatically; a manual ",[14,1088,167],{}," does not.",[10,1091,1092,1095,1096,1099],{},[38,1093,1094],{},"Why is coverage lower rather than zero under xdist?","\nUsually 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 ",[14,1097,1098],{},"coverage debug data"," before assuming the tests themselves changed.",[27,1101,1103],{"id":1102},"related","Related",[32,1105,1106,1112,1119,1126],{},[35,1107,1108,1111],{},[56,1109,1110],{"href":58},"Coverage measurement and enforcement"," — branch coverage, contexts and what the number is worth.",[35,1113,1114,1118],{},[56,1115,1117],{"href":1116},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fenforcing-diff-coverage-on-pull-requests\u002F","Enforcing diff coverage on pull requests"," — what to do with the combined report once it is correct.",[35,1120,1121,1125],{},[56,1122,1124],{"href":1123},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fpytest-xdist-vs-pytest-parallel-performance-comparison\u002F","pytest-xdist vs pytest-parallel performance comparison"," — the worker model behind the per-process data files.",[35,1127,1128,1132,1133,51],{},[56,1129,1131],{"href":1130},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fcutting-collection-time-with-norecursedirs\u002F","Cutting collection time with norecursedirs"," — the other per-worker cost that multiplies with ",[14,1134,883],{},[10,1136,1137,1138],{},"← Back to ",[56,1139,1140],{"href":58},"Coverage Measurement and Enforcement",[1142,1143,1144],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":73,"searchDepth":86,"depth":86,"links":1146},[1147,1148,1149,1150,1151,1152,1153,1154,1155],{"id":29,"depth":86,"text":30},{"id":62,"depth":86,"text":63},{"id":359,"depth":86,"text":360},{"id":383,"depth":86,"text":384},{"id":584,"depth":86,"text":585},{"id":812,"depth":86,"text":813},{"id":893,"depth":86,"text":894},{"id":1055,"depth":86,"text":1056},{"id":1102,"depth":86,"text":1103},"Fix pytest-cov reporting 0% or partial coverage under pytest-xdist: per-worker data files, the combine step, parallel mode, and the three configurations that break it.","md",{"slug":1159,"type":1160,"breadcrumb":1161,"datePublished":1162,"dateModified":1162,"faq":1163,"howto":1172},"why-pytest-cov-reports-zero-under-xdist","article","0% Under xdist","2026-08-01",[1164,1166,1168,1170],{"q":1061,"a":1165},"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.",{"q":1070,"a":1167},"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.",{"q":1079,"a":1169},"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.",{"q":1094,"a":1171},"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.",{"name":1173,"description":1174,"steps":1175},"How to fix pytest-cov reporting 0% under xdist","Restore accurate coverage under parallel execution by letting pytest-cov own measurement and combination.",[1176,1179,1182,1185],{"name":1177,"text":1178},"Stop starting coverage outside pytest","Replace coverage run -m pytest with pytest --cov so the plugin controls worker startup.",{"name":1180,"text":1181},"Enable parallel data files","Set parallel = true so each worker writes a uniquely named data file instead of overwriting one.",{"name":1183,"text":1184},"Point every process at the same config","Ensure workers and subprocesses read the same coverage configuration file.",{"name":1186,"text":1187},"Verify the combine","Run coverage debug data and confirm the number of combined files matches the number of workers.","\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fwhy-pytest-cov-reports-zero-under-xdist",{"title":5,"description":1156},"advanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fwhy-pytest-cov-reports-zero-under-xdist\u002Findex","iwrxNTJ1voGqAMmOW30D3eB7wwuTjvsZ-pLRSFJ84jU",1785613403108]