Skip to content

pip says it’s installed, but Python can’t find it

  • Python · Foundations
  • 20 min read, about 1 h 5 min with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

Your report script needs the requests package. You install it, and pip says it worked:

$ pip install requests
Successfully installed requests-2.32.3
$ python report.py
ModuleNotFoundError: No module named 'requests'

A friend’s laptop is worse. Their game has a file called random.py, and now import random gives AttributeError: module 'random' has no attribute 'randint'.

Both times, Python looked for a module somewhere other than where you think. Where does import look?

  • Goal: set up a project so that import requests finds the package you installed, every time, on any computer.
  • Rules: another project on the same computer may need a different version of requests. A teammate must be able to make the same setup with one command.
  • The hard part: a computer can have many Pythons, and each has its own place for packages.

Example: after the setup, python report.py runs, and python -m pip list shows requests in the project’s own folder.

Hints open one at a time:

Hint 1 of 3

pip and python are two separate commands. On PATH, the first pip may belong to another Python.

Hint 2 of 3

python -m pip runs the pip that belongs to that exact python.

Hint 3 of 3

A virtual environment is a folder with its own python and its own packages, one for each project.

Modules and packages. Any .py file is a module. import prices runs prices.py once and gives you its names: prices.total(). A folder with an __init__.py file is a package: from shop.prices import total.

Where import looks. Python keeps a list of folders in sys.path and tries them in order:

OrderFolderHolds
1The folder of the script you ranYour own modules, like prices.py, or a stray random.py
2The standard libraryrandom, json, pathlib…
3site-packagesPackages pip installed for this Python

The first match wins. In each folder, a package folder comes before a .py file of the same name. No match anywhere gives ModuleNotFoundError. That’s why your random.py hides the real random: your folder comes first.

Each Python has its own site-packages. A computer often has two or three Pythons. A plain pip may belong to a different one than python. So pip install can put the package where your python never looks.

A virtual environment (a venv) is a folder, usually .venv, with its own python and its own site-packages. Make one per project. Activate it, and python and pip in that terminal mean the project’s own.

All three install one package. What changes is whether it lands in the Python you run, and whether projects stay apart.

Okay

pip install

The idea: Run pip install and hope pip and python match.

On a computer with one Python, pip and python belong together, and it works.

Terminal window
# Install with whichever pip comes first on PATH
pip install requests
python report.py
# ModuleNotFoundError: No module named 'requests'
Works for
One Python on the computer
Weak spot
The wrong Python
Why
pip is whichever comes first on PATH.

Where it breaks: with two Pythons, pip may be the other one’s. The package lands in a site-packages your python never searches.

Better

python -m pip

The idea: Run pip through the same python that runs the script.

The waste in Okay: it trusts pip and python to match, when one command can make sure.

python -m pip means “run the pip module of this python”. The package goes where this python looks.

Highlighted lines are new in this rung.

Terminal window
# Install with the pip that belongs to this python
python -m pip install requests
python report.py
# Works, but every project on this computer now shares this requests
Works for
One project
Weak spot
Projects share packages
Why
Every project uses this Python’s one site-packages.
What changed from the previous rung
Terminal window
# Install with whichever pip comes first on PATH
pip install requests
# Install with the pip that belongs to this python
python -m pip install requests
python report.py
# ModuleNotFoundError: No module named 'requests'
# Works, but every project on this computer now shares this requests

Where it breaks: every project on the computer shares one site-packages. Project A needs requests 2.32 and project B needs 2.31: installing one breaks the other. And nothing records what the project needs.

Best

A venv per project

The idea: Make .venv in the project, activate it, and install from requirements.txt.

The waste in Better: all projects share one set of packages, and none of them says what it needs.

requirements.txt lists each package with its version, like requests==2.32.3. A teammate runs the same four lines and gets the same setup. Add .venv/ to .gitignore; share requirements.txt instead.

Highlighted lines are new in this rung.

Terminal window
# One virtual environment per project, in a .venv folder
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install -r requirements.txt
python report.py
Works for
Any number of projects
Weak spot
Remember to activate
Why
Each project has its own python and site-packages.
What changed from the previous rung
Terminal window
# Install with the pip that belongs to this python
python -m pip install requests
# One virtual environment per project, in a .venv folder
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install -r requirements.txt
python report.py
# Works, but every project on this computer now shares this requests

When Best isn’t best: tools like uv or Poetry make the venv, lock exact versions and install in one step. Teams often use them for bigger projects. A one-file script that uses only the standard library needs no venv at all.

Now write the search that import does. Given a module name, the folders in sys.path order, and the files that exist, return the file import would load. Return None when it would raise ModuleNotFoundError.

In each folder, check folder/name/__init__.py first, then folder/name.py. Press Run tests to check your code.

Where import looks

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: find_module('random', ['/proj', '/usr/lib/python3.12'], ['/proj/game.py', '/usr/lib/python3.12/random.py']) → '/usr/lib/python3.12/random.py'
  2. Not run yet: find_module('random', ['/proj', '/usr/lib/python3.12'], ['/proj/random.py', '/usr/lib/python3.12/random.py']) → '/proj/random.py'
  3. Not run yet: find_module('requests', ['/proj', '/proj/.venv/site-packages'], ['/proj/.venv/site-packages/requests/__init__.py']) → '/proj/.venv/site-packages/requests/__init__.py'
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayLands in the Python you runProjects stay apartTeammates can copy itUse it when
Okay: pip installOnly with one PythonNoNoNever, if you can type python -m
Better: python -m pipYesNoNoA quick tool you install once for yourself
Best: a venv and requirements.txtYesYesYesEvery project
  • Forgetting to activate: a new terminal starts outside the venv. Check with python -c "import sys; print(sys.prefix)", or look for (.venv) before the prompt.
  • Committing .venv: it holds thousands of files built for your computer. Share requirements.txt instead.
  • pip freeze > requirements.txt writes every installed package with its exact version. That includes packages your packages need.
  • Circular imports: a.py imports b.py, which imports a.py. One of them sees a half-loaded module. Move the shared code into a third module.
  • Importing runs the file: top-level code runs on the first import. Guard scripts with if __name__ == "__main__":.

Questions to ask first

  • How many Pythons are on the machine, and which one runs the app?
  • Does the team use a lock file, or a tool like uv or Poetry?

A 60-second walkthrough

“import searches sys.path in order: the script’s folder, the standard library, then site-packages, and the first match wins. Every Python has its own site-packages, so a bare pip can install into a different interpreter. I always use python -m pip, inside a virtual environment per project. That keeps versions apart between projects, and requirements.txt or a lock file lets anyone rebuild the same setup.”

Likely follow-ups

  • What is __init__.py for? It marks a folder as a regular package and runs when the package is imported.
  • What does a venv contain? A link to a Python, its own site-packages, and scripts to activate it. Nothing else is copied.
  • import x or from x import y? Both load the whole module once. from only changes which name you get in your file.

Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

Write module_name(path). Turn a file path inside a project into the name you would import. "app/utils/text.py" gives "app.utils.text". A package’s __init__.py is imported by the folder’s name, so "app/utils/__init__.py" gives "app.utils". Windows paths use \, and must work too.

Hint 1 of 3

Drop the ".py" at the end with removesuffix(".py").

Hint 2 of 3

Split the path into parts, and join them with dots.

Hint 3 of 3

Turn every "\" into "/" first, so one split works for both.

From path to module name

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: module_name('app/utils/text.py') → 'app.utils.text'
  2. Not run yet: module_name('app/utils/__init__.py') → 'app.utils'
  3. Not run yet: module_name('main.py') → 'main'
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Two replaces

The idea: Turn / into . and remove .py.

"app/utils/text.py" gives "app.utils.text".

def module_name(path: str) -> str:
return path.replace("/", ".").replace(".py", "")
Time
O(n)
Space
O(n)
Why
Each replace makes a new string of the path’s length.

Where it breaks: "app/utils/__init__.py" gives "app.utils.__init__", which isn’t how you import a package.

Better

Drop __init__ too

The idea: Remove .py from the end only, turn / into ., and remove a final .__init__.

The waste in Okay: it treats a package’s __init__.py like any other file.

Highlighted lines are new in this rung.

