Asking for one package installs several. Measured on 2026-09-02, installing
requests into a fresh environment reportedSuccessfully installed certifi-2026.7.22 charset_normalizer-3.5.1: five packages for one name. Four
idna-3.19 requests-2.34.2 urllib3-2.7.0
of them are somebody else's decision, they will be updated on somebody else's
schedule, and they are now yours to keep working.
They landed inside the folder and nowhere else. From the environment's own
interpreter the import succeeds; from the system interpreter in the same shell,import requests answersModuleNotFoundError: No module named 'requests'. That is the whole
mechanism, and it is why the same command works for a colleague and not for
you.
What a virtual environment is, exactly
It is a folder with its own interpreter and its own site-packages, and one
value that gives it away: inside it, sys.prefix differs fromsys.base_prefix. That one line is the reliable way to find out
which environment you are in, and it is worth more than reading a prompt, because
a prompt can be wrong and that comparison cannot.
A fresh environment contains pip and nothing else. Everything the machine had
installed globally is invisible to it, deliberately, and that is the property
being bought: two projects wanting different versions of the same library stop
being a problem that has to be solved at all.
Activating is a convenience, and the path is the fact
Activation edits your PATH so that python means the
environment's interpreter. Nothing else happens, which is why every problem in
this area has the same fix: stop relying on activation and call the interpreter
by path. ./demoenv/bin/python -m pip install requests works
whether or not anything is activated, in a cron job, in a Makefile, and inside
an editor that started its own shell.
Two habits follow. Run pip as python -m pip rather than aspip, so the pip you run and the interpreter you install into cannot
disagree. And never install into the system interpreter: on a current machine it
will usually refuse outright, and where it does not, it is putting your project's
dependencies where every other project will see them.
What makes the run reproducible
python -m pip freeze prints exactly what is installed with exact
versions, five lines for the install above, startingcertifi==2026.7.22. Committing that is the minimum bar: without
it, the next person gets whatever was current the day they ran the install, and
the bug report begins with two different version sets.
The current shape is better than a frozen list, because a frozen list cannot
tell your direct dependencies from the four that came along. Declaring requests
in pyproject.toml and letting a tool write the lock file keeps that
distinction, so upgrading one thing does not require knowing which of the five
names you actually asked for.
What to run in 2026
uv is the tool most Python projects moved to by 2026, free and open source.
It creates the environment, resolves and installs, and writes a lock file, in
one program where there used to be three, and it is fast enough that the
environment stops being a thing anyone avoids recreating. It replaced the
venv plus pip plus pip-tools combination, and the honest note is thatpython -m venv and python -m pip are always present,
which is why this page is written on them.
pyproject.toml is the file, free with the ecosystem and the
standard as of 2026. It replaced setup.py and the looserequirements.txt convention, and the practical gain is that the
declared dependencies and the resolved ones are two different lists in two
different files, which is the distinction the paragraph above needs.
Where this stalls people
This is the stall of the whole pillar, arriving early: someone who can write
Python meets an environment that will not install, concludes they are bad at
programming, and is actually meeting packaging, which nobody told them was a
separate subject. It is, it has its own vocabulary, and it takes a fortnight
rather than an afternoon.
The one habit that removes most of it: before anything else fails, runpython -c "import sys; print(sys.executable)" and read which
interpreter you are actually talking to. Nearly every install-that-did-not-work
is that path being different from the one running the code. What the interpreter
does with those packages once they are found is
what happens between your
file and the processor, and what to reach for before adding any of them is
what ships
with Python, and saves you a dependency. Shipping the environment itself
rather than rebuilding it is
shipping the environment along with
the code, and the language it all sits in is
readable, everywhere, and the usual
first language.