[{"data":1,"prerenderedAt":939},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fcutting-collection-time-with-norecursedirs\u002F":3},{"id":4,"title":5,"body":6,"description":905,"extension":906,"meta":907,"navigation":154,"path":935,"seo":936,"stem":937,"__hash__":938},"content\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fcutting-collection-time-with-norecursedirs\u002Findex.md","Cutting pytest Collection Time with norecursedirs",{"type":7,"value":8,"toc":895},"minimark",[9,17,22,54,58,61,72,107,125,133,179,194,213,345,349,367,451,455,458,464,491,498,554,565,573,672,676,734,738,744,747,754,769,772,782,789,796,800,817,828,853,857,885,891],[10,11,12,16],"p",{},[13,14,15],"code",{},"pytest --collect-only -q"," takes eleven seconds on a repository with four thousand tests, and every invocation — including the one-test run you use while iterating — pays it. Collection cost is almost never about the number of tests. It is the directory walk plus the import of every module that walk finds, and both are prunable with configuration rather than code changes.",[18,19,21],"h2",{"id":20},"prerequisites","Prerequisites",[23,24,25,45],"ul",{},[26,27,28,32,33,36,37,40,41,44],"li",{},[29,30,31],"strong",{},"pytest 7.0+","; ",[13,34,35],{},"testpaths",", ",[13,38,39],{},"norecursedirs"," and ",[13,42,43],{},"collect_ignore"," behave as described in 7 and 8.",[26,46,47,48,53],{},"The discovery mechanics in ",[49,50,52],"a",{"href":51},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002F","optimizing test discovery",".",[18,55,57],{"id":56},"solution","Solution",[10,59,60],{},"Four controls prune at different stages. Apply them in this order, because each one shrinks the work the next has to do.",[10,62,63,68,69,71],{},[29,64,65,67],{},[13,66,35],{}," — never walk what is not a test directory."," With no path argument, pytest walks from rootdir, which in a monorepo means node_modules, terraform state, documentation and everything else. ",[13,70,35],{}," replaces that with an explicit list:",[73,74,79],"pre",{"className":75,"code":76,"language":77,"meta":78,"style":78},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.pytest.ini_options]\ntestpaths = [\"tests\", \"services\u002Fbilling\u002Ftests\", \"services\u002Fcatalog\u002Ftests\"]\nnorecursedirs = [\".git\", \".venv\", \"build\", \"dist\", \"node_modules\", \"*.egg-info\", \"__snapshots__\"]\n","toml","",[13,80,81,89,95,101],{"__ignoreMap":78},[82,83,86],"span",{"class":84,"line":85},"line",1,[82,87,88],{},"# pyproject.toml\n",[82,90,92],{"class":84,"line":91},2,[82,93,94],{},"[tool.pytest.ini_options]\n",[82,96,98],{"class":84,"line":97},3,[82,99,100],{},"testpaths = [\"tests\", \"services\u002Fbilling\u002Ftests\", \"services\u002Fcatalog\u002Ftests\"]\n",[82,102,104],{"class":84,"line":103},4,[82,105,106],{},"norecursedirs = [\".git\", \".venv\", \"build\", \"dist\", \"node_modules\", \"*.egg-info\", \"__snapshots__\"]\n",[10,108,109,114,115,36,118,40,121,124],{},[29,110,111,113],{},[13,112,39],{}," — prune inside the test paths."," The default already excludes ",[13,116,117],{},".*",[13,119,120],{},"build",[13,122,123],{},"dist","; the list above adds the ones a real repository accumulates. Pruning a directory means pytest never stats its contents, which matters most for directories with thousands of small files such as snapshot or fixture stores.",[10,126,127,132],{},[29,128,129,131],{},[13,130,43],{}," — skip conditionally, from Python."," When the decision depends on the environment — a test package that cannot even be imported without a native dependency — a conftest can prune it dynamically:",[73,134,138],{"className":135,"code":136,"language":137,"meta":78,"style":78},"language-python shiki shiki-themes github-light github-dark","# tests\u002Fconftest.py\nimport importlib.util\n\ncollect_ignore = []\nif importlib.util.find_spec(\"pyarrow\") is None:\n    # Skipping the import entirely, not just the tests: the module would fail to import.\n    collect_ignore.append(\"arrow_integration\")\n","python",[13,139,140,145,150,156,161,167,173],{"__ignoreMap":78},[82,141,142],{"class":84,"line":85},[82,143,144],{},"# tests\u002Fconftest.py\n",[82,146,147],{"class":84,"line":91},[82,148,149],{},"import importlib.util\n",[82,151,152],{"class":84,"line":97},[82,153,155],{"emptyLinePlaceholder":154},true,"\n",[82,157,158],{"class":84,"line":103},[82,159,160],{},"collect_ignore = []\n",[82,162,164],{"class":84,"line":163},5,[82,165,166],{},"if importlib.util.find_spec(\"pyarrow\") is None:\n",[82,168,170],{"class":84,"line":169},6,[82,171,172],{},"    # Skipping the import entirely, not just the tests: the module would fail to import.\n",[82,174,176],{"class":84,"line":175},7,[82,177,178],{},"    collect_ignore.append(\"arrow_integration\")\n",[10,180,181,190,191,53],{},[29,182,183,40,186,189],{},[13,184,185],{},"--ignore",[13,187,188],{},"--ignore-glob"," — prune for one run."," The command-line equivalent, useful for a fast local loop: ",[13,192,193],{},"pytest --ignore=tests\u002Fintegration -q",[10,195,196,197,40,200,203,204,208,209,212],{},"Deselection flags are a different thing entirely. ",[13,198,199],{},"-k",[13,201,202],{},"-m"," filter items ",[205,206,207],"em",{},"after"," collection has imported everything, so they make the run shorter without making the collection cheaper. On a suite whose problem is import cost, ",[13,210,211],{},"-m \"not slow\""," saves nothing at the stage that hurts.",[214,215,218,341],"figure",{"className":216},[217],"diagram",[219,220,227,228,227,232,227,236,227,254,227,262,227,271,227,280,227,286,227,290,227,296,227,299,227,303,227,307,227,311,227,314,227,318,227,321,227,325,227,328,227,332,227,335],"svg",{"viewBox":221,"role":222,"ariaLabelledBy":223,"xmlns":226},"0 0 760 172","img",[224,225],"collectstages-t","collectstages-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[229,230,231],"title",{"id":224},"Where each control prunes",[233,234,235],"desc",{"id":225},"A left-to-right pipeline of collection: pytest determines the start directories, walks them recursively, imports each matching module, and builds test items, with testpaths pruning the first stage, norecursedirs and ignore pruning the walk, and deselection acting only after items exist.",[237,238,239,240,227],"defs",{},"\n    ",[241,242,249],"marker",{"id":243,"viewBox":244,"refX":245,"refY":246,"markerWidth":247,"markerHeight":247,"orient":248},"collectstages-a","0 0 10 10","9","5","7","auto-start-reverse",[250,251],"path",{"d":252,"fill":253},"M0 0 L10 5 L0 10 z","#e07a5f",[255,256],"rect",{"x":257,"y":257,"width":258,"height":259,"rx":260,"fill":261},"0","760","172","14","#fffdf8",[263,264,231],"text",{"x":265,"y":266,"textAnchor":267,"fontSize":268,"fontWeight":269,"fill":270},"380","30","middle","15","700","#3d405b",[255,272],{"x":273,"y":274,"width":275,"height":276,"rx":277,"fill":278,"stroke":253,"strokeWidth":279},"26","62","142","76","12","#f4f1de","1.8",[263,281,285],{"x":282,"y":283,"textAnchor":267,"fontSize":284,"fontWeight":269,"fill":270},"97","96","12.5","start dirs",[263,287,35],{"x":282,"y":288,"textAnchor":267,"fontSize":289,"fill":270},"113","11",[84,291],{"x1":292,"y1":293,"x2":294,"y2":293,"stroke":253,"strokeWidth":279,"markerEnd":295},"176","100","208","url(#collectstages-a)",[255,297],{"x":298,"y":274,"width":275,"height":276,"rx":277,"fill":261,"stroke":253,"strokeWidth":279},"214",[263,300,302],{"x":301,"y":283,"textAnchor":267,"fontSize":284,"fontWeight":269,"fill":270},"286","directory walk",[263,304,306],{"x":301,"y":288,"textAnchor":267,"fontSize":305,"fill":270},"10.5","norecursedirs, --ignore",[84,308],{"x1":309,"y1":293,"x2":310,"y2":293,"stroke":253,"strokeWidth":279,"markerEnd":295},"364","396",[255,312],{"x":313,"y":274,"width":275,"height":276,"rx":277,"fill":278,"stroke":253,"strokeWidth":279},"403",[263,315,317],{"x":316,"y":283,"textAnchor":267,"fontSize":284,"fontWeight":269,"fill":270},"474","import modules",[263,319,320],{"x":316,"y":288,"textAnchor":267,"fontSize":289,"fill":270},"the expensive part",[84,322],{"x1":323,"y1":293,"x2":324,"y2":293,"stroke":253,"strokeWidth":279,"markerEnd":295},"552","584",[255,326],{"x":327,"y":274,"width":275,"height":276,"rx":277,"fill":261,"stroke":253,"strokeWidth":279},"592",[263,329,331],{"x":330,"y":283,"textAnchor":267,"fontSize":284,"fontWeight":269,"fill":270},"663","build items",[263,333,334],{"x":330,"y":288,"textAnchor":267,"fontSize":289,"fill":270},"-k and -m act here",[263,336,340],{"x":265,"y":337,"textAnchor":267,"fontSize":338,"fontStyle":339,"fill":270},"164","11.5","italic","An imported module stays imported — its cost is paid even if every test in it is deselected.",[342,343,344],"figcaption",{},"The first two stages are where time is saved; filtering at the last stage shortens the run but not the collection.",[18,346,348],{"id":347},"why-this-works","Why this works",[10,350,351,352,355,356,36,359,362,363,366],{},"Collection is a filesystem walk followed by an import. The walk costs a ",[13,353,354],{},"stat"," per entry and is cheap per directory but expensive when a directory contains tens of thousands of files. The import is where the real time goes: every test module is executed at import, which means every module-scope ",[13,357,358],{},"import django",[13,360,361],{},"import pandas"," or ",[13,364,365],{},"from app import settings"," in that file runs, transitively, once per session. Pruning a directory removes both costs at once; deferring a heavy import removes only the second, but it is usually the larger of the two.",[214,368,370,448],{"className":369},[217],[219,371,227,376,227,379,227,382,227,385,227,387,227,391,227,398,227,402,227,406,227,411,227,415,227,419,227,424,227,428,227,432,227,435,227,439,227,444],{"viewBox":372,"role":222,"ariaLabelledBy":373,"xmlns":226},"0 0 760 276",[374,375],"collectsplit-t","collectsplit-d",[229,377,378],{"id":374},"Effect of each control on a 4,800-test monorepo",[233,380,381],{"id":375},"A bar chart of collection time after applying each control cumulatively: the unconfigured baseline, after setting testpaths, after adding norecursedirs, and after deferring heavy module-scope imports into fixtures.",[255,383],{"x":257,"y":257,"width":258,"height":384,"rx":260,"fill":261},"276",[263,386,378],{"x":265,"y":266,"textAnchor":267,"fontSize":268,"fontWeight":269,"fill":270},[263,388,390],{"x":266,"y":389,"fontSize":338,"fill":270},"75","unconfigured baseline",[255,392],{"x":393,"y":394,"width":395,"height":266,"rx":396,"fill":253,"stroke":270,"strokeWidth":397},"254","56","386","6","1.2",[263,399,401],{"x":400,"y":389,"fontSize":338,"fontWeight":269,"fill":270},"650","11.2 s",[263,403,405],{"x":266,"y":404,"fontSize":338,"fill":270},"121","+ testpaths",[255,407],{"x":393,"y":408,"width":409,"height":266,"rx":396,"fill":410,"stroke":270,"strokeWidth":397},"102","181","#81b29a",[263,412,414],{"x":413,"y":404,"fontSize":338,"fontWeight":269,"fill":270},"445","5.3 s",[263,416,418],{"x":266,"y":417,"fontSize":338,"fill":270},"167","+ norecursedirs",[255,420],{"x":393,"y":421,"width":422,"height":266,"rx":396,"fill":423,"stroke":270,"strokeWidth":397},"148","147","#f2cc8f",[263,425,427],{"x":426,"y":417,"fontSize":338,"fontWeight":269,"fill":270},"411","4.2 s",[263,429,431],{"x":266,"y":430,"fontSize":338,"fill":270},"213","+ deferred imports",[255,433],{"x":393,"y":434,"width":274,"height":266,"rx":396,"fill":270,"stroke":270,"strokeWidth":397},"194",[263,436,438],{"x":437,"y":430,"fontSize":338,"fontWeight":269,"fill":270},"326","1.8 s",[84,440],{"x1":393,"y1":441,"x2":393,"y2":442,"stroke":270,"strokeWidth":443},"48","232","1.5",[263,445,447],{"x":265,"y":446,"textAnchor":267,"fontSize":289,"fontStyle":339,"fill":270},"262","Measured with pytest --collect-only -q, warm page cache, on one repository.",[342,449,450],{},"Most of the remaining cost after pruning is import time, which no amount of directory configuration can remove.",[18,452,454],{"id":453},"finding-the-expensive-imports","Finding the expensive imports",[10,456,457],{},"Once the walk is pruned, the remaining cost is imports, and two tools localise it.",[10,459,460,463],{},[13,461,462],{},"-X importtime"," reports the cumulative import cost of everything Python loaded, sorted so the expensive subtrees are visible at the bottom:",[73,465,469],{"className":466,"code":467,"language":468,"meta":78,"style":78},"language-console shiki shiki-themes github-light github-dark","$ python -X importtime -m pytest --collect-only -q 2>&1 | sort -k2 -n -r | head -8\nimport time:  1943 | 512883 | pandas\nimport time:   402 | 231044 | sqlalchemy\nimport time:   118 |  88210 | app.settings\n","console",[13,470,471,476,481,486],{"__ignoreMap":78},[82,472,473],{"class":84,"line":85},[82,474,475],{},"$ python -X importtime -m pytest --collect-only -q 2>&1 | sort -k2 -n -r | head -8\n",[82,477,478],{"class":84,"line":91},[82,479,480],{},"import time:  1943 | 512883 | pandas\n",[82,482,483],{"class":84,"line":97},[82,484,485],{},"import time:   402 | 231044 | sqlalchemy\n",[82,487,488],{"class":84,"line":103},[82,489,490],{},"import time:   118 |  88210 | app.settings\n",[10,492,493,494,497],{},"Anything in the hundreds of milliseconds is worth deferring. The fix is to move the import out of module scope and into the fixture or function that needs it, which keeps the type available to annotations via ",[13,495,496],{},"TYPE_CHECKING"," without paying for it at collection:",[73,499,501],{"className":135,"code":500,"language":137,"meta":78,"style":78},"from typing import TYPE_CHECKING\nimport pytest\n\nif TYPE_CHECKING:                       # only the type checker pays for this\n    from sqlalchemy.engine import Engine\n\n@pytest.fixture(scope=\"session\")\ndef engine() -> \"Engine\":\n    from sqlalchemy import create_engine   # paid once, and only when used\n    return create_engine(\"postgresql:\u002F\u002F\u002Ftest\")\n",[13,502,503,508,513,517,522,527,531,536,542,548],{"__ignoreMap":78},[82,504,505],{"class":84,"line":85},[82,506,507],{},"from typing import TYPE_CHECKING\n",[82,509,510],{"class":84,"line":91},[82,511,512],{},"import pytest\n",[82,514,515],{"class":84,"line":97},[82,516,155],{"emptyLinePlaceholder":154},[82,518,519],{"class":84,"line":103},[82,520,521],{},"if TYPE_CHECKING:                       # only the type checker pays for this\n",[82,523,524],{"class":84,"line":163},[82,525,526],{},"    from sqlalchemy.engine import Engine\n",[82,528,529],{"class":84,"line":169},[82,530,155],{"emptyLinePlaceholder":154},[82,532,533],{"class":84,"line":175},[82,534,535],{},"@pytest.fixture(scope=\"session\")\n",[82,537,539],{"class":84,"line":538},8,[82,540,541],{},"def engine() -> \"Engine\":\n",[82,543,545],{"class":84,"line":544},9,[82,546,547],{},"    from sqlalchemy import create_engine   # paid once, and only when used\n",[82,549,551],{"class":84,"line":550},10,[82,552,553],{},"    return create_engine(\"postgresql:\u002F\u002F\u002Ftest\")\n",[10,555,556,557,560,561,564],{},"The second tool is pytest's own ",[13,558,559],{},"--durations"," applied to collection: ",[13,562,563],{},"pytest --collect-only --durations=10"," reports the slowest collection steps, which is how you find the single conftest importing the whole application.",[10,566,567,568,572],{},"For a monorepo, run collection per service and compare — a service that collects in 200ms and another that takes four seconds is a strong signal that the slow one has an import in its conftest that belongs in a fixture. That comparison also makes the isolation check from ",[49,569,571],{"href":570},"\u002Fadvanced-pytest-architecture-configuration\u002Fmanaging-conftest-hierarchies\u002Fcreating-conftestpy-hierarchies-for-monorepos\u002F","creating conftest.py hierarchies for monorepos"," cheap to run in CI.",[214,574,576,669],{"className":575},[217],[219,577,227,582,227,585,227,588,227,591,227,593,227,600,227,604,227,608,227,612,227,616,227,619,227,622,227,626,227,629,227,632,227,635,227,637,227,639,227,642,227,645,227,649,227,652,227,656,227,659,227,662,227,666],{"viewBox":578,"role":222,"ariaLabelledBy":579,"xmlns":226},"0 0 760 270",[580,581],"prunetools-t","prunetools-d",[229,583,584],{"id":580},"Which control to reach for",[233,586,587],{"id":581},"A table of four pruning controls - testpaths, norecursedirs, collect_ignore and the ignore flag - describing what each skips, where it is configured, and whether the decision can depend on the environment.",[255,589],{"x":257,"y":257,"width":258,"height":590,"rx":260,"fill":261},"270",[263,592,584],{"x":265,"y":266,"textAnchor":267,"fontSize":268,"fontWeight":269,"fill":270},[255,594],{"x":595,"y":596,"width":597,"height":598,"rx":599,"fill":423,"stroke":270,"strokeWidth":443},"24","52","712","40","10",[263,601,603],{"x":602,"y":276,"fontSize":277,"fontWeight":269,"fill":270},"38","Criterion",[263,605,607],{"x":606,"y":276,"textAnchor":267,"fontSize":277,"fontWeight":269,"fill":270},"390","Skips",[263,609,611],{"x":610,"y":276,"textAnchor":267,"fontSize":277,"fontWeight":269,"fill":270},"620","Conditional?",[255,613],{"x":595,"y":614,"width":597,"height":598,"fill":278,"stroke":270,"strokeWidth":615},"92","1",[263,617,35],{"x":602,"y":618,"fontSize":338,"fill":270},"116",[263,620,621],{"x":606,"y":618,"textAnchor":267,"fontSize":338,"fill":270},"everything outside it",[263,623,625],{"x":610,"y":618,"textAnchor":267,"fontSize":338,"fill":624},"#8f3d22","static",[255,627],{"x":595,"y":628,"width":597,"height":598,"fill":261,"stroke":270,"strokeWidth":615},"132",[263,630,39],{"x":602,"y":631,"fontSize":338,"fill":270},"156",[263,633,634],{"x":606,"y":631,"textAnchor":267,"fontSize":338,"fill":270},"directories in the walk",[263,636,625],{"x":610,"y":631,"textAnchor":267,"fontSize":338,"fill":624},[255,638],{"x":595,"y":259,"width":597,"height":598,"fill":278,"stroke":270,"strokeWidth":615},[263,640,43],{"x":602,"y":641,"fontSize":338,"fill":270},"196",[263,643,644],{"x":606,"y":641,"textAnchor":267,"fontSize":338,"fill":270},"paths, from a conftest",[263,646,648],{"x":610,"y":641,"textAnchor":267,"fontSize":338,"fill":647},"#2a5f49","yes, in Python",[255,650],{"x":595,"y":651,"width":597,"height":598,"fill":261,"stroke":270,"strokeWidth":615},"212",[263,653,655],{"x":602,"y":654,"fontSize":338,"fill":270},"236","--ignore \u002F --ignore-glob",[263,657,658],{"x":606,"y":654,"textAnchor":267,"fontSize":338,"fill":270},"paths, for one run",[263,660,661],{"x":610,"y":654,"textAnchor":267,"fontSize":338,"fill":647},"per invocation",[84,663],{"x1":664,"y1":596,"x2":664,"y2":665,"stroke":270,"strokeWidth":615},"274","252",[84,667],{"x1":668,"y1":596,"x2":668,"y2":665,"stroke":270,"strokeWidth":615},"505",[342,670,671],{},"Static configuration covers the permanent structure; collect_ignore is for the cases that depend on what is installed.",[18,673,675],{"id":674},"edge-cases-and-failure-modes","Edge cases and failure modes",[23,677,678,690,705,711,719],{},[26,679,680,685,686,689],{},[29,681,682,684],{},[13,683,35],{}," is ignored when a path argument is given."," ",[13,687,688],{},"pytest tests\u002Funit"," overrides it entirely, which is correct but means a slow ad-hoc invocation is not evidence that the configuration is wrong.",[26,691,692,697,698,36,700,40,702,704],{},[29,693,694,696],{},[13,695,39],{}," replaces the default list rather than adding to it."," Setting it without including ",[13,699,117],{},[13,701,120],{},[13,703,123],{}," re-enables walking those directories.",[26,706,707,710],{},[29,708,709],{},"A pruned directory's conftest never loads."," If a fixture lived there and something outside expected it, the failure is \"fixture not found\" rather than anything mentioning collection.",[26,712,713,718],{},[29,714,715,717],{},[13,716,43],{}," paths are relative to the conftest that declares them",", not to rootdir; a wrong relative path fails silently by simply not matching anything.",[26,720,721,727,728,730,731,733],{},[29,722,723,724,53],{},"Import cost hides in ",[13,725,726],{},"__init__.py"," A test package whose ",[13,729,726],{}," imports the application makes every module under it expensive, and ",[13,732,462],{}," attributes the cost to the application rather than to the test package.",[18,735,737],{"id":736},"keeping-the-saving-from-eroding","Keeping the saving from eroding",[10,739,740,741,743],{},"Collection time regresses quietly: someone adds a module-scope import, a new directory of fixture data appears, a service is added without being listed in ",[13,742,35],{},". Three cheap habits keep the number where you left it.",[10,745,746],{},"Record the baseline in the repository. A one-line note in the testing README — \"collection: 1.8s as of this commit\" — turns a vague \"feels slower\" into a measurable claim, and makes the next investigation start from a number rather than from scratch.",[10,748,749,750,753],{},"Assert it in CI when the suite is large enough to care. A job step that runs ",[13,751,752],{},"--collect-only -q",", times it, and fails above a threshold costs two seconds and catches the regression in the pull request that caused it:",[73,755,757],{"className":466,"code":756,"language":468,"meta":78,"style":78},"$ \u002Fusr\u002Fbin\u002Ftime -f \"%e\" pytest --collect-only -q >\u002Fdev\u002Fnull\n2.04\n",[13,758,759,764],{"__ignoreMap":78},[82,760,761],{"class":84,"line":85},[82,762,763],{},"$ \u002Fusr\u002Fbin\u002Ftime -f \"%e\" pytest --collect-only -q >\u002Fdev\u002Fnull\n",[82,765,766],{"class":84,"line":91},[82,767,768],{},"2.04\n",[10,770,771],{},"Review new conftest imports specifically. A conftest is imported for every test beneath it, so a module-scope import added there has a multiplier no other file has. Treating conftest changes as needing a second look — the way schema migrations do — costs nothing and prevents the most common regression.",[10,773,774,775,777,778,53],{},"Finally, re-check the configuration after any directory move. ",[13,776,35],{}," entries are literal paths, and a renamed service simply stops being collected: the suite goes green, faster than before, and nobody notices that a thousand tests disappeared. Asserting a minimum collected count in CI closes that gap, and is the same guard recommended for markers in ",[49,779,781],{"href":780},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpytest-markers-for-conditional-test-execution\u002F","pytest markers for conditional test execution",[10,783,784,785,788],{},"A note on where collection time actually bites: it is paid on every invocation, including the ones that run a single test. An engineer running ",[13,786,787],{},"pytest tests\u002Ftest_one.py::test_x"," in a tight loop pays the full import cost of that module's dependencies each time, which is why a two-second collection is felt as a slow feedback loop long before it shows up in CI totals. Pruning benefits that loop more than it benefits the nightly run, and that is the argument to make when the work needs justifying.",[10,790,791,792,795],{},"Note also that collection cost interacts with parallel execution: under ",[13,793,794],{},"pytest-xdist"," every worker collects independently, so a three-second collection with eight workers is twenty-four seconds of CPU before the first test runs. Pruning is therefore worth more in a parallel suite than in a serial one, which is the opposite of the intuition that parallelism hides fixed costs.",[18,797,799],{"id":798},"frequently-asked-questions","Frequently Asked Questions",[10,801,802,805,807,808,810,811,813,814,816],{},[29,803,804],{},"What is the difference between norecursedirs and testpaths?",[13,806,35],{}," sets where collection starts when no path argument is given, so pytest never walks anything outside it. ",[13,809,39],{}," prunes directories pytest would otherwise descend into during that walk. Setting ",[13,812,35],{}," is the larger win; ",[13,815,39],{}," handles the exceptions inside it.",[10,818,819,822,823,362,825,827],{},[29,820,821],{},"Does --ignore speed up collection or just hide tests?","\nIt genuinely prunes: an ignored path is never walked or imported, so its import cost disappears. Deselection with ",[13,824,199],{},[13,826,202],{}," happens after collection, so those flags do not save collection time at all.",[10,829,830,833,834,841,842,845,846,849,850,852],{},[29,831,832],{},"Why is collection slow when I only have a few hundred tests?","\nBecause collection time is dominated by importing test modules and their dependencies, not by the number of tests. A handful of modules that import a web framework at module scope can cost more than a thousand tests that import nothing.\n",[29,835,836,837,840],{},"Does ",[13,838,839],{},"--import-mode=importlib"," change collection cost?","\nMarginally, and in the right direction: it avoids the ",[13,843,844],{},"sys.path"," manipulation that ",[13,847,848],{},"prepend"," mode performs for every test package, and it removes the duplicate-basename collisions that force teams into ",[13,851,726],{}," files they do not otherwise want. The saving is small compared with pruning the walk, but the mode is worth adopting for correctness alone, and new projects should start with it.",[18,854,856],{"id":855},"related","Related",[23,858,859,865,872,878],{},[26,860,861,864],{},[49,862,863],{"href":51},"Optimizing test discovery"," — the full discovery pipeline these controls prune.",[26,866,867,871],{},[49,868,870],{"href":869},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fpytest-xdist-vs-pytest-parallel-performance-comparison\u002F","pytest-xdist vs pytest-parallel performance comparison"," — why collection cost multiplies by worker count under parallel runs.",[26,873,874,877],{},[49,875,876],{"href":570},"Creating conftest.py hierarchies for monorepos"," — keeping per-service collection independent.",[26,879,880,884],{},[49,881,883],{"href":882},"\u002Fsystematic-debugging-performance-profiling\u002Fcpu-profiling-with-cprofile-and-py-spy\u002F","CPU profiling with cProfile and py-spy"," — when the import cost needs attributing line by line.",[10,886,887,888],{},"← Back to ",[49,889,890],{"href":51},"Optimizing Test Discovery",[892,893,894],"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);}",{"title":78,"searchDepth":91,"depth":91,"links":896},[897,898,899,900,901,902,903,904],{"id":20,"depth":91,"text":21},{"id":56,"depth":91,"text":57},{"id":347,"depth":91,"text":348},{"id":453,"depth":91,"text":454},{"id":674,"depth":91,"text":675},{"id":736,"depth":91,"text":737},{"id":798,"depth":91,"text":799},{"id":855,"depth":91,"text":856},"Speed up pytest collection with testpaths, norecursedirs, collect_ignore and --ignore: what each one skips, when it applies, and how to measure the saving.","md",{"slug":908,"type":909,"breadcrumb":910,"datePublished":911,"dateModified":911,"faq":912,"howto":919},"cutting-collection-time-with-norecursedirs","article","Faster Collection","2026-08-01",[913,915,917],{"q":804,"a":914},"testpaths sets where collection starts when no path argument is given, so pytest never walks anything outside it. norecursedirs prunes directories pytest would otherwise descend into during that walk. Setting testpaths is the larger win; norecursedirs handles the exceptions inside it.",{"q":821,"a":916},"It genuinely prunes: an ignored path is never walked or imported, so its import cost disappears. Deselection with -k or -m happens after collection, so those flags do not save collection time at all.",{"q":832,"a":918},"Because collection time is dominated by importing test modules and their dependencies, not by the number of tests. A handful of modules that import a web framework at module scope can cost more than a thousand tests that import nothing.",{"name":920,"description":921,"steps":922},"How to cut pytest collection time","Measure collection cost, then prune the directory walk and the import graph that dominate it.",[923,926,929,932],{"name":924,"text":925},"Measure the baseline","Run pytest --collect-only -q and time it so later changes can be compared against a real number.",{"name":927,"text":928},"Pin testpaths","Declare the directories that contain tests so pytest never walks the rest of the repository.",{"name":930,"text":931},"Prune the exceptions with norecursedirs","List build, cache and vendor directories that live inside the test paths.",{"name":933,"text":934},"Defer heavy imports","Move module-scope imports of frameworks and clients into fixtures so collection does not pay for them.","\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fcutting-collection-time-with-norecursedirs",{"title":5,"description":905},"advanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fcutting-collection-time-with-norecursedirs\u002Findex","qxuICxKwA3m9LVv2LUzJcsgrpEroF_iJcHNFVAButEk",1785613403107]