ImportError while loading conftest, import file mismatch, ModuleNotFoundError: No module named 'myapp', a test that passes locally and fails in CI because a different copy of the package was imported: all four are the same problem wearing different hats. pytest's import behaviour is a small set of rules interacting with the shape of the repository, and once the rules are explicit the errors stop being mysterious.
Prerequisites
pytest >= 8.0;importmode=importlibhas existed since 6.0 but became reliably usable for most layouts in 8.- A packaging setup that can install the project —
pyproject.tomlwith any modern backend. - Python 3.9+, and an understanding of what
sys.pathordering means for imports. - The configuration file hierarchy covered in pyproject.toml vs pytest.ini.
Core concept: two decisions, four common outcomes
Everything here follows from two independent choices: where the package sits relative to the repository root, and which import mode pytest uses.
Layout. A flat layout puts myapp/ at the repository root next to tests/. A src layout puts it at src/myapp/. The difference is whether the repository root — which pytest and Python both tend to put on sys.path — contains an importable copy of the package.
Import mode. Under prepend, pytest inserts the test file's rootdir-relative first non-package parent into sys.path[0] and imports the module under a name derived from its path. Under importlib, pytest imports the file directly through the import system without touching sys.path at all.
Step-by-step implementation
1. Move to a src layout
repository/
├── pyproject.toml
├── src/
│ └── myapp/
│ ├── __init__.py
│ └── billing.py
└── tests/
├── conftest.py
├── billing/
│ └── test_invoice.py
└── api/
└── test_invoice.py ← same basename, different directory
With src/, the repository root contains no importable myapp, so import myapp can only resolve through the installed distribution. That single property removes an entire class of "works locally, fails in CI" failure, because locally and in CI now import the same thing.
2. Install the package rather than relying on path insertion
pip install -e . # editable install: src/myapp becomes importable everywhere
An editable install puts the package on sys.path through the mechanism packaging provides, rather than through pytest's path manipulation. Tests then import myapp exactly the way a user would.
3. Declare the configuration explicitly
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
# importlib: no sys.path insertion, no module-name collisions, no __init__.py.
addopts = "--import-mode=importlib"
testpaths stops collection depending on the directory the command was run from. The import mode set in configuration rather than on the command line means every invocation — local, CI, an IDE's test runner — behaves the same.
4. Delete the __init__.py files from the test tree
Under importlib they are unnecessary, and leaving them creates a package whose name can collide with a real one. Under prepend they are mandatory wherever two test files share a basename, which is why suites that stay on prepend end up with them everywhere.
5. Verify from more than one directory
pytest -q # from the repository root
(cd tests/billing && pytest -q) # from a subdirectory
python -c "import myapp; print(myapp.__file__)"
/home/dev/project/.venv/lib/python3.12/site-packages/myapp/__init__.py
The last command is the check that matters. If it prints a path inside the working tree rather than inside the environment, something is shadowing the installed package and the suite is not testing what will ship.
Verification
Two properties are worth asserting continuously rather than checking once.
# tests/test_packaging.py
import pathlib
import sysconfig
import myapp
def test_imports_the_installed_distribution():
"""Guards against a path insertion shadowing the installed package."""
site_packages = pathlib.Path(sysconfig.get_paths()["purelib"]).resolve()
module_path = pathlib.Path(myapp.__file__).resolve()
assert site_packages in module_path.parents, f"imported {module_path}"
# Collection must be identical from anywhere in the tree.
pytest --collect-only -q | md5sum
(cd tests && pytest --collect-only -q | md5sum)
Matching checksums mean testpaths and the import mode are doing their job. Differing ones mean collection depends on the working directory, and somebody's IDE will eventually run a different set of tests from CI.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
import file mismatch | Two test files share a basename in non-package directories | --import-mode=importlib, or add __init__.py |
ModuleNotFoundError: myapp | Package never installed; relying on rootdir insertion | pip install -e . |
| Local pass, CI fail on a changed module | Flat layout shadowing the installed copy | Move to a src layout |
ImportError while loading conftest | conftest.py imports something only on the local path | Import through the installed package |
| IDE runs a different test set | Collection rooted at a different directory | Set testpaths in configuration |
attempted relative import with no known parent package | Test file imported as a top-level module | Use absolute imports in tests |
What prepend mode actually does
Understanding the default explains almost every mismatch error. For each test file, pytest walks upward from the file's directory for as long as it finds __init__.py, and stops at the first directory that has none. That directory is the basedir. pytest inserts the basedir at sys.path[0] and imports the file under the dotted name formed from its path relative to the basedir.
So tests/billing/test_invoice.py with no __init__.py anywhere becomes module test_invoice, with tests/billing on the path. And tests/api/test_invoice.py becomes module test_invoice as well — the same name, already in sys.modules, pointing at a different file. pytest detects the mismatch and raises rather than silently running the wrong file, which is the error everyone has seen.
Adding __init__.py to tests/, tests/billing/ and tests/api/ changes the basedir to the repository root and the module names to tests.billing.test_invoice and tests.api.test_invoice, which are distinct. That works, and it is why the convention exists. The cost is that the test tree is now a package whose name is tests, which collides with any other installed distribution named tests and which must be excluded from the built wheel.
importlib mode sidesteps the whole mechanism: the file is imported by location, given a unique name, and nothing is added to sys.path. The historic reason not to use it was that test modules could not import each other by name; since pytest 8 the consider_namespace_packages option and improved name resolution make that rare enough that importlib is the better default for a new project.
Where conftest.py is found, and where it is not
conftest.py follows its own rules, separate from the import mode, and they explain a second family of errors.
pytest collects conftest.py files from the rootdir down to each collected test file's directory, and applies them in that order: the root one first, the nearest one last. A fixture defined in a nearer file overrides one of the same name defined further up. Crucially, a conftest.py in a sibling directory is never consulted — tests/api/conftest.py has no effect on tests/billing/, however much it looks like shared configuration.
One further rule catches people out. conftest.py files are imported before collection of the directory they govern, and they are always rewritten for assertions, but they are not affected by --import-mode in the same way test modules are — pytest imports them by path under a unique name regardless. That is why a conftest.py can safely exist in every directory without __init__.py, even under prepend, while two test_invoice.py files cannot.
Organising the tree once imports are settled
With imports out of the way, the layout question becomes purely about navigation, and two conventions do most of the work.
Mirror the package. tests/billing/test_invoice.py for src/myapp/billing/invoice.py means a reader can find a module's tests without searching, and a reviewer can see at a glance that a changed module has no test changes. It also makes coverage gaps visible as missing directories.
Separate by cost, not by philosophy. A top-level split between fast tests and those needing a database or a network is worth having, because it is the split CI uses. Splitting by "unit" and "integration" as categories tends to produce arguments about which a given test is; splitting by "needs Docker" and "does not" produces none, because it is a fact about the test rather than an opinion.
tests/
├── conftest.py # shared, cheap fixtures only
├── unit/ # no I/O; runs on every push
│ └── billing/test_invoice.py
└── integration/ # needs the container stack
├── conftest.py # database and service fixtures
└── billing/test_invoice.py
Placing the expensive fixtures in tests/integration/conftest.py rather than the root one matters more than it looks: a session-scoped container fixture defined at the root is collected for every run, and an accidental autouse or an import at module level will start Docker even for pytest tests/unit. Keeping it in the subtree that needs it means the fast suite never touches it, a property covered further in managing conftest hierarchies.
Monorepos and multiple distributions
A repository containing several installable packages multiplies every decision above, and the failure mode is subtle: one package's tests silently importing another package's working copy instead of its installed version.
repository/
├── pyproject.toml # workspace root: dev tooling only
├── packages/
│ ├── billing/
│ │ ├── pyproject.toml
│ │ ├── src/billing/
│ │ └── tests/
│ └── notifications/
│ ├── pyproject.toml
│ ├── src/notifications/
│ └── tests/
└── pytest.ini # rootdir anchor for whole-repo runs
Each package keeps its own src layout and its own tests, and every package is installed editable into the shared environment. A whole-repository run then collects every tests/ directory, and each test imports through the installed distributions rather than through path insertion — which is what makes a change to billing correctly break notifications' tests when it should.
Two configuration details keep this working. The root configuration file sets testpaths = ["packages"] so that running pytest from the root behaves the same as running it from a package. And each package's pyproject.toml declares its dependency on the others by name, so the environment resolves the graph rather than relying on everything happening to be on the path.
The temptation to avoid is a single conftest.py at the repository root providing fixtures for every package. It creates a dependency from each package's tests to the root of the monorepo, which means a package can no longer be tested in isolation — exactly the property a monorepo is supposed to preserve. Shared fixtures belong in a small installed package of their own, imported explicitly by whoever needs them, as described in sharing fixtures without conftest.py.
Testing the installed artefact, not the working copy
The strongest argument for the whole arrangement is that it makes one specific test possible: running the suite against the built wheel rather than the source tree.
python -m build # produces dist/myapp-1.4.0-py3-none-any.whl
python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl pytest
cd /tmp && /tmp/verify/bin/pytest --import-mode=importlib /path/to/repo/tests
Running from /tmp guarantees the working tree is nowhere near sys.path, so the only importable myapp is the one inside the wheel. This catches the packaging mistakes that no amount of source-tree testing can: a subpackage missing from the wheel because it had no __init__.py, a data file not declared in the package data, a module that imported successfully only because a sibling directory happened to be adjacent.
It is worth running on every release and cheap enough to run nightly. Teams that ship libraries tend to add it after the first bug report that says "it works when I clone the repository but not when I pip install it", which is always a packaging problem and never a code one.
Migrating an existing suite
Changing layout and import mode in a live repository is a mechanical change with one sharp edge, so the order matters.
Move to the src layout first, in its own change, with pip install -e . added to the contributing instructions and to every CI job. This is the step most likely to break something — a script that did from myapp import x relying on the working directory, a tool configured with a relative path — and isolating it makes the breakage attributable.
Switch the import mode second. Add --import-mode=importlib, run the suite, and expect failures only where test modules import each other by bare module name (from test_helpers import make_order). Those should become imports from a proper helper package under tests/ that is installed or path-configured deliberately, which is a better arrangement anyway.
Delete the __init__.py files last, once the suite is green under the new mode, so that reverting the mode change remains possible up to that point. Doing all three at once produces a change nobody can bisect, and these failures are exactly the kind where bisecting is the fastest route to the cause. The specific errors and their fixes are catalogued in fixing import file mismatch errors in pytest.
A checklist that prevents recurrence
Import problems come back whenever a repository changes shape, so the durable fix is a handful of assertions about the project rather than a one-off cleanup.
The package is installed, not merely present: pip install -e . in the contributing instructions and in every CI job, with the packaging test above guarding it. The configuration is explicit: testpaths and --import-mode live in pyproject.toml, never in a shell alias or an IDE setting where only one person has them. The test tree is not a distribution: no __init__.py, excluded from the wheel, and containing nothing another package imports. And collection is location-independent, verified by the two-directory checksum comparison.
Four properties, each checkable in seconds, and between them they cover every failure in the troubleshooting table above. Writing them into a short note beside the configuration is worth more than fixing the errors individually, because the next person to add a package or move a directory reads the note instead of rediscovering the rules from the error messages.
Frequently Asked Questions
What actually causes 'import file mismatch' in pytest?
Two test files with the same basename in directories that are not packages. Under the default prepend import mode pytest derives a module name from the basename alone, so the second file collides with the first in sys.modules. Adding __init__.py files makes the names unique, and importmode=importlib removes the collision entirely.
Should tests live inside the package or beside it?
Beside it, in a top-level tests directory, when the package uses a src layout. Tests inside the package ship to users, get imported by anything that imports the package, and make it impossible to test the installed distribution rather than the working copy. Ship tests inside the package only when downstream consumers are meant to run them.
Do test directories still need init.py files?
Not with importmode=importlib, which is the reason to switch. Under the default prepend mode they are needed wherever two test files could share a basename, which in a suite of any size is everywhere. Choosing importlib and deleting them is usually the simpler answer.
Why does pytest import a different copy of my package than I expect?
Because rootdir insertion put the working directory on sys.path ahead of site-packages. With a flat layout the local directory shadows the installed distribution, so you test the working copy even when you meant to test the wheel. A src layout makes this impossible, which is its main argument.
What is rootdir used for, exactly?
It anchors relative paths in configuration, cache locations and node identifiers. It does not by itself control imports. Import behaviour comes from the import mode and from which directories pytest inserts into sys.path, which are related to rootdir but not the same thing.
Related guides
- Diagnose the specific error in fixing import file mismatch errors in pytest.
- Make the switch deliberately with using importmode=importlib without init.py files.
- Keep expensive fixtures out of the fast suite using managing conftest hierarchies.
- Cut collection cost once the tree is settled with optimizing test discovery.
- Put the configuration in the right file per pyproject.toml vs pytest.ini.