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: addopts ignored, markers unregistered, testpaths apparently not read. The cause is discovery order, and the fix is to know which file wins and consolidate.
Prerequisites
- pytest 7.0+; the discovery order below is unchanged in 8.
- The wider configuration surface in pytest configuration best practices.
Solution
pytest looks for a configuration file in a fixed order and stops at the first one it finds:
pytest.ini— always wins, even when empty.pyproject.toml— only when it contains a[tool.pytest.ini_options]table.tox.ini— only with a[pytest]section.setup.cfg— only with a[tool:pytest]section.
The header of every run states the outcome, and reading it takes less time than reasoning about it:
$ pytest -q
rootdir: /srv/app, configfile: pyproject.toml
testpaths: tests
The two viable homes differ in syntax more than in capability. The same configuration in each:
; pytest.ini — untyped strings, newline-separated lists
[pytest]
addopts = --strict-markers -ra
testpaths =
tests
services/billing/tests
markers =
slow: takes more than two seconds
integration: needs Postgres on localhost
filterwarnings =
error::RuntimeWarning
# pyproject.toml — real TOML types
[tool.pytest.ini_options]
addopts = "--strict-markers -ra"
testpaths = ["tests", "services/billing/tests"]
markers = [
"slow: takes more than two seconds",
"integration: needs Postgres on localhost",
]
filterwarnings = ["error::RuntimeWarning"]
Choose pyproject.toml 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 pytest.ini 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.
Why this works
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 pytest.ini is so disruptive: it satisfies the search, so the walk stops, and every setting in pyproject.toml further up is never read. Because rootdir is the anchor for relative paths, the same file choice also decides what 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.
Migrating between the two safely
The migration is short but has three failure modes worth avoiding, and all three are silent.
Quoting. TOML strings need escaping that ini strings do not. A 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.
# Correct: a literal string, so the regex reaches pytest unchanged.
filterwarnings = ['ignore:.*\bdeprecated\b.*:DeprecationWarning']
Booleans and numbers. In ini files everything is a string and pytest parses it. In TOML, xfail_strict = true is a boolean and 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.
Rootdir movement. If pytest.ini sat in tests/ and pyproject.toml 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:
$ pytest --collect-only -q | head -2 # before
rootdir: /srv/app/tests, configfile: pytest.ini
$ pytest --collect-only -q | head -2 # after
rootdir: /srv/app, configfile: pyproject.toml
A changed rootdir also changes the node ids in reports (tests/test_a.py::test_x becomes tests/tests/test_a.py::test_x if paths were adjusted wrongly), which breaks --last-failed and any dashboard keyed on them. Verify the collected node ids match the previous run before merging the migration.
Edge cases and failure modes
- An empty
pytest.inidisablespyproject.tomlentirely. This is the single most common configuration bug; delete the file rather than emptying it. setup.cfgcannot hold every value safely. Its parser mangles some characters inaddopts, which is why pytest's own documentation discourages it for new projects.addoptsaccumulates across files, but only one file is read, so a value you expect to inherit from a parent directory is not inherited at all.- Editors and formatters may reorder TOML tables but never merge them; two
[tool.pytest.ini_options]tables in one file is a TOML syntax error, not a merge. - CI images sometimes ship a stray
tox.ini. When a setting works locally and not in CI, the header line in the CI log answers it immediately.
A minimal configuration worth copying
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.
[tool.pytest.ini_options]
minversion = "7.0" # fail loudly on an ancient pytest
addopts = [
"--strict-markers", # an unregistered marker is an error
"--strict-config", # an unknown ini key is an error
"-ra", # summarise every non-passing outcome
"--import-mode=importlib", # no sys.path manipulation at import
]
testpaths = ["tests"]
xfail_strict = true # an xpass fails: fixed tests must be untagged
filterwarnings = ["error::RuntimeWarning"]
markers = ["slow: excluded from the pre-commit run"]
--strict-config is the least known and the most useful of these: without it, a typo in an ini key is silently ignored, so testpath = ["tests"] configures nothing and reports nothing. With it, the run fails at startup with the offending key named.
--import-mode=importlib deserves its own note. The historic prepend mode inserts test directories at the front of 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. 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.
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.
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 -p no:randomly in addopts disables test-order randomisation for everyone, and an over-broad filterwarnings 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.
Frequently Asked Questions
Which file wins if both pytest.ini and pyproject.toml exist?pytest.ini 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 pytest.ini silently disables the settings in pyproject.toml.
Does the config file location change rootdir?
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.
Are the values written differently in the two formats?
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.
Can different directories have different pytest configuration?
Not through configuration files — pytest reads exactly one. Per-directory behaviour comes from conftest.py (fixtures, hooks, collect_ignore) or from separate invocations with their own -p and -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.
How do I override a single ini value for one run?
Use -o key=value on the command line, which takes precedence over the file: 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 -o changes the behaviour and the file does not, the file is not the one pytest is reading.
Related
- Pytest configuration best practices — what belongs in the file once you have chosen it.
- Pytest markers for conditional test execution — marker registration, one of the settings most often lost to the empty-ini trap.
- Cutting collection time with norecursedirs —
testpathsand friends, whose relative paths depend on rootdir. - Managing conftest hierarchies — rootdir also anchors the conftest walk.
← Back to Pytest Configuration Best Practices