Skip to content

Python ModuleNotFoundError: No Module Named 'x'

ModuleNotFoundError: No module named 'x' almost never means the package isn't installed — it means pip and python disagree about which interpreter owns it. Here's how to prove it and fix it.

Python python-errors ModuleNotFoundError imports sys-path virtualenv pip packaging
Bharath G
Reading Progress

On This Page

1. The Error

text

Traceback (most recent call last):
  File "/tmp/modrepro/repro.py", line 1, in <module>
    import requests
ModuleNotFoundError: No module named 'requests'

That's the whole traceback for the simplest case: one file, one bad import, nothing else running. In practice you'll more often see it a few frames deep, raised by a library you didn't write, after pip install supposedly succeeded:

Traceback (most recent call last):
  File "/home/dev/app/main.py", line 3, in <module>
    import pandas as pd
  File "/home/dev/app/.venv/lib/python3.13/site-packages/pandas/__init__.py", line 22, in <module>
    from pandas.compat import (
  File "/home/dev/app/.venv/lib/python3.13/site-packages/pandas/compat/__init__.py", line 25, in <module>
    from pandas.compat.numpy import (
  File "/home/dev/app/.venv/lib/python3.13/site-packages/pandas/compat/numpy/__init__.py", line 4, in <module>
    from pandas.util.version import Version
ModuleNotFoundError: No module named 'numpy'

Same exception, different story: here pandas imported fine, but one of its dependencies is missing from the environment that's actually running — usually because the environment was resolved without extras, or a partial install got interrupted.

ModuleNotFoundError has been a subclass of ImportError since Python 3.6 (it replaced a bare ImportError with no distinguishing type), and the message text — No module named 'x' — has not changed since. Unlike NameError and AttributeError, it never gets a "Did you mean" suggestion in any version through 3.14: the suggestion engine (difflib-based, added for name/attribute lookups) works by fuzzy-matching against a live set of candidate names already in scope. A missing module was never imported, so the interpreter has no candidate list to match against — it can't tell you "did you mean numpy" because it has no idea numpy exists anywhere on disk.

The one thing that does change across versions is not the message — it's when the message fires. That's covered in the version matrix below, because Python 3.15 changes it for imports marked lazy.

2. How to Reproduce It

Minimal case, Python 3.11+, no external dependencies beyond the standard library and venv:

project/
├── .venv/
└── repro.py

python

# repro.py
import requests
python3 -m venv .venv
./.venv/bin/python repro.py

Output:

Traceback (most recent call last):
  File "/tmp/modrepro/repro.py", line 1, in <module>
    import requests
ModuleNotFoundError: No module named 'requests'

Expected — nothing has installed requests yet. Now install it "the obvious way" from a fresh shell, without activating the venv:

pip3 install requests
./.venv/bin/python repro.py

It still fails, with the identical traceback. This is the whole bug, reproduced end to end: pip3 on this machine resolves to /usr/bin/pip3, which installs into the system interpreter's site-packages, not .venv's. Prove it directly:

$ ./.venv/bin/python -c "import sys; print(sys.executable)"
/tmp/modrepro/.venv/bin/python

$ ./.venv/bin/python -c "import sys; [print(p) for p in sys.path]"

/usr/lib/python311.zip
/usr/lib/python3.11
/usr/lib/python3.11/lib-dynload
/tmp/modrepro/.venv/lib/python3.11/site-packages

$ which pip3
/usr/bin/pip3

pip3 and ./.venv/bin/python are two different programs with two different site-packages directories. requests landed in the system one; the venv's sys.path only lists its own. The fix that actually works:

source .venv/bin/activate
python -m pip install requests
python repro.py     # imports cleanly

python -m pip is the important habit: it guarantees pip installs into whatever interpreter you invoked it with, because it runs pip as a module inside that interpreter's process rather than shelling out to a pip/pip3 binary that might resolve anywhere on PATH.

The same mismatch is what's happening when the error is IDE- or notebook-specific rather than terminal-specific — "works when I run it from the terminal, fails in VS Code / PyCharm / Jupyter." Each of those tools picks an interpreter independently of your shell's PATH, and it's frequently not the one you last activated. Confirm it directly rather than guessing:

# run this as a cell in the notebook, or paste into the IDE's debug console
import sys
print(sys.executable)

Compare that path against which python (or ./.venv/bin/python -c "import sys; print(sys.executable)") in your terminal. If they differ, that's the entire bug — the IDE is running a different Python than the one you installed the package into. In VS Code specifically, the bottom-right interpreter picker (or Python: Select Interpreter in the command palette) sets this per-workspace in .vscode/settings.json as python.defaultInterpreterPath; in Jupyter, the kernel is a separate registration (python -m ipykernel install --user --name myenv) that can silently keep pointing at an old or deleted environment.

Environment variables that change this resolution, worth checking when the mismatch isn't obvious: VIRTUAL_ENV (set by activate, read by some tools to detect an active venv), PYTHONPATH (extra directories prepended to sys.path — a stray leftover entry here is a classic source of "it finds the wrong requests"), PYTHONNOUSERSITE (disables the per-user site-packages directory, useful for ruling it out as a source of a shadow copy), and, from Python 3.11 onward, -P / PYTHONSAFEPATH, covered next.

3. Version Behaviour Matrix

The exception type, message text, and the sys.path-resolution mechanics that cause it are unchanged from 3.11 through 3.14 — this is a version-neutral bug class. What differs across versions is the tooling around it:

VersionStatus (per devguide.python.org, checked 2026-09)Relevant to this error
3.10Security-only, EOL 2026-10Baseline behavior; no -P/PYTHONSAFEPATH
3.11Security-only, EOL 2027-10Adds -P / PYTHONSAFEPATH: stops CPython from prepending the script's directory (or cwd, for -c/-m/REPL) to sys.path, which prevents a same-named local file or directory from shadowing an installed package of the same name
3.12Security-only, EOL 2028-10distutils removed (PEP 632) — a different ModuleNotFoundError: No module named 'distutils' shows up on old code; that's install-tooling breakage, not this article's interpreter-mismatch class, and deserves its own writeup
3.13Bugfix, EOL 2029-10No change to import resolution; the new REPL and colorized tracebacks make the traceback easier to read but don't change its content
3.14Bugfix (current), EOL 2030-10No change to import resolution for this scenario
3.15Prerelease, final release scheduled 2026-10-01PEP 810 (accepted, implementation complete): a lazy import x statement — opt-in via the lazy soft keyword, a module-level __lazy_modules__ set, or -X lazy_imports=all / PYTHON_LAZY_IMPORTS=all — defers module execution until the name is first used, not when the import line runs. For a lazy import, ModuleNotFoundError fires at the point of first access, with the exception chained to show both where the lazy import was written and where it was triggered. Ordinary, non-lazy imports (everything shown above) are completely unaffected.

Run python -c "import sys; print(sys.version)" if you're unsure which interpreter is actually executing — this whole bug class is, after all, about not knowing that.

4. Why It Happens — Surface Level

There is exactly one mechanical cause: the Python process that ran your import statement looked through its sys.path, checked every directory on it for a matching module or package, and found nothing. That's it. The package being "installed" is irrelevant if it was installed into a different interpreter's site-packages — which is what happens whenever pip, pip3, or an IDE's install button doesn't point at the same Python binary you're about to run the script with.

5. Why It Happens — Under the Hood

sys.path is not one list maintained globally by the OS — it's assembled fresh by each interpreter process at startup, and it's specific to that interpreter's installation. The initialization order (per sys_path_init in the CPython docs) is: the script's directory (or the current working directory, for -c, -m, and the REPL) goes in first as sys.path[0]; then PYTHONPATH; then the installation's standard-library and site-packages directories, added by the site module at startup unless you passed -S. A virtual environment doesn't patch this list into the system Python — it is a separate interpreter (or a symlink to one) with its own pyvenv.cfg, its own lib/pythonX.Y/site-packages, and its own independent path-initialization run. Two Python binaries, /usr/bin/python3 and /tmp/modrepro/.venv/bin/python, produce two completely different sys.path lists even though they're often the same underlying executable — pyvenv.cfg just tells the interpreter "prefer this site-packages, not the one next to you."

pip is a regular Python package, installed into some interpreter's site-packages, that happens to also drop an executable script (pip, pip3, pip3.11, ...) onto PATH. Which pip3 your shell finds is controlled by PATH order — often the system one, if a venv was never activated or a new shell forgot to re-source activate — with zero relationship to which python you intend to run the script with. That's the entire bug: two independent lookups (which python, which pip) that happen to usually agree and occasionally don't. python -m pip sidesteps the ambiguity entirely, because -m makes the interpreter find and run the pip package from its own sys.path, rather than asking the shell to find a pip executable independently.

You can watch this resolution happen instead of guessing at it:

python3 -X importtime -c "import requests" 2>&1 | tail -5

On a working environment this prints the cumulative and self time spent importing each frame of requests and its dependency chain; on a broken one, the only import line for the target module shows a near-zero self time before the traceback — proof the finder never located it, let alone started walking its dependencies:

import time:       105 |        105 | fakemodule123
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'fakemodule123'

importlib.util.find_spec("requests") is the programmatic version of the same check and returns None instead of raising when nothing is found, which is the cleaner way to write a "is this actually importable" probe in a script:

>>> import importlib.util
>>> importlib.util.find_spec("requests") is None
True   # confirms it, without the exception-handling ceremony

6. The Fix

Quick fix — you're already in the right environment, you just used the wrong installer:

- pip3 install requests
+ python -m pip install requests

Run it with the venv activated (source .venv/bin/activate first), or spell out the interpreter explicitly if you don't want to activate anything:

- pip install requests
+ /tmp/modrepro/.venv/bin/python -m pip install requests

Correct fix for an IDE/notebook mismatch — point the tool at the interpreter you actually installed into, rather than installing again and hoping the tool catches up:

# find the interpreter you want (inside the activated venv)
python -c "import sys; print(sys.executable)"
# /tmp/modrepro/.venv/bin/python

In VS Code: Ctrl+Shift+PPython: Select Interpreter → pick that exact path. In Jupyter, register a kernel from inside that environment rather than relying on a global one:

source .venv/bin/activate
python -m pip install ipykernel
python -m ipykernel install --user --name myproject --display-name "Python (myproject)"

Then select "Python (myproject)" as the kernel, not "Python 3 (ipykernel)".

Use when a local file is silently shadowing the real package — if sys.path[0] (your script's own directory) contains a file with the same name as the package you're importing (json.py, random.py, queue.py, or your own requests.py used for something unrelated), Python 3.11+'s -P flag, or setting PYTHONSAFEPATH=1, stops that directory from being prepended, which removes the shadow without renaming anything — good for CI or a wrapper script where you don't control the project layout but want a fast diagnostic.

7. Best Practices & The Better Design

The fix above treats the symptom. The design that prevents the whole class: one virtual environment per project, one lockfile, and never call a bare pip/pip3 on the command line — always python -m pip.

# project setup, once
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt   # or: uv sync, poetry install

Commit a real lockfile (requirements.txt pinned with hashes, uv.lock, or poetry.lock) instead of an unpinned requirements.txt — an unpinned file lets "it's installed" mean a different version, or a different transitive dependency set, on every machine that runs pip install -r. If you use uv, its uv run and uv sync --frozen sidestep the whole activation question by resolving and invoking the project's interpreter explicitly every time, which removes the "which python picked this up" ambiguity at the tool level rather than the habit level.

For layout, prefer a src/ layout with a real pyproject.toml and an editable install (python -m pip install -e .) over sys.path.append(...) hacks or PYTHONPATH=. python script.py workarounds — those work until the second script needs the same package, at which point you're maintaining path manipulation instead of a package:

# pyproject.toml
[project]
name = "myproject"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["requests>=2.31"]

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
myproject/
├── pyproject.toml
├── src/
│   └── myproject/
│       ├── __init__.py
│       └── main.py
└── .venv/

With that layout, python -m pip install -e . from the project root makes import myproject work from anywhere the venv's Python runs, with no PYTHONPATH fragility and no ambiguity about which copy of the code is live.

8. How to Prevent It Long-Term

Make the mismatch impossible to miss instead of hoping to remember it:

  • CI matrix that actually installs fresh. Run at least one job that creates a brand-new venv and installs strictly from the lockfile (pip install --require-hashes -r requirements.txt or uv sync --frozen), with no cached, pre-populated environment — this is what catches "works on my machine" before it reaches anyone else.
  • pip check in CI, after install, to catch a broken or partially-shadowed environment before tests even run.
  • deptry to catch dependencies that are imported but not declared (the inverse failure mode — it works today because something else pulled the package in transitively, and breaks the day that changes).
  • A committed lockfile with hash checking (--require-hashes, or uv.lock / poetry.lock) so "installed" means the same bytes everywhere, not just the same name.
  • A single documented bootstrap command for the team (make setup, uv sync, or a ./scripts/bootstrap.sh) that always creates the venv and always installs with python -m pip — remove the decision point where someone reaches for a bare pip install.
  • A smoke test that imports every top-level module the project ships, run as an actual CI step — it turns a ModuleNotFoundError from a runtime surprise into a five-second CI failure with a stack trace pointing at the exact missing name.
  • Log sys.executable at process startup in anything that runs unattended (a cron job, a container entrypoint, a CI step) — when the failure does happen in production, that one line tells you immediately whether it's an environment mismatch or something else, without needing to reproduce it interactively.

Related failure modes worth knowing by name so you pick the right article next time: a same-named local file shadowing a stdlib or third-party module produces AttributeError: module 'x' has no attribute 'y', not ModuleNotFoundError — different diagnosis, same root cause of sys.path ordering. ModuleNotFoundError: No module named 'distutils' on 3.12+ is PEP 632's stdlib removal, not an interpreter mismatch. And error: externally-managed-environment is pip refusing to install into the system interpreter at all (PEP 668) — a policy error, not a path-resolution one, and worth its own writeup.

9. Key Takeaways

  • ModuleNotFoundError: No module named 'x' means the interpreter that ran your code searched its sys.path and found nothing there — it says nothing about whether the package is installed somewhere.
  • The single most common cause is pip/pip3 on PATH resolving to a different interpreter than the one executing your script; python -m pip install ... removes the ambiguity by construction.
  • In an IDE or notebook, compare sys.executable from inside the tool against which python in your terminal before changing anything else — that one check identifies the mismatch immediately.
  • -P / PYTHONSAFEPATH (3.11+) protects against a different variant of the same family: a local file shadowing the real package by sitting first on sys.path.
  • Python 3.15's PEP 810 lazy imports move when this exception can fire — from the import line to the first use of the name — for any import explicitly marked lazy; ordinary imports are unaffected.
    Run - today at 4:52 AM
    Run - yesterday at 4:52 AM

See task progress for longer tasks.

python-modulenotfounderror-no-module-named-x.md
python-ledger
Artifact
Python Error Article Log
Artifact
Connectors
Web search

Pythonpython-errorsModuleNotFoundErrorimportssys-pathvirtualenvpippackaging

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments