Python, from the ground up Lesson 13 / 60

Virtual environments explained for humans

Why every Python project needs its own sandbox, what venv/poetry/uv actually do, and how to stop your projects from breaking each other.

Virtual environments explained for humans

This lesson opens Module 3 — the tooling module. Modules 1 and 2 were about the language itself. From here on the question is no longer “what does Python do?” but “how do you run a Python project, with its dependencies, on more than one machine, without losing a weekend.” Virtual environments are the foundation. Everything in the next few lessons — pyproject.toml, uv, dependency locking, packaging — sits on top of them.

The problem they solve

Out of the box, every Python install has a single site-packages directory where pip install drops everything. Install pandas==1.5 for one project and the whole machine has pandas==1.5. Start a new project that needs pandas==2.2, run pip install pandas, and now the first project is broken because its pandas==1.5 is gone. There’s only one slot.

It’s worse than that. Some packages depend on a specific version of numpy, and a different package on the same machine depends on a different version. The shared global install can’t satisfy both. The only general fix is per-project isolation: each project gets its own site-packages, its own pinned versions, its own everything.

That isolated-per-project thing is a virtual environment.

Three eras

Python has been through three eras of how to do this:

  1. System Python (≈ pre-2012) — sudo pip install into the system interpreter. Bad. Don’t. You break system tools, you can’t have two projects with conflicting deps, and sudo shouldn’t be touching your developer tools at all. If you’re still running sudo pip in 2026, stop.
  2. venv + pip (Python 3.3 onwards) — the standard library got built-in support for creating isolated environments. This is the foundation everything else still uses.
  3. uv (2024 onwards) — a single Rust-based tool that creates the venv, installs the deps, and resolves a lock file in seconds rather than minutes. New projects in 2026 should default to it. Old projects don’t need to migrate, but new ones don’t need to learn the older tools first.

Each era still works. The point of this lesson is that you understand the layer underneath — venv and site-packages — even when something like uv is doing it for you. When things go wrong, they go wrong at this layer, and “I’m in the wrong virtual environment” is the single most common cause of “this works on Alex’s machine and not mine.”

What python -m venv actually does

python -m venv .venv

Run that and .venv/ appears in your project. It’s not magic. The directory contains:

  • bin/ (or Scripts/ on Windows) — a copy or symlink of the Python executable, plus tiny wrappers (pip, python, anything you pip install’d that exposes a CLI).
  • lib/python3.13/site-packages/ — the empty site-packages directory that this venv’s Python will read from.
  • pyvenv.cfg — a small text file with three or four lines: which “real” Python this points at, the version, whether to inherit system site-packages.

That last file is the thing that makes the whole mechanism work. When you run .venv/bin/python, it reads pyvenv.cfg, learns where the real interpreter and the real standard library live, and then adds its own site-packages to the front of the import path. Anything pip install’d into the venv shadows the system version because it shows up first in sys.path. Outside the venv, that same package isn’t visible.

That’s it. A venv is a folder with a config file and a site-packages directory. There’s nothing else.

Activation: convenient but not required

The usual instructions say:

# macOS / Linux
source .venv/bin/activate

# Windows
.venv\Scripts\activate

What activate does is shell bookkeeping. It prepends .venv/bin to your PATH and sets a few environment variables (VIRTUAL_ENV, PS1 for the shell prompt). After that, typing python finds .venv/bin/python first instead of /usr/bin/python.

You don’t have to activate. This works just as well:

./.venv/bin/python script.py
./.venv/bin/pip install httpx

For scripts, cron jobs, Dockerfiles, and CI it’s actually better — there’s nothing to “forget to activate.” For interactive work, activation is convenient because you stop typing the path every time. Both approaches use the exact same venv; activation is just sugar.

The classic workflow: requirements.txt

Before lock files were standard, the canonical pattern was:

python -m venv .venv
source .venv/bin/activate
pip install httpx pydantic
pip freeze > requirements.txt

pip freeze writes every installed package and its exact version to a file. To recreate the env on another machine:

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

