Whetstone.
The 2026 ToolchainWhy virtual environments exist at all
Module 2 · Lesson 113 min

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.

What is actually in a .venv
.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 interpreter

Nothing 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

The ritual you have seen a thousand times
python -m venv .venv
source .venv/bin/activate
pip install requests

activate 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:

No activation required, ever
.venv/bin/python script.py
.venv/bin/pip install requests

Once 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.

Practice

Try it yourself

Quiz

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?

  1. APython's import system predates the filesystem conventions that a project-local package directory would require, and changing it would break the standard library
  2. BPython packages frequently ship compiled extensions, and those cannot be relocated into a project-local directory after installation
  3. CPython resolves imports against a single interpreter-wide list of directories, with no per-directory lookup walking up from the importing file
  4. DPython allows only one version of any given package to exist on a machine, so isolation has to happen at the interpreter level
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.

Quiz

What activate actually does

You have seen source .venv/bin/activate in every Python README ever written. What does that script change?

  1. AYour working directory's package resolution root, in the way that a lockfile in a directory does for Node
  2. BA registry of the currently selected interpreter, which subsequent Python processes read at startup
  3. CThe interpreter's own sys.path, by injecting the environment's site-packages directory ahead of the system one
  4. DYour PATH, so the environment's python is found first, plus a couple of shell variables for the prompt
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.

Recall

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.

Sign in to track your progress →