QA How-To
pytest.ini vs pyproject.toml: Where to Configure pytest
Compare pytest ini vs pyproject toml syntax, precedence, and migration with runnable tests. Choose the right configuration file and verify what pytest loads.
18 min read | 3,122 words
TL;DR
Keep pytest.ini for a dedicated test configuration or older supported runners. Use pyproject.toml when your project centralizes tooling there, starting with [tool.pytest.ini_options] unless all runners support native [tool.pytest]. Pytest selects one config file, so verify the header and collection during migration.
Key Takeaways
- Check the pytest header to see the actual configfile and rootdir before editing settings.
- Use one authoritative pytest configuration because candidate files do not merge.
- An empty pytest.ini can take precedence over pyproject.toml.
- Use [tool.pytest.ini_options] when your supported runners need the TOML compatibility format.
- Use native [tool.pytest] only after verifying every runner supports it.
- Compare collected node IDs and marker selection before and after a migration.
Pytest.ini vs pyproject.toml is a choice about where your team keeps pytest settings, how those settings are written, and which file pytest actually loads. For most Python projects already using pyproject.toml, put pytest under [tool.pytest.ini_options] if you need broad pytest compatibility. Keep pytest.ini when a dedicated, immediately visible test configuration is useful or your installed pytest cannot read the TOML form. If both files exist, inspect the selected configfile before changing settings: pytest does not combine them.
This guide gives you two equivalent configurations for one tiny checkout suite, then demonstrates migration, precedence, and a newer native TOML option. Every step includes a command and an observable result. You can run the example without a web service, database, browser, or third-party pytest plugin.
TL;DR
| Decision point | pytest.ini |
pyproject.toml |
|---|---|---|
| Section | [pytest] |
[tool.pytest.ini_options], or [tool.pytest] with pytest that supports native TOML |
| Values | INI text and multiline entries | Quoted TOML strings and arrays; native table also supports TOML value types |
| File selection | Wins over pyproject.toml at the same search location, even if empty |
Selected when no higher-priority config wins |
| Best fit | Separate test policy, simple existing suite, older pytest installations | One project configuration hub, tooling already in TOML |
| Migration risk | An old copy can shadow new TOML settings | A wrong table or invalid TOML can stop or misdirect a run |
Verdict: Use one authoritative pytest configuration. For an existing pyproject.toml, the compatibility table is the safest default until your supported pytest versions are known. Choose the native [tool.pytest] table only when every runner supports it. The official pytest configuration reference documents the file search order and supported sections.
What You Will Build
- A three-test checkout example with a registered
smokemarker. - A
pytest.iniconfiguration and an equivalent[tool.pytest.ini_options]configuration. - A repeatable check of the loaded
configfile, root directory, and selected tests. - A migration procedure that exposes a shadowing
pytest.inibefore removing it. - An optional native TOML comparison for teams whose installed pytest supports that format.
The final working state uses pyproject.toml. You can stop after the INI step if that file is the better fit for your repository. Run the commands from the example project root unless a step says otherwise.
Prerequisites
Use an installed Python interpreter with venv and a pytest release supported by your team. Check the actual version instead of copying a package pin from an article. [tool.pytest.ini_options] is supported by pytest releases that understand pyproject.toml; native [tool.pytest] is documented for pytest 9.0 and later. If your organization runs older pytest versions, check its lowest supported runner before moving settings. The pytest tutorial for beginners covers discovery and assertion basics if those are new to you.
On macOS or Linux, make an isolated directory and install pytest in a virtual environment:
mkdir pytest-config-demo
cd pytest-config-demo
python3 -m venv .venv
. .venv/bin/activate
python -m pip install pytest
python --version
python -m pytest --version
On Windows, create the environment with py -m venv .venv, activate it using your shell's syntax, and use python -m pytest for the remaining commands. Record the version reported by the last command. A real project should pin and lock the pytest release it has tested; this example deliberately leaves the package version to your environment.
Step 1: Create a Suite Before Choosing Pytest.ini vs Pyproject.toml
Make a tests directory and save checkout.py in the project root. The function accepts integer cents, rejects negative line items or discounts, and never returns a negative payable total. The integer representation keeps the example about configuration rather than floating-point money rules.
mkdir tests
# checkout.py
def payable_total(prices: list[int], discount: int = 0) -> int:
if any(price < 0 for price in prices) or discount < 0:
raise ValueError("prices and discount must be nonnegative")
return max(0, sum(prices) - discount)
Save this test module as tests/test_checkout.py. No custom mark is present yet, so collection works before either configuration file exists.
# tests/test_checkout.py
import pytest
from checkout import payable_total
def test_regular_order():
assert payable_total([1200, 300]) == 1500
def test_discounted_order():
assert payable_total([1200, 300], discount=200) == 1300
def test_negative_price_is_rejected():
with pytest.raises(ValueError, match="nonnegative"):
payable_total([-1])
Verify: Run python -m pytest -q tests/test_checkout.py. Expect three passes. Then run python -m pytest --collect-only -q tests/test_checkout.py; expect three node IDs. An explicit file path is intentional here: it proves the code works before testpaths changes default collection. If import fails, confirm that checkout.py and tests/ are under the current directory and invoke pytest through the active environment's Python.
Step 2: Configure pytest.ini and Inspect the Active File
Create pytest.ini beside checkout.py. The [pytest] header is required. testpaths tells a no-argument run where to collect, while addopts adds reporting and strict marker checks to every run. The marker declaration documents a future smoke group. It does not select or skip tests by itself.
# pytest.ini
[pytest]
testpaths =
tests
addopts = -ra --strict-markers
markers =
smoke: core checkout rules required on every change
INI uses an indented continuation for the lists shown here. The addopts value is a command-line string, and markers entries are descriptions, not Python expressions. Keep options scoped to pytest; this file is not a place for build-system metadata or another tool's configuration. A small project often benefits from how visible this dedicated file is in the root directory.
Verify: Run python -m pytest without -q and read the header. It should show configfile: pytest.ini, rootdir at the example directory, and three passes. Run python -m pytest --markers and find the smoke description. Finally, python -m pytest --collect-only -q should list the same three tests with no explicit path. The pytest BDD tutorial shows where registration and selection matter when a suite includes scenario markers.
If your header names a different file, stop and investigate the actual directory, an ancestor configuration, or a -c argument supplied by a wrapper. The header is stronger evidence than the file you remember editing. A pytest.toml or .pytest.toml in the same location has even higher selection priority in pytest versions that recognize it.
Step 3: Prove Marker Configuration Controls Collection
Replace tests/test_checkout.py with the marked version below. Only the two successful purchase paths belong to the smoke slice. The validation test remains unmarked so you can see a real deselection count. The assertions and function signature match the previous step.
# tests/test_checkout.py
import pytest
from checkout import payable_total
@pytest.mark.smoke
def test_regular_order():
assert payable_total([1200, 300]) == 1500
@pytest.mark.smoke
def test_discounted_order():
assert payable_total([1200, 300], discount=200) == 1300
def test_negative_price_is_rejected():
with pytest.raises(ValueError, match="nonnegative"):
payable_total([-1])
The strict marker option makes an accidental decorator such as @pytest.mark.smkoe a collection error. Registration and filtering are separate operations: the ordinary suite still executes all three tests, and -m smoke selects two. That distinction matters in CI because a green filtered job is evidence only for its selected tests.
python -m pytest -q
python -m pytest -q -m smoke
python -m pytest --collect-only -q -m smoke
Verify: Expect three passes from the first command, two passes plus one deselected from the second, and two collected node IDs from the third. Read the selected test names rather than trusting only the count. If the marker expression selects zero, inspect the decorators and active config header; an absent registration may mean another file won discovery. For a broader view of filtered pipeline gates, see the test automation CI/CD guide.
Step 4: Translate the Same Settings to pyproject.toml
Create pyproject.toml in the same root. Keep pytest.ini for the moment so you can prove the precedence rule before migration. Under [tool.pytest.ini_options], testpaths and markers are TOML arrays, while addopts can remain a string. TOML requires quoted strings and explicit brackets; copying the INI block verbatim is not valid TOML.
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"smoke: core checkout rules required on every change",
]
This table provides INI-style pytest options inside a TOML file. The name ini_options describes the compatibility interface; it does not mean the file is parsed as INI. Other tools can own their own [tool.<name>] tables in the same pyproject.toml, which is a practical reason to consolidate. Do not put pytest keys under an unrelated table or at the TOML top level.
Use -c to force this file while pytest.ini still exists. It is a diagnostic and an explicit choice for the current invocation, not a merge of both configurations.
python -m pytest -c pyproject.toml -q
python -m pytest -c pyproject.toml -q -m smoke
Verify: The first command should show three passes and the second two passes with one deselected. Run python -m pytest -c pyproject.toml without quiet output and confirm configfile: pyproject.toml. If either command fails to parse TOML, check quote characters, commas between array items, and the exact [tool.pytest.ini_options] spelling. For details on the runner's configuration search, use the official pytest file format guide.
Step 5: Test Pytest.ini vs Pyproject.toml Precedence, Then Complete the Migration
Run pytest normally while both files exist. The result can be deceptively green because the two files currently have equivalent options. The header still identifies pytest.ini. If you changed only pyproject.toml and expected behavior to change, this is the reason it did not. Pytest chooses a configuration file; it does not layer settings from both.
python -m pytest
python -m pytest -c pyproject.toml
Verify: The first header should name pytest.ini; the second should name pyproject.toml. Both should run three tests. In a real migration, compare marker registration, warning filters, discovery paths, and addopts before deleting or renaming the old file. Treat an empty pytest.ini as active too: its presence alone can win over pyproject.toml at the same search location.
Now move the INI file out of pytest's recognized names. The backup is a teaching aid; in a repository, remove the old tracked file in the same change that adds the TOML configuration after reviewing its contents.
mv pytest.ini pytest.ini.backup
python -m pytest
python -m pytest -q -m smoke
Verify: The unfiltered header should now report configfile: pyproject.toml and three passes. The filtered run should still select two tests. The backup filename is not one of pytest's recognized configuration names, so it cannot shadow the new table. Check this state in CI as well as locally; a job that runs from a different directory or supplies -c may choose differently.
Step 6: Compare INI Compatibility With Native TOML
The compatibility table is a sound default. Pytest 9.0 introduced native TOML configuration under [tool.pytest], and also supports a dedicated pytest.toml file. Native configuration accepts TOML types directly; addopts can be an array of separate arguments rather than one string. Do not put [tool.pytest] and [tool.pytest.ini_options] in the same pyproject.toml: pytest's documentation says the two tables cannot be used together.
If every developer and CI runner reports a pytest version that supports native TOML, replace the compatibility table with this complete alternative. Keep the exact marker description so the suite's behavior stays comparable.
# pyproject.toml, native alternative
[tool.pytest]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"smoke: core checkout rules required on every change",
]
Verify: First run python -m pytest --version on the interpreter that will execute tests. After switching the table, run python -m pytest and python -m pytest -q -m smoke. Expect the same three-pass full run and two-pass smoke run. If any required runner lacks native TOML support, restore the earlier [tool.pytest.ini_options] block and verify again. Do not infer support from your laptop alone when a separate CI image installs dependencies independently.
This native option does not make pytest.ini obsolete. A team may prefer a dedicated test file even with current pytest. If you choose pytest.toml instead, remember it outranks both files discussed here in current pytest, even when empty. The comparison is about your team's supported tools and ownership, not a universal winner.
Step 7: Check Overrides Without Hiding the Source of Truth
A command can override selected settings for one run. Use -c when diagnosing which configuration file is loaded, and -o name=value when checking the effect of a particular option. These are useful for experiments, but repeated overrides in CI can make the repository file misleading. Keep permanent policy in the chosen file and make exceptional command-line choices visible in the job definition.
With either TOML table from the previous steps active, run the commands below. The first shows the normal smoke subset. The second temporarily clears addopts while still requesting the smoke subset explicitly. The third bypasses the default testpaths choice with a direct file path.
python -m pytest -q -m smoke
python -m pytest -o addopts= -q -m smoke
python -m pytest -q tests/test_checkout.py
Verify: Expect two passes and one deselected in the first two runs, then three passes in the explicit-path run. The -o addopts= check does not delete the setting from pyproject.toml; the next ordinary run uses it again. Check python -m pytest -h for options supplied by the pytest installation and its plugins. Use --collect-only -q when a selection filter changes, since a passing job with the wrong collected set offers weak coverage evidence.
Neither rootdir nor the current working directory should be mistaken for an import fix. Pytest documents that rootdir helps form node IDs and locate cache data; it does not itself add a package to Python's import path. If an existing src/ layout cannot import the application, solve packaging or import-path setup deliberately. The small example here keeps checkout.py in the root so python -m pytest from that directory works without extra path configuration.
Step 8: Make the Choice Reproducible in CI
A local migration is incomplete until the pipeline uses the same interpreter, config file, and collection rules. Put the selected pytest release in your project's dependency lock or environment specification. Run from the repository root, invoke pytest through that environment's Python, and keep the command short enough that a teammate can reproduce it. Do not add -c to every job just to hide an old recognized config file; remove the stale file after the migration is reviewed.
python -m pytest --version
python -m pytest --collect-only -q
python -m pytest -q
python -m pytest -q -m smoke
Verify: The collection command should name three tests. The full command should pass all three; the smoke command should pass the two marked cases and report one deselected. Run a non-quiet python -m pytest once in the CI job setup or capture its header so the loaded configfile is visible in logs. If your suite is much larger, compare node IDs and counts before and after the migration, not just exit code.
Avoid hard-coding a version copied from a tutorial into a container tag or package pin. Match the version used by your actual lockfile, developer environments, and CI image. The flaky test quarantine in CI guide helps separate configuration changes from genuinely intermittent tests when a migration surfaces failures. Changing the config can alter what is collected, so a new failure is a signal to inspect collection before classifying it as flakiness.
Which Should You Choose
Choose pytest.ini if pytest is the main tool requiring project settings, the team values a dedicated and easy-to-find test policy, or a supported runner cannot read the TOML form you want. Its compact syntax works well for a small number of options. It is also a sensible temporary home while you audit a repository that already has conflicting configuration files. The cost is another root-level file and the possibility of forgetting that it outranks pyproject.toml.
Choose pyproject.toml when the project already centralizes tool settings there and every required pytest installation supports the selected table. Use [tool.pytest.ini_options] for the widest compatibility among the TOML choices. Use native [tool.pytest] when all runners support it and your team wants native TOML values. In either case, commit one authoritative pytest section and verify the file name in the runner header. A Python project does not need to move its pytest settings merely because pyproject.toml exists.
For a monorepo, decide whether one root policy covers every package. Pytest searches from the arguments' common ancestor upward, and nested configuration can change the root and node IDs. An explicit -c path/to/config may be appropriate for a package-specific job if the job documents that choice. Test each package from the same working directory CI uses. There is no automatic merge between a parent pytest.ini and a child pyproject.toml; whichever file discovery selects supplies the options for that run.
Troubleshooting
Problem: edits in pyproject.toml have no effect -> Run non-quiet python -m pytest and read configfile. Rename or remove a stale pytest.ini after comparing its settings. Also check for pytest.toml, an explicit -c, and wrapper scripts that change the working directory.
Problem: TOML parsing fails -> Confirm the table name is [tool.pytest.ini_options] or, on a supporting pytest release, [tool.pytest]. Use double-quoted strings and comma-separated arrays. A multiline INI value pasted beneath a TOML key is not the same syntax.
Problem: smoke is unknown -> Check that the active file registers smoke under markers and that the decorator has the same spelling. Strict marker checking is useful precisely because a typo should fail collection rather than silently changing the selected suite.
Problem: no tests are collected -> Run python -m pytest --collect-only -q tests/test_checkout.py with an explicit path. If that works, inspect testpaths, the current directory, and any name filters or -m expression. If it fails too, investigate import errors and file naming before changing configuration format.
Problem: CI and local runs choose different files -> Compare pytest versions, working directories, command arguments, and files present in each environment. Capture the rootdir and configfile header from both. A local backup named pytest.ini.backup will not be selected, but a tracked empty pytest.ini in CI will.
Problem: native TOML works locally but fails in another runner -> Confirm that runner's pytest version. Restore [tool.pytest.ini_options] until the supported environments have moved together. Do not combine both tables in one file as a compatibility trick.
Interview Questions and Answers
For an interview, explain the observable behavior instead of naming a favorite file. State which file pytest loaded, why it won, how you verified collection, and how you migrated without losing options. The questions in the interviewQnA field below cover these decisions, including empty-file precedence and the difference between compatibility and native TOML.
Common Mistakes
- Keeping both
pytest.iniandpyproject.tomlwhile assuming settings merge. Inspect theconfigfileheader; the chosen file supplies pytest settings. - Editing TOML during a migration while an empty
pytest.inistill shadows it. Remove or rename the stale recognized file after comparing values. - Writing
[pytest]inpyproject.toml. Use[tool.pytest.ini_options], or[tool.pytest]on supported pytest versions. - Using both TOML pytest tables in one file. Pick the compatibility or native table for that file.
- Copying INI syntax into TOML without quoting strings or building arrays. Parse the TOML and run collection before trusting a green test result.
- Treating
testpathsas an import-path setting. It influences default collection; package imports need separate attention. - Comparing only pass counts after moving the config. Compare collected node IDs, deselections, marker registration, and the loaded file.
- Adding permanent
-cor-ooverrides to CI without documenting them. They can conceal a stale repository setting from future maintainers.
Where To Go Next
Apply the migration to one real repository on a small branch. Copy every relevant setting, run a forced -c pyproject.toml check while the old file remains, then remove the old recognized file and compare full collection. Follow with the pytest versus unittest comparison if you are choosing a broader Python testing approach, or the Playwright Python fixtures with pytest guide if your configured suite will include browser tests. For BDD suites, the pytest BDD tutorial shows how scenario organization adds collection concerns.
Conclusion
Pytest.ini vs pyproject.toml has no single answer independent of your installed pytest versions and repository layout. pytest.ini keeps test policy separate and visible. pyproject.toml centralizes settings, with a compatibility table for established pytest support and a native table for runners that support it. Pick one authority, verify the configfile header and collected tests, and make the same check part of CI before calling the migration complete.
Interview Questions and Answers
How do you decide between pytest.ini and pyproject.toml?
I first check the supported pytest versions and the repository's existing tool configuration. If pyproject.toml is already the central tool file, I use its compatible pytest table unless every runner supports native TOML. If a dedicated test policy is clearer or an older runner constrains us, I keep pytest.ini. I verify the chosen file in the pytest header rather than assuming my edit is active.
What happens if both files contain pytest settings?
Pytest selects one file; it does not merge settings. At the same search location, pytest.ini wins over pyproject.toml, even if it is empty. I would compare both files, remove the stale one, and rerun collection to prove the intended policy is active.
What is the difference between the two pytest tables in pyproject.toml?
`[tool.pytest.ini_options]` is the established compatibility format for pytest settings inside TOML. `[tool.pytest]` uses the native TOML model supported by newer pytest releases, including direct TOML types. I choose one based on the oldest supported runner and never define both in one file.
How do you verify a pytest configuration migration?
I run pytest non-quietly to record rootdir and configfile, then compare `--collect-only -q` node IDs before and after. I run the full suite and each important marker filter, checking pass and deselected counts. A matching exit code alone is insufficient because a changed testpaths value can silently alter coverage.
Why can an empty pytest.ini be dangerous?
Pytest treats pytest.ini as a configuration match even when it has no options. It can therefore shadow settings in pyproject.toml and make TOML edits appear ineffective. The header reveals the selected file, and removing the obsolete INI file restores the intended TOML configuration.
When would you use pytest -c or -o?
I use `-c` to force a specific configuration file during diagnosis or a documented package-specific run. I use `-o name=value` to override one setting temporarily and inspect its effect. I avoid relying on hidden permanent overrides because they make repository configuration harder to trust.
Does pytest rootdir control Python imports?
No. Rootdir is used for node IDs and project-specific artifacts such as the cache, while Python import behavior has its own rules. If imports fail after a config migration, I check packaging, working directory, and interpreter setup rather than assuming rootdir changed `sys.path`.
Frequently Asked Questions
Is pytest.ini or pyproject.toml better for pytest?
Use pyproject.toml if your project already keeps tool settings there and all supported pytest installations read the table you choose. Use pytest.ini for a separate test policy or when older supported runners require it. The decisive check is which configfile pytest actually loads in each environment.
Does pytest combine pytest.ini and pyproject.toml settings?
No. Pytest selects one configuration file during discovery and does not merge options from the other candidate files. A green test run can still be using the wrong file, so read the non-quiet run header.
Which file wins when both pytest.ini and pyproject.toml exist?
At the same search location, pytest.ini takes precedence over pyproject.toml even if the INI file is empty. Current pytest also recognizes pytest.toml, which has higher priority than pytest.ini. Use `python -m pytest` without quiet output to see the selected configfile.
What section should pytest use in pyproject.toml?
Use `[tool.pytest.ini_options]` for the established INI-compatible form. Pytest releases supporting native TOML can use `[tool.pytest]` instead. Do not place both pytest tables in the same file.
How can I test pyproject.toml before removing pytest.ini?
Run `python -m pytest -c pyproject.toml` to force the TOML file for that invocation. Inspect the configfile header and compare collection and selected tests with the existing INI run. After migration, remove or rename the old recognized file and repeat the ordinary command.
Does testpaths fix Python import errors?
No. `testpaths` chooses default locations to search for tests when no path is supplied. It does not make application modules importable; solve package installation or Python path setup separately.
Can I use native TOML on every pytest installation?
No. The native `[tool.pytest]` table is documented for pytest 9.0 and later. Check the versions used by developers and CI, then retain `[tool.pytest.ini_options]` if any required runner lacks native support.