Skip to content
← All articles

How Python actually resolves an import

Every ModuleNotFoundError-but-the-file-is-right-there, every tests-import-a-different-copy, and every conftest.py with sys.path.insert traces back to six rules.

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:

  1. Checks sys.modules. If the fully-qualified name is already there, it is returned immediately — even if the value is None, which raises. This is a cache and it is global and mutable, which is why one test that stuffs a fake into sys.modules can poison every test after it.
  2. Imports the parent first. mypkg.core requires mypkg, which is imported (and cached) before core is even looked for.
  3. Walks sys.meta_path. These are the meta path finders. By default: the builtin-module finder, the frozen-module finder, and PathFinder. An import hook — a coverage tool, an import-time patcher, a lazy-loading shim — is an object inserted here.
  4. PathFinder walks sys.path. For each entry it gets (or builds and caches in sys.path_importer_cache) a path entry finder and asks it for the module. Directories get a FileFinder; a .zip gets zipimport.
  5. The first finder to return a spec wins — with one exception, the namespace portion, covered in the previous item.
  6. The loader executes the module into a fresh module object, which is inserted into sys.modules before 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.