sys.path.insert(0, ...) in a conftest.py is a confession. It says: I do
not know why my import does not resolve, and this made it stop complaining.
It survives review because nobody else knows either.
The rules are short. Learn them once and that line never gets written again.
The six steps
When you write import mypkg.core, the interpreter:
-
Checks
sys.modules. If the fully-qualified name is already there, it is returned immediately — even if the value isNone, which raises. This is a cache and it is global and mutable, which is why one test that stuffs a fake intosys.modulescan poison every test after it. -
Imports the parent first.
mypkg.corerequiresmypkg, which is imported (and cached) beforecoreis even looked for. -
Walks
sys.meta_path. These are the meta path finders. By default: the builtin-module finder, the frozen-module finder, andPathFinder. An import hook — a coverage tool, an import-time patcher, a lazy-loading shim — is an object inserted here. -
PathFinderwalkssys.path. For each entry it gets (or builds and caches insys.path_importer_cache) a path entry finder and asks it for the module. Directories get aFileFinder; a.zipgetszipimport. - The first finder to return a spec wins — with one exception, the namespace portion, covered in the previous item.
-
The loader executes the module into a fresh module object, which is
inserted into
sys.modulesbefore execution begins. That ordering is precisely what makes circular imports partially work.
Six steps, and almost every real import problem is step 4 with the wrong
sys.path.
What seeds sys.path[0], by invocation
This is the part people get wrong, because it is different for each way of starting the interpreter:
| Invocation | What is prepended |
|---|---|
python script.py |
the script’s directory, absolute, symlinks resolved |
python -m pkg.mod |
the current working directory |
python -c "..." |
'' — the empty string, meaning “cwd, resolved at import time” |
| REPL / stdin |
'' |
python somedir/ or python app.zip |
the directory or zip name itself |
Two of those bite regularly.
python -m prepends the cwd, not the script’s directory. So python -m mypkg.cli run from /home/you and from /srv/app are genuinely different
programs, and a cron job that forgets to cd first can import a different
package than the one you tested.
'' is not the same as an absolute cwd. It is resolved each time an import
is attempted, so a program that changes directory mid-run changes where its
later imports come from. Absolute entries do not do that.
💡Your CLI works as python -m mypkg.cli from the project root and fails with ModuleNotFoundError: No module named 'mypkg' from anywhere else. The package is installed. What is actually going on?
click to reveal
It is almost certainly not installed — or not installed into the interpreter you are running.
From the project root, -m prepends the cwd, and the flat-layout working
tree supplies mypkg. From anywhere else that entry is a different
directory, the working tree is no longer reachable, and the import falls
through to site-packages — where there is nothing.
Confirm it in one command: python -c "import mypkg; print(mypkg.__file__)"
from the project root. If the path is inside your source tree rather than
inside site-packages, that is the answer. python -P -c "import mypkg"
from the project root gives you the same answer as a pass/fail.
The fix is pip install -e . (or uv sync), not a sys.path line.
Turning the seeding off
Python 3.11 added -P and PYTHONSAFEPATH=1, which suppress that leading
entry entirely — every row of the table above becomes “nothing is prepended”.
python -I (isolated mode) implies -P, along with -E (ignore PYTHON*
environment variables) and -s (ignore the user site-packages directory).
For a program that must not be influenced by whatever directory it was
launched from — anything running as a service, in a container, or from cron —
-P is the difference between “imports the code we shipped” and “imports
whatever was lying around”.
Absolute imports, relative imports, and __main__
Two more rules and you have the whole picture.
All imports are absolute unless written with a leading dot. import utils inside mypkg/core.py does not find mypkg/utils.py; it looks for
a top-level utils. Implicit relative imports were removed in Python 3.
from . import utils resolves against __package__. The dot is
interpreted relative to the module’s package — not its directory, not the
filename. And here is the trap:
$ python mypkg/core.py
ImportError: attempted relative import with no known parent package
A module run as a script is called __main__ and has no package, so there is
nothing for the dot to be relative to. The same file imported as
mypkg.core, or run as python -m mypkg.core, works perfectly. Nothing
about the file changed — only what the interpreter called it.
💡A file inside a package ends with if __name__ == "__main__": main(), and running it directly raises attempted relative import with no known parent package. A teammate proposes changing the relative imports to absolute ones. Does that work, and is it the right fix?
click to reveal
It usually does work — and it is usually the wrong fix, because it treats the symptom.
It works because an absolute from mypkg import utils does not need
__package__; it needs mypkg to be importable, which it is if the project
is installed. But it also means the file now has two different identities at
runtime: imported as mypkg.core it is one module object, and run as a
script it is a second module object named __main__ with its own copies of
every module-level value. Class identity breaks, module-level caches
duplicate, and isinstance starts returning False for objects that look
identical.
The right fix is to stop running the file directly. Use python -m mypkg.core
(which sets __package__ correctly), or better, give the package a
__main__.py and a [project.scripts] entry point so there is exactly one
supported way in.
The rule that replaces sys.path.insert
If an import does not resolve, the question is never “how do I add a directory”. It is: which distribution is supposed to provide this module, and is it installed in this interpreter? Answer that and the path takes care of itself.
The problem below asks you to reproduce the seeding table exactly. It is eleven lines of code, and it is the piece of the model people are missing when they reach for the insert.