"It is installed, but Python says ModuleNotFoundError." "I fixed the bug, but the command still does the old thing." "mytool --version says 1.4 but pip says 1.6." Almost every one of these reports has the same root cause: the command that ran is not the one the person thinks it is. There are several interpreters on a typical developer machine — the system Python, Homebrew or pyenv versions, uv-managed ones — and several environments built from them: project virtual environments, pipx and uv tool environments, the user site. PATH order, shell caches and shebang lines decide which one a command uses, and each can be stale. This guide gives a systematic way to find out which interpreter and environment are actually involved, explains the usual culprits, and ends with a doctor command you can add to your own CLI so users can answer the question for you. It belongs to the virtual environments topic.
Prerequisites
- A shell (bash, zsh, fish or PowerShell) and Python 3.9+.
- Familiarity with virtual environments; managing virtual environments for cross-platform CLIs covers the basics.
How a command finds its interpreter
When you type mytool, three lookups happen, and each can go wrong:
- The shell finds an executable by walking
PATHleft to right — unless it remembered an earlier answer in its hash table, in which case it does not look again. - The executable's shebang (
#!/path/to/venv/bin/python) names the interpreter. Console scripts created by pip, uv and pipx hard-code the absolute path of the environment's Python at install time. - That interpreter builds
sys.pathfrom its own site-packages, the user site (unless disabled),.pthfiles andPYTHONPATH, and imports the first matching module it finds.
A ModuleNotFoundError means step 3 ran in an environment without the package. Stale behaviour usually means step 3 found an older copy first. A wrong version means step 1 or 2 picked a different environment than the one you upgraded.
The recipe: four questions, in order
1. Which executable runs?
type -a mytool python python3 # bash/zsh: every match on PATH, in order
command -v mytool # the one that will actually run
hash -r # bash/zsh: forget remembered command locations
type -a shows every candidate, which immediately reveals a second copy shadowing the one you installed — for example ~/.local/bin/mytool from pipx ahead of .venv/bin/mytool. After installing or activating something, run hash -r (zsh also has rehash): bash and zsh cache command locations and may keep running the old path. In PowerShell, Get-Command mytool -All does the same job; fish has type -a and does not cache.
2. Which interpreter does it use?
head -1 "$(command -v mytool)"
If that path no longer exists — the environment was deleted, moved or recreated with a different Python — you get the confusing "bad interpreter: No such file or directory" error, or on some systems a "command not found" for a file that plainly exists. Virtual environments cannot be moved or renamed; recreate them instead (rm -rf .venv && uv sync). Tools installed by uv sometimes use a #!/bin/sh launcher line instead of a direct shebang when the path is long; follow the symlink with readlink -f "$(command -v mytool)" and look at the environment it points into.
3. Which environment and which copy of the package?
Ask the interpreter itself, not pip — pip on PATH may belong to yet another environment:
python -c "import sys; print(sys.executable); print(sys.prefix); print(sys.base_prefix)"
python -m pip show mytool # pip of *this* interpreter
python -c "import mytool, importlib.metadata as m; print(mytool.__file__, m.version('mytool'))"
sys.prefix != sys.base_prefix means you are inside a virtual environment. mytool.__file__ shows exactly which copy was imported — a path into your source tree means an editable install, a path into site-packages means a regular one. If the file path and the metadata version disagree, there are two copies on sys.path.
4. What else is on sys.path?
python -m site # sys.path, user site and whether it is enabled
env | grep -E '^(PYTHONPATH|PYTHONHOME|VIRTUAL_ENV)='
A leftover PYTHONPATH from a shell profile or IDE run configuration puts a directory ahead of site-packages, so an old checkout of the package silently wins. PYTHONHOME set by an installer can point the interpreter at the wrong standard library entirely. VIRTUAL_ENV set while the matching bin/ is not first on PATH (a half-activated environment, common after cd-ing between projects) misleads tools that trust the variable.
Give your CLI a doctor command
Users rarely know these commands, and support threads spend days on them. A doctor (or version --verbose) command that prints the answers turns that into one paste:
# src/mytool/doctor.py
from __future__ import annotations
import os
import platform
import sys
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
def environment_kind() -> str:
prefix = Path(sys.prefix)
if (prefix / "pipx_metadata.json").exists():
return "pipx"
if (prefix / "uv-receipt.toml").exists():
return "uv tool"
if getattr(sys, "frozen", False):
return "frozen binary"
if sys.prefix != sys.base_prefix:
return "virtual environment"
return "system interpreter"
def report(dist_name: str = "mytool") -> dict[str, str]:
try:
dist = distribution(dist_name)
version = dist.version
location = str(dist.locate_file(""))
except PackageNotFoundError:
version, location = "not installed (running from source?)", "-"
module = sys.modules.get(dist_name.replace("-", "_"))
return {
"mytool version": version,
"imported from": getattr(module, "__file__", None) or "-",
"installed at": location,
"python": f"{platform.python_version()} ({sys.executable})",
"environment": f"{environment_kind()} at {sys.prefix}",
"platform": platform.platform(),
"PYTHONPATH": os.environ.get("PYTHONPATH", "(not set)"),
}
def render(info: dict[str, str]) -> str:
width = max(map(len, info))
return "\n".join(f"{key:<{width}} {value}" for key, value in info.items())
Wire it to a command — @app.command() def doctor(): typer.echo(render(report())) — and add "please paste the output of mytool doctor" to your bug report template. The "imported from" line alone resolves most stale-code reports, and the environment line resolves most "wrong version" ones. Keep anything sensitive out: no environment variables beyond PYTHONPATH, no tokens, no config contents. Exposing version info and build metadata covers the version side in more depth.
UX considerations
- Prefer
python -min instructions.python -m pip install …andpython -m mytooltie the command to a specific interpreter; barepipdoes not. - Recommend
uv runanduvxin docs for running project and tool commands; they choose the right environment without activation. - Print the interpreter on unexpected import errors. A top-level handler that adds "running under /usr/bin/python3; is mytool installed for this interpreter?" to a
ModuleNotFoundErrorsaves a round of questions. - Never auto-repair environments. A CLI that pip-installs missing modules into whatever interpreter it finds makes the problem worse; diagnose and tell the user what to run.
Testing the behaviour
The doctor report is ordinary code and deserves a test that pins the fields support depends on:
# tests/test_doctor.py
import sys
from mytool import doctor
def test_report_has_the_fields_support_asks_for():
info = doctor.report("pytest") # any installed distribution works here
for key in ("mytool version", "python", "environment", "PYTHONPATH"):
assert key in info
assert sys.executable in info["python"]
def test_environment_kind_detects_venv(monkeypatch, tmp_path):
monkeypatch.setattr(sys, "prefix", str(tmp_path / "venv"))
monkeypatch.setattr(sys, "base_prefix", str(tmp_path / "base"))
assert doctor.environment_kind() == "virtual environment"
def test_missing_distribution_is_reported_not_raised():
assert "not installed" in doctor.report("definitely-not-installed-xyz")["mytool version"]
def test_render_aligns_columns():
out = doctor.render({"a": "1", "longer key": "2"})
assert out.splitlines() == ["a 1", "longer key 2"]
Conclusion
Wrong-interpreter problems feel mysterious because the failing piece is invisible. Make it visible in order: which executable the shell picks (type -a, hash -r), which interpreter its shebang names, which environment and which copy of the package that interpreter imports (sys.prefix, module.__file__, python -m pip), and what else is on sys.path (python -m site, PYTHONPATH). Then build the same questions into a doctor command, so users can hand you the answers in one paste.
Frequently asked questions
Why does pip install succeed but import fail?
Because pip and python belong to different environments. python -m pip install … uses the pip of the interpreter you will import with, which removes the mismatch.
My editable install shows old code — why?
Either a second, non-editable copy is earlier on sys.path (check module.__file__), or the change is in something that needs reinstalling, such as a new entry point or data file declared in pyproject.toml. Re-run uv sync or the editable install after packaging changes.
Is activating a virtual environment necessary?
No. Activation only puts its bin/ first on PATH and sets VIRTUAL_ENV. Running .venv/bin/mytool, uv run mytool or .venv/bin/python -m mytool uses the environment directly and avoids half-activated states.
Why does my CLI work in the terminal but not from cron or an IDE?
Those start with a different PATH and none of your shell's activation. Use absolute paths to the environment's executable in cron jobs, as in running a CLI on a schedule with cron and systemd, and point the IDE at the project interpreter explicitly.