Why virtual environments exist at all
Every Python tutorial tells you to make a virtual environment and none of them tell you why, so you end up doing it as a superstition. Five minutes here removes a lot of future confusion, because once you know the mechanism, every packaging error message in the language becomes readable.
The mechanism, which is smaller than you expect
When Python executes import requests, it searches sys.path: an ordered list of directories, built once at interpreter startup, containing roughly the script’s own directory, the standard library, and one site-packages directory belonging to that interpreter.
That is the whole import system. It is a flat search over a global list.
Compare what Node does. require and import walk up from the importing file looking for node_modules. Resolution is a function of where the file is, so two projects on one machine never interact, and per-project dependencies fall out of the design for free without anyone naming a feature.
Python has nothing like that. sys.path belongs to the interpreter, not to the directory you happen to be standing in. So one interpreter means one set of installed packages, shared by every project that uses it. Install a library for one project and you have installed it for all of them. Two projects needing different versions of the same library is not a resolvable situation, it is a conflict.
So a virtual environment is a second interpreter
A venv is a directory containing its own python (usually a symlink or small shim to a real installation) and its own empty site-packages. Running that interpreter produces a sys.path pointing at that site-packages.
.venv/
bin/python -> a link to a real interpreter
bin/pip
lib/python3.14/site-packages/ <- your project's packages live here
pyvenv.cfg <- names the base interpreterNothing is copied except the shim and the config. It is disposable by construction: deleting .venv and recreating it is always safe and is frequently the fastest fix for a broken environment. Never commit it.
And activation is only a PATH edit
python -m venv .venv
source .venv/bin/activate
pip install requestsactivate sets VIRTUAL_ENV, prepends .venv/bin to your PATH, and rewrites your prompt. That is all it does. It does not tell Python anything. The interpreter figures out its own environment from its own executable path, which is why this works with nothing activated at all:
.venv/bin/python script.py
.venv/bin/pip install requestsOnce you see that, activation stops being a mystery and becomes what it is: a convenience so you can type python instead of .venv/bin/python. It is also stateful shell mutation that persists until you close the terminal or remember to deactivate, which is why every Python developer has, at some point, installed a package into entirely the wrong project.
What the old world looked like
Worth two minutes, because you will read repos that still contain it and you need to recognise what you are looking at.
| File | What it was |
|---|---|
requirements.txt |
A flat list of packages, often unpinned. Not a lockfile, though widely used as one. |
setup.py |
An executable Python script that described how to build the package. Yes, executable. |
setup.cfg |
The declarative replacement for setup.py, before pyproject.toml existed. |
Pipfile / Pipfile.lock |
pipenv’s answer. Real locking, famously slow. |
poetry.lock |
Poetry’s answer. Good, popular, and slow, with its own non-standard metadata for years. |
environment.yml |
conda, a separate universe aimed at scientific stacks with non-Python dependencies. |
The problem was never that no solution existed. It was that five solutions existed, none of them shipped with Python, they disagreed about metadata, and the one that did ship (pip plus venv) had no lockfile at all. pip install -r requirements.txt gives you some version of each named package and whatever their dependencies resolved to today, which is reproducible in the way that a weather forecast is reproducible.
Which brings us to why uv won
Given all of that, the pitch for uv writes itself. One tool that creates the environment, resolves the dependencies, writes a real cross-platform lockfile, installs the packages, manages the Python interpreter itself, and runs your commands in the right environment without you ever activating anything.
It is Rust, it is roughly ten to a hundred times faster than what it replaced, and the speed is not the interesting part. The interesting part is that it is one tool instead of five, and that it made the activation ritual optional.
That is the next lesson.
Try it yourself
The reason virtualenvs had to be invented
Node solved per-project dependencies with a directory called node_modules and no ceremony. Why could Python not do the same thing?
Show answer
Correct answer: C — Python resolves imports against a single interpreter-wide list of directories, with no per-directory lookup walking up from the importing file
The mechanism is the whole answer: Node walks up from the importing file looking for node_modules, so location determines resolution and two projects never collide. Python resolves against sys.path, an interpreter-global list, so every project sharing an interpreter shares its packages. The compiled-extensions option is true about compiled extensions and irrelevant, since they live inside the environment perfectly happily. The one-version-per-machine option is a genuine constraint of a single environment and is the SYMPTOM of the design rather than its cause, which is what makes it the most tempting wrong answer.
What activate actually does
You have seen source .venv/bin/activate in every Python README ever written. What does that script change?
Show answer
Correct answer: D — Your PATH, so the environment's python is found first, plus a couple of shell variables for the prompt
Activation is a shell convenience and nothing more: it edits PATH so that .venv/bin comes first, and it sets VIRTUAL_ENV for prompts. That is the entire mechanism. The sys.path half is real but is not done by activation, it is done by the interpreter itself, which locates its environment from its own executable path. Which is precisely why .venv/bin/python script.py works perfectly with nothing activated, and why uv run can skip the whole ritual: it just invokes the right interpreter.
The import model in one paragraph
Hold this and every packaging error message you will ever read becomes legible.
How does Python decide where an imported module comes from, and what does a virtual environment change about that?
Reveal answer
An import searches sys.path, an ordered list of directories built at interpreter startup, containing the script's own directory, the standard library, and one site-packages directory belonging to that interpreter. It is a flat, interpreter-global search: there is no walking up from the importing file, so location on disk does not scope resolution. A virtual environment is a directory containing its own interpreter (usually a link to a real one) plus its own site-packages. Running that interpreter produces a sys.path pointing at that site-packages instead of the system one. Nothing else is different, which is why environments are disposable and why deleting .venv is always safe.