def module_name(path: str) -> str:
name = path.removesuffix(".py").replace("/", ".")
return name.removesuffix(".__init__")
Time
O(n)
Space
O(n)
Why
A few passes over the path.
What changed from the previous rung
def module_name(path: str) -> str:
return path.replace("/", ".").replace(".py", "")
name = path.removesuffix(".py").replace("/", ".")
return name.removesuffix(".__init__")

Where it breaks: a Windows path keeps its \: "app\utils\text.py" gives "app\utils\text".

Best

Split into parts

The idea: Turn \ into /, drop .py, split on /, drop a last __init__, and join with dots.

The waste in Better: it works on one long string, so each new case needs another replace.

Highlighted lines are new in this rung.

def module_name(path: str) -> str:
parts = path.replace("\\", "/").removesuffix(".py").split("/")
if parts[-1] == "__init__":
parts.pop()
return ".".join(parts)
Time
O(n)
Space
O(n)
Why
A few passes over the path.
What changed from the previous rung
def module_name(path: str) -> str:
name = path.removesuffix(".py").replace("/", ".")
return name.removesuffix(".__init__")
parts = path.replace("\\", "/").removesuffix(".py").split("/")
if parts[-1] == "__init__":
parts.pop()
return ".".join(parts)

When Best isn’t best: for real paths, pathlib.PurePath(path).with_suffix("").parts splits on the right separator for you.

Write parse_requirements(text). Each line of a requirements.txt can pin a package, like requests==2.32.3. Return a dict of name to version. Skip blank lines and comments, which start with #, even after a pin. Names are not case-sensitive, and _ means the same as -, so store them in lower case with -.

Hint 1 of 3

text.splitlines() gives one line at a time.

Hint 2 of 3

line.split("#")[0] keeps only the part before a comment.

Hint 3 of 3

Spaces can sit round == too: strip both halves.

Read a requirements file

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: parse_requirements('requests==2.32.3') → {'requests': '2.32.3'}
  2. Not run yet: parse_requirements('# web app\nDjango==5.2.19\n\nrequests==2.32.3') → {'django': '5.2.19', 'requests': '2.32.3'}
  3. Not run yet: parse_requirements('requests==2.32.3 # for the reports') → {'requests': '2.32.3'}
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Split every line on ==

The idea: For each line, split on == into name and version.

A file with only pins works.

def parse_requirements(text: str) -> dict[str, str]:
pins = {}
for line in text.splitlines():
name, version = line.split("==")
pins[name] = version
return pins
Time
O(n)
Space
O(p)
Why
One pass over the n characters; one entry per pin.

Where it breaks: a comment or blank line has no ==, so name, version = … raises ValueError.

Better

Skip blanks and comment lines

The idea: Strip the line, and skip it if it’s empty or starts with #.

The waste in Okay: it expects every line to be a pin.

Highlighted lines are new in this rung.

def parse_requirements(text: str) -> dict[str, str]:
pins = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
name, version = line.split("==")
pins[name] = version
return pins
Time
O(n)
Space
O(p)
Why
The same pass.
What changed from the previous rung
def parse_requirements(text: str) -> dict[str, str]:
pins = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
name, version = line.split("==")
pins[name] = version
return pins

Where it breaks: a comment after a pin stays in the version: "2.32.3 # for the reports". And Django and django would be two packages.

Best

Cut comments, tidy names

The idea: Cut everything after #, then store the name in lower case with - and the version stripped.

The waste in Better: it trusts names and comments to be written one way.

Highlighted lines are new in this rung.

def parse_requirements(text: str) -> dict[str, str]:
pins = {}
for line in text.splitlines():
line = line.split("#")[0].strip() # drop a comment, even after a pin
if not line:
continue
name, version = line.split("==")
pins[name.strip().lower().replace("_", "-")] = version.strip()
return pins
Time
O(n)
Space
O(p)
Why
The same pass.
What changed from the previous rung
def parse_requirements(text: str) -> dict[str, str]:
pins = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
line = line.split("#")[0].strip() # drop a comment, even after a pin
if not line:
continue
name, version = line.split("==")
pins[name] = version
pins[name.strip().lower().replace("_", "-")] = version.strip()
return pins