This works, but it has the limits we discussed: pip freeze mixes direct deps and transitive ones, there are no hashes, and the file isn’t really designed to be hand-edited. Lesson 17 covers the modern alternatives. For now, just know that the venv-plus-requirements workflow is what every newer tool generalises.

The .python-version file

A small but useful convention: drop a file called .python-version in your project root containing nothing but the version, e.g. 3.13.1. Tools that respect it (pyenv, uv, mise, asdf) will switch to that interpreter automatically when you cd into the directory.

echo "3.13.1" > .python-version

This is how teams keep the Python version itself reproducible, not just the packages. Without it, you have to remember which version each project needs and switch by hand.

How uv makes all this go away

uv (covered in detail in lesson 15) wraps the venv layer transparently. You don’t python -m venv and you don’t source activate. You write:

uv init
uv add httpx pydantic
uv run python script.py

Behind the scenes uv creates .venv/ the same way python -m venv would, installs the deps into its site-packages, and uv run executes commands inside that env without touching your shell’s PATH. Everything we just covered still applies — the directory layout, pyvenv.cfg, site-packagesuv just stops asking you to manage it by hand. When you debug a problem you’ll still drop down to ls .venv/lib/python3.13/site-packages to see what’s actually installed.

Poetry’s approach

Poetry (covered next lesson) does the same thing as uv here, with one philosophical difference: by default it puts venvs in a central cache directory (~/.cache/pypoetry/virtualenvs/) instead of inside the project. You can flip a setting (poetry config virtualenvs.in-project true) to make it create .venv/ next to the code, which most teams prefer because IDEs and CI find it automatically.

The mechanics underneath are identical. Same pyvenv.cfg, same site-packages, same Python.

Conda: the parallel ecosystem

Conda is the odd one out. It’s not built on venv and it’s not really a “Python tool” — it’s a general-purpose package manager that happens to package Python. Its environments live in ~/miniconda3/envs/<name>/ and contain not just Python packages but compiled binaries, system libraries, even C compilers.

conda create -n myproject python=3.13
conda activate myproject
conda install pandas

For pure Python projects this is overkill. Where Conda earns its keep is scientific Python — when you need a specific BLAS, CUDA, or GDAL pinned alongside Python. If your pip install keeps failing because something needs to compile C++ against a specific system library, Conda is sometimes the path of least resistance.

For everything else, stick with venv-based tools. Modules of this course assume uv from lesson 15 onward.

Debugging: “I’m in the wrong env”

This is the single most common Python tooling problem. The symptoms: you pip install’d something, then import says ModuleNotFoundError. Or two terminals behave differently for the same project.

The two commands you need:

which python      # macOS / Linux
where python      # Windows

pip --version

which python shows the actual path of the interpreter your shell will use. pip --version prints both pip’s own version and the path of the Python it’s bound to:

pip 24.3.1 from /home/narcis/proj/.venv/lib/python3.13/site-packages/pip (python 3.13)

If that path is anywhere outside your project’s .venv/, you’re in the wrong env. Either activate the right one (source .venv/bin/activate) or run things via the explicit path (./.venv/bin/python, ./.venv/bin/pip).

A related symptom: you have a useful library installed globally and your project keeps importing it even when its venv shouldn’t have it. That’s the global site-packages leaking in, and it’s a sign of trouble — you should never pip install outside a venv. Clean up your global Python (pip uninstall everything that isn’t pip and setuptools) and the leaks stop.

The one rule

Every Python project gets its own virtual environment, always, no exceptions, even for a five-line script you’ll throw away tomorrow. It costs you python -m venv .venv (or uv init) — call it five seconds — and saves you the hour-long debugging session three weeks later when the script you forgot about now imports something incompatible with whatever you just installed.

The next two lessons build directly on this. Lesson 14 is pyproject.toml — the file that describes a project’s dependencies, build system, and tool configuration in one place. Lesson 15 is uv itself — how to use the modern tooling without ever typing python -m venv again.

Search