[{"data":1,"prerenderedAt":1069},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpyproject-toml-vs-pytest-ini\u002F":3},{"id":4,"title":5,"body":6,"description":1034,"extension":1035,"meta":1036,"navigation":1064,"path":1065,"seo":1066,"stem":1067,"__hash__":1068},"content\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpyproject-toml-vs-pytest-ini\u002Findex.md","pyproject.toml vs pytest.ini: Choosing a Config Home",{"type":7,"value":8,"toc":1024},"minimark",[9,22,27,47,51,54,94,97,126,129,199,251,260,422,426,439,540,544,547,557,572,586,602,626,641,645,695,699,702,765,775,793,796,809,901,905,918,931,961,978,982,1014,1020],[10,11,12,13,17,18,21],"p",{},"Two files can configure pytest, and a repository that has both is configured by exactly one of them — usually not the one being edited. The symptom is a setting that has no effect: ",[14,15,16],"code",{},"addopts"," ignored, markers unregistered, ",[14,19,20],{},"testpaths"," apparently not read. The cause is discovery order, and the fix is to know which file wins and consolidate.",[23,24,26],"h2",{"id":25},"prerequisites","Prerequisites",[28,29,30,38],"ul",{},[31,32,33,37],"li",{},[34,35,36],"strong",{},"pytest 7.0+","; the discovery order below is unchanged in 8.",[31,39,40,41,46],{},"The wider configuration surface in ",[42,43,45],"a",{"href":44},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002F","pytest configuration best practices",".",[23,48,50],{"id":49},"solution","Solution",[10,52,53],{},"pytest looks for a configuration file in a fixed order and stops at the first one it finds:",[55,56,57,66,76,86],"ol",{},[31,58,59,62,63,46],{},[14,60,61],{},"pytest.ini"," — always wins, ",[34,64,65],{},"even when empty",[31,67,68,71,72,75],{},[14,69,70],{},"pyproject.toml"," — only when it contains a ",[14,73,74],{},"[tool.pytest.ini_options]"," table.",[31,77,78,81,82,85],{},[14,79,80],{},"tox.ini"," — only with a ",[14,83,84],{},"[pytest]"," section.",[31,87,88,81,91,85],{},[14,89,90],{},"setup.cfg",[14,92,93],{},"[tool:pytest]",[10,95,96],{},"The header of every run states the outcome, and reading it takes less time than reasoning about it:",[98,99,104],"pre",{"className":100,"code":101,"language":102,"meta":103,"style":103},"language-console shiki shiki-themes github-light github-dark","$ pytest -q\nrootdir: \u002Fsrv\u002Fapp, configfile: pyproject.toml\ntestpaths: tests\n","console","",[14,105,106,114,120],{"__ignoreMap":103},[107,108,111],"span",{"class":109,"line":110},"line",1,[107,112,113],{},"$ pytest -q\n",[107,115,117],{"class":109,"line":116},2,[107,118,119],{},"rootdir: \u002Fsrv\u002Fapp, configfile: pyproject.toml\n",[107,121,123],{"class":109,"line":122},3,[107,124,125],{},"testpaths: tests\n",[10,127,128],{},"The two viable homes differ in syntax more than in capability. The same configuration in each:",[98,130,134],{"className":131,"code":132,"language":133,"meta":103,"style":103},"language-ini shiki shiki-themes github-light github-dark","; pytest.ini — untyped strings, newline-separated lists\n[pytest]\naddopts = --strict-markers -ra\ntestpaths =\n    tests\n    services\u002Fbilling\u002Ftests\nmarkers =\n    slow: takes more than two seconds\n    integration: needs Postgres on localhost\nfilterwarnings =\n    error::RuntimeWarning\n","ini",[14,135,136,141,146,151,157,163,169,175,181,187,193],{"__ignoreMap":103},[107,137,138],{"class":109,"line":110},[107,139,140],{},"; pytest.ini — untyped strings, newline-separated lists\n",[107,142,143],{"class":109,"line":116},[107,144,145],{},"[pytest]\n",[107,147,148],{"class":109,"line":122},[107,149,150],{},"addopts = --strict-markers -ra\n",[107,152,154],{"class":109,"line":153},4,[107,155,156],{},"testpaths =\n",[107,158,160],{"class":109,"line":159},5,[107,161,162],{},"    tests\n",[107,164,166],{"class":109,"line":165},6,[107,167,168],{},"    services\u002Fbilling\u002Ftests\n",[107,170,172],{"class":109,"line":171},7,[107,173,174],{},"markers =\n",[107,176,178],{"class":109,"line":177},8,[107,179,180],{},"    slow: takes more than two seconds\n",[107,182,184],{"class":109,"line":183},9,[107,185,186],{},"    integration: needs Postgres on localhost\n",[107,188,190],{"class":109,"line":189},10,[107,191,192],{},"filterwarnings =\n",[107,194,196],{"class":109,"line":195},11,[107,197,198],{},"    error::RuntimeWarning\n",[98,200,204],{"className":201,"code":202,"language":203,"meta":103,"style":103},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml — real TOML types\n[tool.pytest.ini_options]\naddopts = \"--strict-markers -ra\"\ntestpaths = [\"tests\", \"services\u002Fbilling\u002Ftests\"]\nmarkers = [\n  \"slow: takes more than two seconds\",\n  \"integration: needs Postgres on localhost\",\n]\nfilterwarnings = [\"error::RuntimeWarning\"]\n","toml",[14,205,206,211,216,221,226,231,236,241,246],{"__ignoreMap":103},[107,207,208],{"class":109,"line":110},[107,209,210],{},"# pyproject.toml — real TOML types\n",[107,212,213],{"class":109,"line":116},[107,214,215],{},"[tool.pytest.ini_options]\n",[107,217,218],{"class":109,"line":122},[107,219,220],{},"addopts = \"--strict-markers -ra\"\n",[107,222,223],{"class":109,"line":153},[107,224,225],{},"testpaths = [\"tests\", \"services\u002Fbilling\u002Ftests\"]\n",[107,227,228],{"class":109,"line":159},[107,229,230],{},"markers = [\n",[107,232,233],{"class":109,"line":165},[107,234,235],{},"  \"slow: takes more than two seconds\",\n",[107,237,238],{"class":109,"line":171},[107,239,240],{},"  \"integration: needs Postgres on localhost\",\n",[107,242,243],{"class":109,"line":177},[107,244,245],{},"]\n",[107,247,248],{"class":109,"line":183},[107,249,250],{},"filterwarnings = [\"error::RuntimeWarning\"]\n",[10,252,253,254,256,257,259],{},"Choose ",[14,255,70],{}," when the repository is a Python package that already has one — one file for build metadata, dependencies, linters and pytest, with real types and a syntax the rest of the toolchain shares. Choose ",[14,258,61],{}," when the tests live in a repository that is not a Python package (a service with tests but no wheel), or when you deliberately want rootdir to be somewhere other than the package root.",[261,262,265,418],"figure",{"className":263},[264],"diagram",[266,267,274,275,274,279,274,283,274,301,274,309,274,318,274,326,274,331,274,336,274,341,274,351,274,356,274,360,274,363,274,367,274,371,274,375,274,379,274,382,274,385,274,389,274,393,274,397,274,401,274,404,274,406,274,410,274,414],"svg",{"viewBox":268,"role":269,"ariaLabelledBy":270,"xmlns":273},"0 0 760 434","img",[271,272],"cfgdiscovery-t","cfgdiscovery-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[276,277,278],"title",{"id":271},"Configuration discovery, stopping at the first hit",[280,281,282],"desc",{"id":272},"A vertical flow of pytest configuration discovery: pytest.ini is checked first and wins even if empty, then pyproject.toml with an ini_options table, then tox.ini with a pytest section, then setup.cfg, and whichever is found sets rootdir to its directory.",[284,285,286,287,274],"defs",{},"\n    ",[288,289,296],"marker",{"id":290,"viewBox":291,"refX":292,"refY":293,"markerWidth":294,"markerHeight":294,"orient":295},"cfgdiscovery-a","0 0 10 10","9","5","7","auto-start-reverse",[297,298],"path",{"d":299,"fill":300},"M0 0 L10 5 L0 10 z","#f2cc8f",[302,303],"rect",{"x":304,"y":304,"width":305,"height":306,"rx":307,"fill":308},"0","760","434","14","#fffdf8",[310,311,278],"text",{"x":312,"y":313,"textAnchor":314,"fontSize":315,"fontWeight":316,"fill":317},"380","30","middle","15","700","#3d405b",[302,319],{"x":320,"y":321,"width":322,"height":323,"rx":324,"fill":308,"stroke":300,"strokeWidth":325},"34","58","320","64","12","1.8",[310,327,61],{"x":328,"y":329,"textAnchor":314,"fontSize":330,"fontWeight":316,"fill":317},"194","86","12.5",[310,332,335],{"x":328,"y":333,"textAnchor":314,"fontSize":334,"fill":317},"103","11","wins even when empty",[109,337],{"x1":328,"y1":338,"x2":328,"y2":339,"stroke":300,"strokeWidth":325,"markerEnd":340},"128","149","url(#cfgdiscovery-a)",[302,342],{"x":343,"y":323,"width":344,"height":345,"rx":346,"fill":308,"stroke":347,"strokeWidth":348,"strokeDashArray":349},"384","346","52","10","#81b29a","1.5",[293,350],"4",[310,352,355],{"x":353,"y":354,"textAnchor":314,"fontSize":334,"fill":317},"557","93","The most common trap",[302,357],{"x":320,"y":358,"width":322,"height":323,"rx":324,"fill":359,"stroke":300,"strokeWidth":325},"156","#f4f1de",[310,361,70],{"x":328,"y":362,"textAnchor":314,"fontSize":330,"fontWeight":316,"fill":317},"184",[310,364,366],{"x":328,"y":365,"textAnchor":314,"fontSize":334,"fill":317},"201","needs the ini_options table",[109,368],{"x1":328,"y1":369,"x2":328,"y2":370,"stroke":300,"strokeWidth":325,"markerEnd":340},"226","247",[302,372],{"x":343,"y":373,"width":344,"height":345,"rx":346,"fill":359,"stroke":347,"strokeWidth":348,"strokeDashArray":374},"162",[293,350],[310,376,378],{"x":353,"y":377,"textAnchor":314,"fontSize":334,"fill":317},"191","The modern default",[302,380],{"x":320,"y":381,"width":322,"height":323,"rx":324,"fill":308,"stroke":300,"strokeWidth":325},"254",[310,383,80],{"x":328,"y":384,"textAnchor":314,"fontSize":330,"fontWeight":316,"fill":317},"282",[310,386,388],{"x":328,"y":387,"textAnchor":314,"fontSize":334,"fill":317},"299","needs a [pytest] section",[109,390],{"x1":328,"y1":391,"x2":328,"y2":392,"stroke":300,"strokeWidth":325,"markerEnd":340},"324","345",[302,394],{"x":343,"y":395,"width":344,"height":345,"rx":346,"fill":308,"stroke":347,"strokeWidth":348,"strokeDashArray":396},"260",[293,350],[310,398,400],{"x":353,"y":399,"textAnchor":314,"fontSize":334,"fill":317},"289","Fine, but easy to miss",[302,402],{"x":320,"y":403,"width":322,"height":323,"rx":324,"fill":359,"stroke":300,"strokeWidth":325},"352",[310,405,90],{"x":328,"y":312,"textAnchor":314,"fontSize":330,"fontWeight":316,"fill":317},[310,407,409],{"x":328,"y":408,"textAnchor":314,"fontSize":334,"fill":317},"397","legacy [tool:pytest]",[302,411],{"x":343,"y":412,"width":344,"height":345,"rx":346,"fill":359,"stroke":347,"strokeWidth":348,"strokeDashArray":413},"358",[293,350],[310,415,417],{"x":353,"y":416,"textAnchor":314,"fontSize":334,"fill":317},"387","Avoid in new projects",[419,420,421],"figcaption",{},"Discovery stops at the first match, and that file’s directory becomes rootdir for the whole run.",[23,423,425],{"id":424},"why-this-works","Why this works",[10,427,428,429,431,432,434,435,438],{},"pytest resolves rootdir and the configuration file together, walking upwards from the common ancestor of the arguments until it finds one of the four candidates. The first hit fixes both values, which is why an empty ",[14,430,61],{}," is so disruptive: it satisfies the search, so the walk stops, and every setting in ",[14,433,70],{}," further up is never read. Because rootdir is the anchor for relative paths, the same file choice also decides what ",[14,436,437],{},"testpaths = [\"tests\"]"," refers to — a detail that only becomes visible when a run started from a subdirectory picks a different rootdir than the one you expected.",[261,440,442,537],{"className":441},[264],[266,443,274,448,274,451,274,454,274,457,274,459,274,464,274,469,274,472,274,475,274,479,274,484,274,488,274,492,274,495,274,498,274,501,274,504,274,507,274,511,274,514,274,517,274,520,274,524,274,527,274,530,274,534],{"viewBox":444,"role":269,"ariaLabelledBy":445,"xmlns":273},"0 0 760 270",[446,447],"cfghomes-t","cfghomes-d",[276,449,450],{"id":446},"The two viable homes, compared",[280,452,453],{"id":447},"A table comparing pyproject.toml and pytest.ini across four properties: value typing, whether the repository must be a Python package, how lists are written, and precedence when both files exist.",[302,455],{"x":304,"y":304,"width":305,"height":456,"rx":307,"fill":308},"270",[310,458,450],{"x":312,"y":313,"textAnchor":314,"fontSize":315,"fontWeight":316,"fill":317},[302,460],{"x":461,"y":345,"width":462,"height":463,"rx":346,"fill":300,"stroke":317,"strokeWidth":348},"24","712","40",[310,465,468],{"x":466,"y":467,"fontSize":324,"fontWeight":316,"fill":317},"38","76","Criterion",[310,470,70],{"x":471,"y":467,"textAnchor":314,"fontSize":324,"fontWeight":316,"fill":317},"390",[310,473,61],{"x":474,"y":467,"textAnchor":314,"fontSize":324,"fontWeight":316,"fill":317},"620",[302,476],{"x":461,"y":477,"width":462,"height":463,"fill":359,"stroke":317,"strokeWidth":478},"92","1",[310,480,483],{"x":466,"y":481,"fontSize":482,"fill":317},"116","11.5","Value types",[310,485,487],{"x":471,"y":481,"textAnchor":314,"fontSize":482,"fill":486},"#2a5f49","real TOML types",[310,489,491],{"x":474,"y":481,"textAnchor":314,"fontSize":482,"fill":490},"#8f3d22","strings only",[302,493],{"x":461,"y":494,"width":462,"height":463,"fill":308,"stroke":317,"strokeWidth":478},"132",[310,496,497],{"x":466,"y":358,"fontSize":482,"fill":317},"Needs a package",[310,499,500],{"x":471,"y":358,"textAnchor":314,"fontSize":482,"fill":486},"usually present",[310,502,503],{"x":474,"y":358,"textAnchor":314,"fontSize":482,"fill":486},"works anywhere",[302,505],{"x":461,"y":506,"width":462,"height":463,"fill":359,"stroke":317,"strokeWidth":478},"172",[310,508,510],{"x":466,"y":509,"fontSize":482,"fill":317},"196","List syntax",[310,512,513],{"x":471,"y":509,"textAnchor":314,"fontSize":482,"fill":486},"TOML arrays",[310,515,516],{"x":474,"y":509,"textAnchor":314,"fontSize":482,"fill":490},"newline-separated",[302,518],{"x":461,"y":519,"width":462,"height":463,"fill":308,"stroke":317,"strokeWidth":478},"212",[310,521,523],{"x":466,"y":522,"fontSize":482,"fill":317},"236","Precedence if both exist",[310,525,526],{"x":471,"y":522,"textAnchor":314,"fontSize":482,"fill":490},"loses",[310,528,529],{"x":474,"y":522,"textAnchor":314,"fontSize":482,"fill":486},"wins",[109,531],{"x1":532,"y1":345,"x2":532,"y2":533,"stroke":317,"strokeWidth":478},"274","252",[109,535],{"x1":536,"y1":345,"x2":536,"y2":533,"stroke":317,"strokeWidth":478},"505",[419,538,539],{},"Prefer pyproject.toml in packaged projects and pytest.ini where the repository is not a package — but never keep both.",[23,541,543],{"id":542},"migrating-between-the-two-safely","Migrating between the two safely",[10,545,546],{},"The migration is short but has three failure modes worth avoiding, and all three are silent.",[10,548,549,552,553,556],{},[34,550,551],{},"Quoting."," TOML strings need escaping that ini strings do not. A ",[14,554,555],{},"filterwarnings"," entry containing a regex with backslashes must be a TOML literal string (single quotes) or every backslash needs doubling. The setting is not rejected when it is wrong; it simply never matches.",[98,558,560],{"className":201,"code":559,"language":203,"meta":103,"style":103},"# Correct: a literal string, so the regex reaches pytest unchanged.\nfilterwarnings = ['ignore:.*\\bdeprecated\\b.*:DeprecationWarning']\n",[14,561,562,567],{"__ignoreMap":103},[107,563,564],{"class":109,"line":110},[107,565,566],{},"# Correct: a literal string, so the regex reaches pytest unchanged.\n",[107,568,569],{"class":109,"line":116},[107,570,571],{},"filterwarnings = ['ignore:.*\\bdeprecated\\b.*:DeprecationWarning']\n",[10,573,574,577,578,581,582,585],{},[34,575,576],{},"Booleans and numbers."," In ini files everything is a string and pytest parses it. In TOML, ",[14,579,580],{},"xfail_strict = true"," is a boolean and ",[14,583,584],{},"xfail_strict = \"true\""," is a string — pytest accepts both today, but typed values are the point of the format and mixing them makes the file harder to review.",[10,587,588,591,592,594,595,598,599,601],{},[34,589,590],{},"Rootdir movement."," If ",[14,593,61],{}," sat in ",[14,596,597],{},"tests\u002F"," and ",[14,600,70],{}," is at the repository root, rootdir moves up one level and every relative path in the configuration now resolves somewhere else. Check the header before and after:",[98,603,605],{"className":100,"code":604,"language":102,"meta":103,"style":103},"$ pytest --collect-only -q | head -2      # before\nrootdir: \u002Fsrv\u002Fapp\u002Ftests, configfile: pytest.ini\n$ pytest --collect-only -q | head -2      # after\nrootdir: \u002Fsrv\u002Fapp, configfile: pyproject.toml\n",[14,606,607,612,617,622],{"__ignoreMap":103},[107,608,609],{"class":109,"line":110},[107,610,611],{},"$ pytest --collect-only -q | head -2      # before\n",[107,613,614],{"class":109,"line":116},[107,615,616],{},"rootdir: \u002Fsrv\u002Fapp\u002Ftests, configfile: pytest.ini\n",[107,618,619],{"class":109,"line":122},[107,620,621],{},"$ pytest --collect-only -q | head -2      # after\n",[107,623,624],{"class":109,"line":153},[107,625,119],{},[10,627,628,629,632,633,636,637,640],{},"A changed rootdir also changes the node ids in reports (",[14,630,631],{},"tests\u002Ftest_a.py::test_x"," becomes ",[14,634,635],{},"tests\u002Ftests\u002Ftest_a.py::test_x"," if paths were adjusted wrongly), which breaks ",[14,638,639],{},"--last-failed"," and any dashboard keyed on them. Verify the collected node ids match the previous run before merging the migration.",[23,642,644],{"id":643},"edge-cases-and-failure-modes","Edge cases and failure modes",[28,646,647,659,670,678,687],{},[31,648,649,658],{},[34,650,651,652,654,655,657],{},"An empty ",[14,653,61],{}," disables ",[14,656,70],{}," entirely."," This is the single most common configuration bug; delete the file rather than emptying it.",[31,660,661,666,667,669],{},[34,662,663,665],{},[14,664,90],{}," cannot hold every value safely."," Its parser mangles some characters in ",[14,668,16],{},", which is why pytest's own documentation discourages it for new projects.",[31,671,672,677],{},[34,673,674,676],{},[14,675,16],{}," accumulates across files, but only one file is read",", so a value you expect to inherit from a parent directory is not inherited at all.",[31,679,680,683,684,686],{},[34,681,682],{},"Editors and formatters may reorder TOML tables"," but never merge them; two ",[14,685,74],{}," tables in one file is a TOML syntax error, not a merge.",[31,688,689,694],{},[34,690,691,692,46],{},"CI images sometimes ship a stray ",[14,693,80],{}," When a setting works locally and not in CI, the header line in the CI log answers it immediately.",[23,696,698],{"id":697},"a-minimal-configuration-worth-copying","A minimal configuration worth copying",[10,700,701],{},"Whichever file you choose, the same handful of settings earns its place in almost every repository. The block below is a defensible default, with each line doing work that a team would otherwise rediscover through a production incident.",[98,703,705],{"className":201,"code":704,"language":203,"meta":103,"style":103},"[tool.pytest.ini_options]\nminversion = \"7.0\"                       # fail loudly on an ancient pytest\naddopts = [\n  \"--strict-markers\",                    # an unregistered marker is an error\n  \"--strict-config\",                     # an unknown ini key is an error\n  \"-ra\",                                 # summarise every non-passing outcome\n  \"--import-mode=importlib\",             # no sys.path manipulation at import\n]\ntestpaths = [\"tests\"]\nxfail_strict = true                      # an xpass fails: fixed tests must be untagged\nfilterwarnings = [\"error::RuntimeWarning\"]\nmarkers = [\"slow: excluded from the pre-commit run\"]\n",[14,706,707,711,716,721,726,731,736,741,745,750,755,759],{"__ignoreMap":103},[107,708,709],{"class":109,"line":110},[107,710,215],{},[107,712,713],{"class":109,"line":116},[107,714,715],{},"minversion = \"7.0\"                       # fail loudly on an ancient pytest\n",[107,717,718],{"class":109,"line":122},[107,719,720],{},"addopts = [\n",[107,722,723],{"class":109,"line":153},[107,724,725],{},"  \"--strict-markers\",                    # an unregistered marker is an error\n",[107,727,728],{"class":109,"line":159},[107,729,730],{},"  \"--strict-config\",                     # an unknown ini key is an error\n",[107,732,733],{"class":109,"line":165},[107,734,735],{},"  \"-ra\",                                 # summarise every non-passing outcome\n",[107,737,738],{"class":109,"line":171},[107,739,740],{},"  \"--import-mode=importlib\",             # no sys.path manipulation at import\n",[107,742,743],{"class":109,"line":177},[107,744,245],{},[107,746,747],{"class":109,"line":183},[107,748,749],{},"testpaths = [\"tests\"]\n",[107,751,752],{"class":109,"line":189},[107,753,754],{},"xfail_strict = true                      # an xpass fails: fixed tests must be untagged\n",[107,756,757],{"class":109,"line":195},[107,758,250],{},[107,760,762],{"class":109,"line":761},12,[107,763,764],{},"markers = [\"slow: excluded from the pre-commit run\"]\n",[10,766,767,770,771,774],{},[14,768,769],{},"--strict-config"," is the least known and the most useful of these: without it, a typo in an ini key is silently ignored, so ",[14,772,773],{},"testpath = [\"tests\"]"," configures nothing and reports nothing. With it, the run fails at startup with the offending key named.",[10,776,777,780,781,784,785,788,789,792],{},[14,778,779],{},"--import-mode=importlib"," deserves its own note. The historic ",[14,782,783],{},"prepend"," mode inserts test directories at the front of ",[14,786,787],{},"sys.path",", which is why two test files with the same basename in different directories collide, and why a test package can shadow a real one. ",[14,790,791],{},"importlib"," mode removes both problems and is the recommended mode for new projects; it requires unique test module names, which is a constraint worth accepting.",[10,794,795],{},"Keep the block short. Every setting in a configuration file is a rule someone will have to understand later, and a file that accumulates twenty options nobody can justify is harder to work with than one that states five and means them.",[10,797,798,799,802,803,805,806,808],{},"Finally, treat the configuration file as reviewed code. Every option in it changes the behaviour of every test run in the repository, which gives it a blast radius no individual test has — a stray ",[14,800,801],{},"-p no:randomly"," in ",[14,804,16],{}," disables test-order randomisation for everyone, and an over-broad ",[14,807,555],{}," entry hides deprecations for years. Requiring the same review attention as a dependency change costs nothing and prevents the class of problem where nobody remembers why a setting is there.",[261,810,812,898],{"className":811},[264],[266,813,274,818,274,821,274,824,274,826,274,828,274,835,274,837,274,841,274,845,274,849,274,853,274,857,274,860,274,862,274,864,274,868,274,871,274,874,274,877,274,880,274,882,274,885,274,889,274,892,274,895],{"viewBox":814,"role":269,"ariaLabelledBy":815,"xmlns":273},"0 0 760 196",[816,817],"cfgreview-t","cfgreview-d",[276,819,820],{"id":816},"Three settings worth a second look in review",[280,822,823],{"id":817},"Three panels naming configuration settings with an outsized blast radius: addopts entries that disable plugins, filterwarnings entries that hide deprecations, and testpaths entries that silently exclude directories.",[302,825],{"x":304,"y":304,"width":305,"height":509,"rx":307,"fill":308},[310,827,820],{"x":312,"y":313,"textAnchor":314,"fontSize":315,"fontWeight":316,"fill":317},[302,829],{"x":830,"y":321,"width":831,"height":832,"rx":324,"fill":308,"stroke":833,"strokeWidth":834},"26","221","124","#e07a5f","2",[302,836],{"x":830,"y":321,"width":831,"height":313,"rx":324,"fill":317},[310,838,16],{"x":839,"y":840,"textAnchor":314,"fontSize":324,"fontWeight":316,"fill":308},"137","78",[310,842,844],{"x":463,"y":843,"fontSize":334,"fill":317},"110","applies to every run",[310,846,848],{"x":463,"y":847,"fontSize":334,"fill":317},"130","can disable a plugin",[310,850,852],{"x":463,"y":851,"fontSize":334,"fill":317},"150","hard to notice locally",[310,854,856],{"x":463,"y":855,"fontSize":334,"fill":317},"170","state the reason inline",[302,858],{"x":859,"y":321,"width":831,"height":832,"rx":324,"fill":308,"stroke":300,"strokeWidth":834},"269",[302,861],{"x":859,"y":321,"width":831,"height":313,"rx":324,"fill":300},[310,863,555],{"x":312,"y":840,"textAnchor":314,"fontSize":324,"fontWeight":316,"fill":317},[310,865,867],{"x":866,"y":843,"fontSize":334,"fill":317},"283","can hide real bugs",[310,869,870],{"x":866,"y":847,"fontSize":334,"fill":317},"ignore rules outlive causes",[310,872,873],{"x":866,"y":851,"fontSize":334,"fill":317},"prefer narrow matches",[310,875,876],{"x":866,"y":855,"fontSize":334,"fill":317},"review annually",[302,878],{"x":879,"y":321,"width":831,"height":832,"rx":324,"fill":308,"stroke":347,"strokeWidth":834},"513",[302,881],{"x":879,"y":321,"width":831,"height":313,"rx":324,"fill":317},[310,883,20],{"x":884,"y":840,"textAnchor":314,"fontSize":324,"fontWeight":316,"fill":308},"623",[310,886,888],{"x":887,"y":843,"fontSize":334,"fill":317},"527","a rename drops tests",[310,890,891],{"x":887,"y":847,"fontSize":334,"fill":317},"suite goes green and small",[310,893,894],{"x":887,"y":851,"fontSize":334,"fill":317},"assert a minimum count",[310,896,897],{"x":887,"y":855,"fontSize":334,"fill":317},"check after moves",[419,899,900],{},"Each of these can make a suite pass for the wrong reason, which is exactly the kind of change review exists to catch.",[23,902,904],{"id":903},"frequently-asked-questions","Frequently Asked Questions",[10,906,907,910,912,913,915,916,46],{},[34,908,909],{},"Which file wins if both pytest.ini and pyproject.toml exist?",[14,911,61],{}," always wins, even when it is empty. pytest checks for it first and stops at the first configuration file it finds, so a leftover empty ",[14,914,61],{}," silently disables the settings in ",[14,917,70],{},[10,919,920,923,924,926,927,930],{},[34,921,922],{},"Does the config file location change rootdir?","\nYes. The directory containing the configuration file becomes rootdir, which anchors relative paths in ",[14,925,20],{},", ",[14,928,929],{},"collect_ignore"," and coverage settings, and appears in the header of every run.",[10,932,933,936,937,939,940,942,943,946,947,950,951,953,954,598,957,960],{},[34,934,935],{},"Are the values written differently in the two formats?","\nYes. ",[14,938,61],{}," values are untyped strings with newline-separated lists, while ",[14,941,70],{}," is real TOML with typed arrays, booleans and numbers. Copying a value between them without adjusting the syntax is a common source of ignored settings.\n",[34,944,945],{},"Can different directories have different pytest configuration?","\nNot through configuration files — pytest reads exactly one. Per-directory behaviour comes from ",[14,948,949],{},"conftest.py"," (fixtures, hooks, ",[14,952,929],{},") or from separate invocations with their own ",[14,955,956],{},"-p",[14,958,959],{},"-o"," overrides. In a monorepo where services genuinely need different settings, run them as separate pytest invocations with their own working directory rather than trying to express it in one file.",[10,962,963,966,967,970,971,974,975,977],{},[34,964,965],{},"How do I override a single ini value for one run?","\nUse ",[14,968,969],{},"-o key=value"," on the command line, which takes precedence over the file: ",[14,972,973],{},"pytest -o xfail_strict=false -q",". It accepts any ini key, which also makes it the quickest way to test whether a setting is being read at all — if ",[14,976,959],{}," changes the behaviour and the file does not, the file is not the one pytest is reading.",[23,979,981],{"id":980},"related","Related",[28,983,984,990,997,1007],{},[31,985,986,989],{},[42,987,988],{"href":44},"Pytest configuration best practices"," — what belongs in the file once you have chosen it.",[31,991,992,996],{},[42,993,995],{"href":994},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpytest-markers-for-conditional-test-execution\u002F","Pytest markers for conditional test execution"," — marker registration, one of the settings most often lost to the empty-ini trap.",[31,998,999,1003,1004,1006],{},[42,1000,1002],{"href":1001},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fcutting-collection-time-with-norecursedirs\u002F","Cutting collection time with norecursedirs"," — ",[14,1005,20],{}," and friends, whose relative paths depend on rootdir.",[31,1008,1009,1013],{},[42,1010,1012],{"href":1011},"\u002Fadvanced-pytest-architecture-configuration\u002Fmanaging-conftest-hierarchies\u002F","Managing conftest hierarchies"," — rootdir also anchors the conftest walk.",[10,1015,1016,1017],{},"← Back to ",[42,1018,1019],{"href":44},"Pytest Configuration Best Practices",[1021,1022,1023],"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":103,"searchDepth":116,"depth":116,"links":1025},[1026,1027,1028,1029,1030,1031,1032,1033],{"id":25,"depth":116,"text":26},{"id":49,"depth":116,"text":50},{"id":424,"depth":116,"text":425},{"id":542,"depth":116,"text":543},{"id":643,"depth":116,"text":644},{"id":697,"depth":116,"text":698},{"id":903,"depth":116,"text":904},{"id":980,"depth":116,"text":981},"Compare pyproject.toml and pytest.ini for pytest configuration: discovery precedence, rootdir effects, quoting differences, and when each file is the right home.","md",{"slug":1037,"type":1038,"breadcrumb":1039,"datePublished":1040,"dateModified":1040,"faq":1041,"howto":1048},"pyproject-toml-vs-pytest-ini","article","Config File Choice","2026-08-01",[1042,1044,1046],{"q":909,"a":1043},"pytest.ini always wins, even when it is empty. pytest checks for pytest.ini first and stops at the first configuration file it finds, so a leftover empty pytest.ini silently disables the settings in pyproject.toml.",{"q":922,"a":1045},"Yes. The directory containing the configuration file becomes rootdir, which anchors relative paths in testpaths, collect_ignore and coverage settings, and appears in the header of every run.",{"q":935,"a":1047},"Yes. pytest.ini values are untyped strings with newline-separated lists, while pyproject.toml is real TOML with typed arrays, booleans and numbers. Copying a value between them without adjusting the syntax is a common source of ignored settings.",{"name":1049,"description":1050,"steps":1051},"How to choose between pyproject.toml and pytest.ini","Pick the configuration home that matches the repository layout and avoid the precedence traps.",[1052,1055,1058,1061],{"name":1053,"text":1054},"Check which file pytest is reading","Run pytest and read the rootdir and configfile line in the header before changing anything.",{"name":1056,"text":1057},"Consolidate on one file","Delete the file you are not using, especially an empty pytest.ini, so precedence cannot surprise anyone.",{"name":1059,"text":1060},"Translate the values to the target syntax","Convert newline-separated ini strings into TOML arrays and quote glob patterns correctly.",{"name":1062,"text":1063},"Confirm rootdir did not move","Re-run and compare the rootdir line, since relative paths in the configuration are resolved against it.",true,"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpyproject-toml-vs-pytest-ini",{"title":5,"description":1034},"advanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fpyproject-toml-vs-pytest-ini\u002Findex","oeBMiK60NpwZCnRphm3a75G6Z0eMYGPepxgJb-Er8W4",1785613404401]