When Best isn’t best: real files also use >=, extras like requests[socks], and markers like ; python_version < "3.12". Use the packaging library’s Requirement class for those.

3. Can two projects share one environment? (medium)

Section titled “3. Can two projects share one environment? (medium)”

Write clashes(a, b). a and b are two projects’ pins, as dicts of name to version. Return, in A to Z order, the names both projects pin to different versions. Those are the packages that make one shared site-packages break.

Example: clashes({"requests": "2.32.3", "pytest": "9.2.0"}, {"requests": "2.31.0", "pytest": "9.2.0"}) gives ["requests"].

Hint 1 of 3

Checking every pair of names is slow for big lists.

Hint 2 of 3

name in b is a fast dict lookup.

Hint 3 of 3

a.keys() & b.keys() gives the names both have.

Can two projects share one environment?

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: clashes({'django': '5.2.19', 'requests': '2.32.3', 'pytest': '9.2.0'}, {'requests': '2.31.0', 'pytest': '9.2.0', 'numpy': '2.3.1'}) → ['requests']
  2. Not run yet: clashes({}, {'requests': '2.31.0'}) → []
  3. Not run yet: clashes({'b': '1', 'a': '1'}, {'a': '2', 'b': '2'}) → ['a', 'b']
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Compare every pair

The idea: For each name in a, look through every name in b.

Small projects come out right.

def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]:
found = []
for name_a, version_a in a.items():
for name_b, version_b in b.items():
if name_a == name_b and version_a != version_b:
found.append(name_a)
return found
Time
O(a × b)
Space
O(k)
Why
Every pin in a meets every pin in b; k clashes are kept.

Where it breaks: 6,000 pins each means 36 million pairs, which is too slow. And the order follows a, not A to Z.

Better

Look each name up

The idea: For each name in a, check name in b, which is one dict lookup.

The waste in Okay: it walks all of b to find one name, when a dict finds it at once.

Highlighted lines are new in this rung.

def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]:
found = []
for name, version in a.items():
if name in b and b[name] != version:
found.append(name)
return found
Time
O(a)was O(a × b)
Space
O(k)
Why
One lookup per pin in a.
What changed from the previous rung
def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]:
found = []
for name_a, version_a in a.items():
for name_b, version_b in b.items():
if name_a == name_b and version_a != version_b:
found.append(name_a)
for name, version in a.items():
if name in b and b[name] != version:
found.append(name)
return found

Where it breaks: the names come out in a’s order, not A to Z.

Best

Shared names, sorted

The idea: Take the names both have with a.keys() & b.keys(), keep those that differ, and sort.

The waste in Better: it leaves the order to chance.

keys() views support &, like sets, so the shared names come in one step.

Highlighted lines are new in this rung.

def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]:
return sorted(name for name in a.keys() & b.keys() if a[name] != b[name])
Time
O(a + k log k)
Space
O(k)
Why
One pass to find shared names; sorting the k clashes.
What changed from the previous rung
def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]:
found = []
for name, version in a.items():
if name in b and b[name] != version:
found.append(name)
return found
return sorted(name for name in a.keys() & b.keys() if a[name] != b[name])

When Best isn’t best: real pins are ranges like >=2.31, and two ranges can overlap. pip’s resolver works that out. This check only covers exact == pins.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Spot the buggame.py runs "import random" and then random.randint(1, 6). It fails with "module random has no attribute randint". Why?
    my_game/
      game.py
      random.py
  2. Multiple choiceWhich command installs a package into the same Python that python report.py uses?
  3. Multiple choiceWhat should you commit to Git for a project that uses a venv?
  4. Predict the outputnoisy.py holds the line print("loading"). What does this print?
    import noisy
    import noisy

Add these to your review deck. Each card comes back a little later every time you get it right.

  • import searches sys.path in order: the script’s folder, the standard library, then site-packages. The first match wins.
  • Never name a file after a module you use: random.py, json.py, requests.py.
  • Install with python -m pip install …, so the package goes to the Python you run.
  • python -m venv .venv, then activate it: source .venv/bin/activate, or .venv\Scripts\activate on Windows.
  • Record packages in requirements.txt and commit it; never commit .venv.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started