[{"data":1,"prerenderedAt":830},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fsharding-a-test-suite-across-ci-runners\u002F":3},{"id":4,"title":5,"body":6,"description":793,"extension":794,"meta":795,"navigation":93,"path":826,"seo":827,"stem":828,"__hash__":829},"content\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fsharding-a-test-suite-across-ci-runners\u002Findex.md","Sharding a Test Suite Across CI Runners",{"type":7,"value":8,"toc":782},"minimark",[9,13,25,30,58,62,65,222,268,424,428,446,453,457,494,498,501,504,511,590,594,597,600,684,687,691,694,701,708,715,719,728,734,740,744,773,778],[10,11,12],"p",{},"A test suite that takes forty minutes on one CI runner takes ten on four, provided the four agree on which tests each of them runs. That agreement is the whole problem. Split carelessly and tests fall between shards and never run, or run twice, or one shard gets all the slow integration tests and finishes twenty minutes after the others. Split deterministically and by duration, and the suite's wall-clock time drops almost linearly with the number of runners.",[10,14,15,16,20,21,24],{},"Sharding complements ",[17,18,19],"code",{},"pytest-xdist"," rather than replacing it. xdist uses the cores of one machine; sharding uses more machines. A suite that has already saturated ",[17,22,23],{},"-n auto"," on the largest available runner gets its next improvement from sharding, and each shard keeps using xdist internally.",[26,27,29],"h2",{"id":28},"prerequisites","Prerequisites",[31,32,33,40,51],"ul",{},[34,35,36,39],"li",{},[17,37,38],{},"pytest >= 8.0",", and a CI system that can run a job matrix with an index and total.",[34,41,42,44,45,50],{},[17,43,19],{}," for parallelism within each shard — see ",[46,47,49],"a",{"href":48},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fpytest-xdist-vs-pytest-parallel-performance-comparison\u002F","pytest-xdist vs pytest-parallel",".",[34,52,53,54,57],{},"Durations from a previous run, for balancing: ",[17,55,56],{},"pytest --durations=0"," or the JUnit XML report.",[26,59,61],{"id":60},"solution","Solution",[10,63,64],{},"A small collection hook assigns each test to a shard by a stable hash of its node id, so every job computes the same assignment independently.",[66,67,72],"pre",{"className":68,"code":69,"language":70,"meta":71,"style":71},"language-python shiki shiki-themes github-light github-dark","# conftest.py\nimport hashlib\n\n\ndef pytest_addoption(parser):\n    parser.addoption(\"--shard-id\", type=int, default=0)\n    parser.addoption(\"--num-shards\", type=int, default=1)\n\n\ndef _shard_of(nodeid: str, num_shards: int) -> int:\n    # Stable across processes and machines, unlike hash(), which is salted.\n    digest = hashlib.sha1(nodeid.encode()).digest()\n    return int.from_bytes(digest[:4], \"big\") % num_shards\n\n\ndef pytest_collection_modifyitems(config, items):\n    num = config.getoption(\"--num-shards\")\n    if num \u003C= 1:\n        return\n    mine = config.getoption(\"--shard-id\")\n    keep, drop = [], []\n    for item in items:\n        (keep if _shard_of(item.nodeid, num) == mine else drop).append(item)\n    items[:] = keep\n    config.hook.pytest_deselected(items=drop)   # reported as deselected, not lost\n","python","",[17,73,74,82,88,95,100,106,112,118,123,128,134,140,146,152,157,162,168,174,180,186,192,198,204,210,216],{"__ignoreMap":71},[75,76,79],"span",{"class":77,"line":78},"line",1,[75,80,81],{},"# conftest.py\n",[75,83,85],{"class":77,"line":84},2,[75,86,87],{},"import hashlib\n",[75,89,91],{"class":77,"line":90},3,[75,92,94],{"emptyLinePlaceholder":93},true,"\n",[75,96,98],{"class":77,"line":97},4,[75,99,94],{"emptyLinePlaceholder":93},[75,101,103],{"class":77,"line":102},5,[75,104,105],{},"def pytest_addoption(parser):\n",[75,107,109],{"class":77,"line":108},6,[75,110,111],{},"    parser.addoption(\"--shard-id\", type=int, default=0)\n",[75,113,115],{"class":77,"line":114},7,[75,116,117],{},"    parser.addoption(\"--num-shards\", type=int, default=1)\n",[75,119,121],{"class":77,"line":120},8,[75,122,94],{"emptyLinePlaceholder":93},[75,124,126],{"class":77,"line":125},9,[75,127,94],{"emptyLinePlaceholder":93},[75,129,131],{"class":77,"line":130},10,[75,132,133],{},"def _shard_of(nodeid: str, num_shards: int) -> int:\n",[75,135,137],{"class":77,"line":136},11,[75,138,139],{},"    # Stable across processes and machines, unlike hash(), which is salted.\n",[75,141,143],{"class":77,"line":142},12,[75,144,145],{},"    digest = hashlib.sha1(nodeid.encode()).digest()\n",[75,147,149],{"class":77,"line":148},13,[75,150,151],{},"    return int.from_bytes(digest[:4], \"big\") % num_shards\n",[75,153,155],{"class":77,"line":154},14,[75,156,94],{"emptyLinePlaceholder":93},[75,158,160],{"class":77,"line":159},15,[75,161,94],{"emptyLinePlaceholder":93},[75,163,165],{"class":77,"line":164},16,[75,166,167],{},"def pytest_collection_modifyitems(config, items):\n",[75,169,171],{"class":77,"line":170},17,[75,172,173],{},"    num = config.getoption(\"--num-shards\")\n",[75,175,177],{"class":77,"line":176},18,[75,178,179],{},"    if num \u003C= 1:\n",[75,181,183],{"class":77,"line":182},19,[75,184,185],{},"        return\n",[75,187,189],{"class":77,"line":188},20,[75,190,191],{},"    mine = config.getoption(\"--shard-id\")\n",[75,193,195],{"class":77,"line":194},21,[75,196,197],{},"    keep, drop = [], []\n",[75,199,201],{"class":77,"line":200},22,[75,202,203],{},"    for item in items:\n",[75,205,207],{"class":77,"line":206},23,[75,208,209],{},"        (keep if _shard_of(item.nodeid, num) == mine else drop).append(item)\n",[75,211,213],{"class":77,"line":212},24,[75,214,215],{},"    items[:] = keep\n",[75,217,219],{"class":77,"line":218},25,[75,220,221],{},"    config.hook.pytest_deselected(items=drop)   # reported as deselected, not lost\n",[66,223,227],{"className":224,"code":225,"language":226,"meta":71,"style":71},"language-bash shiki shiki-themes github-light github-dark","# In the CI matrix: four jobs, each with its own index.\npytest -n auto --num-shards 4 --shard-id \"$SHARD_INDEX\"\n","bash",[17,228,229,235],{"__ignoreMap":71},[75,230,231],{"class":77,"line":78},[75,232,234],{"class":233},"sJ8bj","# In the CI matrix: four jobs, each with its own index.\n",[75,236,237,241,245,249,252,255,258,261,265],{"class":77,"line":84},[75,238,240],{"class":239},"sScJk","pytest",[75,242,244],{"class":243},"sj4cs"," -n",[75,246,248],{"class":247},"sZZnC"," auto",[75,250,251],{"class":243}," --num-shards",[75,253,254],{"class":243}," 4",[75,256,257],{"class":243}," --shard-id",[75,259,260],{"class":247}," \"",[75,262,264],{"class":263},"sVt8B","$SHARD_INDEX",[75,266,267],{"class":247},"\"\n",[269,270,273,420],"figure",{"className":271},[272],"diagram",[274,275,282,283,282,287,282,291,282,309,282,317,282,326,282,335,282,341,282,345,282,351,282,358,282,362,282,366,282,373,282,377,282,381,282,385,282,394,282,400,282,403,282,407,282,410,282,414,282,416],"svg",{"viewBox":276,"role":277,"ariaLabelledBy":278,"xmlns":281},"0 0 820 262","img",[279,280],"sh-t","sh-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[284,285,286],"title",{"id":279},"Deterministic sharding by node id",[288,289,290],"desc",{"id":280},"Every CI job collects the full suite, hashes each node id, and keeps only the tests whose hash modulo the shard count equals its own index. Because the hash is stable, all four jobs agree on the assignment without communicating, every test lands in exactly one shard, and each shard then runs its subset with xdist.",[292,293,294,295,282],"defs",{},"\n    ",[296,297,304],"marker",{"id":298,"viewBox":299,"refX":300,"refY":301,"markerWidth":302,"markerHeight":302,"orient":303},"sh-a","0 0 10 10","9","5","7","auto-start-reverse",[305,306],"path",{"d":307,"fill":308},"M0 0 L10 5 L0 10 z","#3d405b",[310,311],"rect",{"x":312,"y":312,"width":313,"height":314,"rx":315,"fill":316},"0","820","262","14","#fffdf8",[318,319,325],"text",{"x":320,"y":321,"textAnchor":322,"fontSize":323,"fontWeight":324,"fill":308},"410","28","middle","16","700","Four jobs, one rule, no coordination needed",[310,327],{"x":328,"y":329,"width":330,"height":331,"rx":332,"fill":333,"stroke":308,"strokeWidth":334},"26","96","190","72","11","#f4f1de","1.6",[318,336,340],{"x":337,"y":338,"textAnchor":322,"fontSize":339,"fontWeight":324,"fill":308},"121","124","12","full collection",[318,342,344],{"x":337,"y":343,"textAnchor":322,"fontSize":332,"fill":308},"146","in every job",[77,346],{"x1":347,"y1":348,"x2":349,"y2":348,"stroke":308,"strokeWidth":334,"markerEnd":350},"220","132","254","url(#sh-a)",[310,352],{"x":353,"y":329,"width":354,"height":331,"rx":332,"fill":355,"stroke":356,"strokeWidth":357},"260","200","#f7f0da","#f2cc8f","2",[318,359,361],{"x":360,"y":338,"textAnchor":322,"fontSize":339,"fontWeight":324,"fill":308},"360","sha1(nodeid) % 4",[318,363,365],{"x":360,"y":343,"textAnchor":322,"fontSize":332,"fill":364},"#8a5a00","stable everywhere",[77,367],{"x1":368,"y1":369,"x2":370,"y2":371,"stroke":308,"strokeWidth":372,"markerEnd":350},"464","116","530","62","1.5",[77,374],{"x1":368,"y1":375,"x2":370,"y2":376,"stroke":308,"strokeWidth":372,"markerEnd":350},"126","112",[77,378],{"x1":368,"y1":379,"x2":370,"y2":380,"stroke":308,"strokeWidth":372,"markerEnd":350},"138","160",[77,382],{"x1":368,"y1":383,"x2":370,"y2":384,"stroke":308,"strokeWidth":372,"markerEnd":350},"148","208",[310,386],{"x":387,"y":388,"width":389,"height":390,"rx":300,"fill":391,"stroke":392,"strokeWidth":393},"536","44","258","38","#e6f0ea","#81b29a","1.8",[318,395,399],{"x":396,"y":397,"textAnchor":322,"fontSize":398,"fill":308},"665","68","11.5","shard 0 · -n auto",[310,401],{"x":387,"y":402,"width":389,"height":390,"rx":300,"fill":391,"stroke":392,"strokeWidth":393},"94",[318,404,406],{"x":396,"y":405,"textAnchor":322,"fontSize":398,"fill":308},"118","shard 1 · -n auto",[310,408],{"x":387,"y":409,"width":389,"height":390,"rx":300,"fill":391,"stroke":392,"strokeWidth":393},"142",[318,411,413],{"x":396,"y":412,"textAnchor":322,"fontSize":398,"fill":308},"166","shard 2 · -n auto",[310,415],{"x":387,"y":330,"width":389,"height":390,"rx":300,"fill":391,"stroke":392,"strokeWidth":393},[318,417,419],{"x":396,"y":418,"textAnchor":322,"fontSize":398,"fill":308},"214","shard 3 · -n auto",[421,422,423],"figcaption",{},"No job needs to know what the others are doing. The node id and the shard count are enough for each to compute its own subset.",[26,425,427],{"id":426},"why-this-works","Why this works",[10,429,430,431,434,435,438,439,441,442,445],{},"Every job collects the same suite, so every job sees the same node ids. A stable hash of the node id modulo the shard count assigns each test to exactly one shard, and because the computation is deterministic, all jobs agree without any shared state. ",[17,432,433],{},"hashlib"," rather than Python's built-in ",[17,436,437],{},"hash()"," matters here: ",[17,440,437],{}," of a string is salted per process unless ",[17,443,444],{},"PYTHONHASHSEED"," is fixed, so two jobs would compute different assignments.",[10,447,448,449,452],{},"Reporting the dropped items through ",[17,450,451],{},"pytest_deselected"," rather than silently removing them keeps the summary honest — each shard reports how many tests it deselected — which is what makes the union check below possible.",[26,454,456],{"id":455},"edge-cases-and-failure-modes","Edge cases and failure modes",[31,458,459,470,476,482,488],{},[34,460,461,467,468,50],{},[462,463,464,465,50],"strong",{},"Using ",[17,466,437],{}," Salted per process, so shards disagree and tests run twice or not at all. Use ",[17,469,433],{},[34,471,472,475],{},[462,473,474],{},"Sharding by position in the collected list."," Adding one test shifts every later test to a different shard, which is harmless for correctness but defeats duration balancing and caching. Shard by node id.",[34,477,478,481],{},[462,479,480],{},"Session fixtures in every shard."," Each shard pays for its own expensive session setup. That is usually fine; if it dominates, fewer, larger shards may be faster.",[34,483,484,487],{},[462,485,486],{},"Tests that depend on running together."," A test relying on state from another in the same module can break when they land in different shards. That dependency is a bug; fix it rather than sharding by module.",[34,489,490,493],{},[462,491,492],{},"Uneven shards."," Hash-based sharding balances counts, not durations. Balance by duration once the suite has a few slow outliers.",[26,495,497],{"id":496},"balancing-shards-by-duration","Balancing shards by duration",[10,499,500],{},"Hash-based sharding gives each shard roughly the same number of tests, which is not the same as the same amount of work. One shard that happens to receive the three slowest integration tests becomes the pipeline's bottleneck, and adding runners stops helping.",[10,502,503],{},"Duration balancing fixes it with a simple greedy algorithm: sort tests by recorded duration, longest first, and assign each to whichever shard currently has the least total time. The durations come from the previous run's JUnit report or a small JSON file committed to the repository and refreshed periodically. Tests with no recorded duration — new ones — get the median, which is a reasonable guess that corrects itself after one run.",[10,505,506,507,510],{},"The result is shards that finish within a few seconds of each other, which is the property that actually matters, because the pipeline waits for the slowest shard. Plugins such as ",[17,508,509],{},"pytest-split"," implement exactly this with a stored durations file, and are worth adopting rather than reimplementing once a suite needs duration balancing; the hash-based hook above remains the right starting point because it needs no stored state at all.",[269,512,514,587],{"className":513},[272],[274,515,282,520,282,523,282,526,282,530,282,535,282,541,282,548,282,552,282,556,282,558,282,563,282,567,282,571,282,575,282,579,282,583],{"viewBox":516,"role":277,"ariaLabelledBy":517,"xmlns":281},"0 0 800 244",[518,519],"bal-t","bal-d",[284,521,522],{"id":518},"Count-balanced versus duration-balanced shards",[288,524,525],{"id":519},"Two sets of four shard bars. Count-balanced shards hold equal numbers of tests but one shard contains the slow integration tests and runs far longer than the others, setting the pipeline duration. Duration-balanced shards hold different numbers of tests but finish at nearly the same time.",[310,527],{"x":312,"y":312,"width":528,"height":529,"rx":315,"fill":316},"800","244",[318,531,534],{"x":532,"y":321,"textAnchor":322,"fontSize":533,"fontWeight":324,"fill":308},"400","15.5","The pipeline waits for the slowest shard",[318,536,540],{"x":537,"y":538,"fontSize":339,"fontWeight":324,"fill":539},"34","66","#8f3d22","by count",[310,542],{"x":543,"y":544,"width":545,"height":546,"rx":547,"fill":392},"140","52","180","18","4",[310,549],{"x":543,"y":550,"width":551,"height":546,"rx":547,"fill":392},"74","170",[310,553],{"x":543,"y":329,"width":554,"height":546,"rx":547,"fill":555},"560","#e07a5f",[310,557],{"x":543,"y":405,"width":330,"height":546,"rx":547,"fill":392},[318,559,562],{"x":560,"y":561,"fontSize":332,"fill":539},"710","110","bottleneck",[318,564,566],{"x":537,"y":412,"fontSize":339,"fontWeight":324,"fill":565},"#2a5f49","by duration",[310,568],{"x":543,"y":569,"width":570,"height":546,"rx":547,"fill":392},"152","300",[310,572],{"x":543,"y":573,"width":574,"height":546,"rx":547,"fill":392},"174","290",[310,576],{"x":543,"y":577,"width":578,"height":546,"rx":547,"fill":392},"196","310",[310,580],{"x":543,"y":581,"width":582,"height":546,"rx":547,"fill":392},"218","295",[318,584,586],{"x":585,"y":354,"fontSize":332,"fill":565},"470","all finish together",[421,588,589],{},"Same total work, very different wall-clock time. Duration balancing is what makes the fourth runner worth paying for.",[26,591,593],{"id":592},"choosing-the-number-of-shards","Choosing the number of shards",[10,595,596],{},"More shards is not always faster, because each shard pays fixed costs the others also pay: checking out the repository, installing dependencies, starting containers, collecting the suite. Once those fixed costs are a large fraction of each shard's runtime, adding another runner saves little and costs a whole runner.",[10,598,599],{},"A workable starting estimate divides the suite's serial duration by the target wall-clock time and adds the fixed overhead back in. A forty-minute suite with three minutes of per-job setup, aiming for ten minutes, needs roughly five shards: forty minutes of tests over five shards is eight each, plus three of setup, makes eleven. Six shards would reach about nine and a half, and the seventh would save barely thirty seconds.",[269,601,603,681],{"className":602},[272],[274,604,282,609,282,612,282,615,282,618,282,621,282,626,282,631,282,636,282,639,282,643,282,648,282,650,282,654,282,658,282,660,282,664,282,668,282,670,282,673,282,678],{"viewBox":605,"role":277,"ariaLabelledBy":606,"xmlns":281},"0 0 800 236",[607,608],"nsh-t","nsh-d",[284,610,611],{"id":607},"Diminishing returns as shards are added",[288,613,614],{"id":608},"Wall-clock time for a forty-minute suite with three minutes of fixed setup per shard. One shard takes forty-three minutes, two take twenty-three, four take thirteen, five take eleven and eight take eight, showing each additional runner saving less time while costing the same.",[310,616],{"x":312,"y":312,"width":528,"height":617,"rx":315,"fill":316},"236",[318,619,620],{"x":532,"y":321,"textAnchor":322,"fontSize":533,"fontWeight":324,"fill":308},"Fixed setup cost caps what extra shards can save",[77,622],{"x1":623,"y1":577,"x2":624,"y2":577,"stroke":308,"strokeWidth":625},"80","760","1.3",[310,627],{"x":628,"y":629,"width":630,"height":543,"fill":555},"100","56","70",[318,632,635],{"x":633,"y":634,"textAnchor":322,"fontSize":332,"fill":308},"135","50","43 min",[318,637,638],{"x":633,"y":418,"textAnchor":322,"fontSize":332,"fill":308},"1",[310,640],{"x":641,"y":337,"width":630,"height":642,"fill":356},"230","75",[318,644,647],{"x":645,"y":646,"textAnchor":322,"fontSize":332,"fill":308},"265","115","23 min",[318,649,357],{"x":645,"y":418,"textAnchor":322,"fontSize":332,"fill":308},[310,651],{"x":360,"y":652,"width":630,"height":653,"fill":392},"154","42",[318,655,657],{"x":656,"y":383,"textAnchor":322,"fontSize":332,"fill":308},"395","13 min",[318,659,547],{"x":656,"y":418,"textAnchor":322,"fontSize":332,"fill":308},[310,661],{"x":662,"y":380,"width":630,"height":663,"fill":392},"490","36",[318,665,667],{"x":666,"y":652,"textAnchor":322,"fontSize":332,"fill":308},"525","11 min",[318,669,301],{"x":666,"y":418,"textAnchor":322,"fontSize":332,"fill":308},[310,671],{"x":672,"y":551,"width":630,"height":328,"fill":392},"620",[318,674,677],{"x":675,"y":676,"textAnchor":322,"fontSize":332,"fill":308},"655","164","8 min",[318,679,680],{"x":675,"y":418,"textAnchor":322,"fontSize":332,"fill":308},"8 shards",[421,682,683],{},"Going from four shards to eight doubles the runner cost to save five minutes. Reducing the three-minute setup would save more, on every shard at once.",[10,685,686],{},"That last observation usually points to the better investment. Once shards are dominated by setup, caching dependencies, pre-pulling container images and cutting collection time help every shard simultaneously, and they make the next round of sharding worthwhile again. Measuring the setup phase of a single shard job — dependency install, image pulls, collection — before deciding on a shard count is the step most teams skip, and it is usually where the largest single saving is hiding. Only once setup is lean does the shard count become the right lever to pull. Then add runners one at a time and stop when the saving drops below a minute.",[26,688,690],{"id":689},"proving-every-test-ran-exactly-once","Proving every test ran exactly once",[10,692,693],{},"Sharding introduces a failure mode that ordinary test runs cannot have: a test that silently never executes because no shard claimed it. A bug in the assignment, a shard job that failed to start, a matrix entry removed by accident — each leaves the pipeline green and a slice of the suite untested. The defence is a small verification step that runs after the shards and checks their union.",[10,695,696,697,700],{},"Each shard writes the node ids it actually ran — JUnit XML already contains them — and a final job collects the full suite once with ",[17,698,699],{},"--collect-only -q"," and compares. Any id in the full collection missing from every shard is an unexecuted test; any id present in two shards is a duplicate. Either one fails the pipeline, which is exactly what should happen, because a green build that skipped part of the suite is worse than a red one.",[10,702,703,704,707],{},"The check costs one collection and a set comparison — seconds — and it converts the most dangerous property of sharding from something you trust into something you verify. It also catches the configuration drift that happens over time: someone raises the shard count in one place but not the other, or a new directory is excluded from one job's ",[17,705,706],{},"testpaths"," and not the others. Without the check, those mistakes are found when a bug ships through the unexecuted tests; with it, they are found the day they are made.",[10,709,710,711,714],{},"Treat the shard count as configuration that lives in exactly one place — the CI matrix definition — and pass it to pytest from there. Duplicating it into a script or a ",[17,712,713],{},"conftest.py"," default is how the counts drift apart in the first place.",[26,716,718],{"id":717},"frequently-asked-questions","Frequently Asked Questions",[10,720,721,724,725,727],{},[462,722,723],{},"What is the difference between sharding and pytest-xdist?","\nxdist parallelises within one machine using worker processes; sharding parallelises across machines by giving each CI job a subset of the tests. They compose: each shard can itself run with ",[17,726,23],{},". Sharding is what helps once a single machine's cores are saturated.",[10,729,730,733],{},[462,731,732],{},"How do I guarantee no test is skipped or run twice?","\nAssign shards deterministically from the node id — a stable hash modulo the shard count — so every test maps to exactly one shard regardless of collection order. Then verify in CI that the union of shards equals the full collection.",[10,735,736,739],{},[462,737,738],{},"Why are some shards much slower than others?","\nBecause splitting by count ignores duration. A few slow integration tests landing in one shard make it the bottleneck. Balance by recorded durations instead, assigning tests greedily to the currently lightest shard.",[26,741,743],{"id":742},"related","Related",[31,745,746,753,759,766],{},[34,747,748,752],{},[46,749,751],{"href":750},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002F","Optimizing Test Discovery"," — cutting collection time, which every shard pays.",[34,754,755,758],{},[46,756,757],{"href":48},"pytest-xdist vs pytest-parallel Performance"," — parallelism inside each shard.",[34,760,761,765],{},[46,762,764],{"href":763},"\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fproducing-junit-xml-reports-for-ci-dashboards\u002F","Producing JUnit XML Reports for CI Dashboards"," — merging per-shard reports and recording durations.",[34,767,768,772],{},[46,769,771],{"href":770},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002Fcombining-coverage-across-a-python-version-matrix\u002F","Combining Coverage Across a Python Version Matrix"," — merging per-shard coverage data.",[10,774,775,776],{},"← Back to ",[46,777,751],{"href":750},[779,780,781],"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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":71,"searchDepth":84,"depth":84,"links":783},[784,785,786,787,788,789,790,791,792],{"id":28,"depth":84,"text":29},{"id":60,"depth":84,"text":61},{"id":426,"depth":84,"text":427},{"id":455,"depth":84,"text":456},{"id":496,"depth":84,"text":497},{"id":592,"depth":84,"text":593},{"id":689,"depth":84,"text":690},{"id":717,"depth":84,"text":718},{"id":742,"depth":84,"text":743},"Split a pytest suite across parallel CI jobs: deterministic sharding by node id, duration-balanced shards, combining with xdist, and keeping every test in exactly one shard.","md",{"slug":796,"type":797,"breadcrumb":798,"datePublished":799,"dateModified":799,"faq":800,"howto":807},"sharding-a-test-suite-across-ci-runners","article","CI Sharding","2026-09-18",[801,803,805],{"q":723,"a":802},"xdist parallelises within one machine using worker processes; sharding parallelises across machines by giving each CI job a subset of the tests. They compose: each shard can itself run with -n auto. Sharding is what helps once a single machine's cores are saturated.",{"q":732,"a":804},"Assign shards deterministically from the node id — a stable hash modulo the shard count — so every test maps to exactly one shard regardless of collection order. Then verify in CI that the union of shards equals the full collection.",{"q":738,"a":806},"Because splitting by count ignores duration. A few slow integration tests landing in one shard make it the bottleneck. Balance by recorded durations instead, assigning tests greedily to the currently lightest shard.",{"name":808,"description":809,"steps":810},"How to shard a pytest suite across CI jobs","Assign tests to shards deterministically, balance by duration, verify coverage of the full suite, and combine with xdist inside each shard.",[811,814,817,820,823],{"name":812,"text":813},"Choose a shard count from the pipeline budget","Divide the suite's serial duration by the target wall-clock time to get a starting shard count.",{"name":815,"text":816},"Assign shards deterministically","Map each node id to a shard with a stable hash so every run agrees and no test is lost.",{"name":818,"text":819},"Balance by duration","Use recorded durations to assign tests greedily to the lightest shard so shards finish together.",{"name":821,"text":822},"Verify the union","Check that the shards' collected tests together equal the full collection, with no overlap.",{"name":824,"text":825},"Parallelise within each shard","Run each shard with pytest-xdist to use every core on its runner.","\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fsharding-a-test-suite-across-ci-runners",{"title":5,"description":793},"advanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fsharding-a-test-suite-across-ci-runners\u002Findex","XNfdw5GwdITFWwayCuk6cxyyMWifUdY9M8Fp7PCT9uM",1789718768736]