Ch. 9 · Python

Python Virtual Environments and Reproducible Dependencies

What python -m venv creates on disk, why activation only edits PATH, why python -m pip beats pip, and how lock files make installs repeatable.

~5 min readadvancedupdated Oct 7, 2026

A virtual environment gives a project its own set of installed packages without touching the system Python or other projects. That’s the whole job. It doesn’t pin versions, it doesn’t choose your Python version, and it isn’t portable between machines. Most environment problems in interviews and in real teams come from expecting one of those extra things. This guide looks at what a venv actually is on disk, then at what you need on top of it to make installs reproducible. Everything was run on macOS with Homebrew’s Python 3.14.

What python -m venv creates

python3 -m venv .venv
cat .venv/pyvenv.cfg
Terminal
home = /opt/homebrew/opt/python@3.14/bin
include-system-site-packages = false
version = 3.14.7
executable = /opt/homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/bin/python3.14
command = /opt/homebrew/opt/python@3.14/bin/python3.14 -m venv /…/ve/.venv
Text

That small file is what makes the directory a virtual environment. When Python starts, it looks for pyvenv.cfg next to (or one level above) its executable. If it finds one, it sets sys.prefix to the venv directory and uses the venv’s site-packages for installs and imports, while the standard library still comes from the base installation named by home.

The bin directory is mostly links and scripts:

.venv/bin/python -> python3.14
.venv/bin/python3 -> python3.14
.venv/bin/python3.14 -> /opt/homebrew/opt/python@3.14/bin/python3.14
Text

So the venv doesn’t contain a copy of Python. It points at the base interpreter. Upgrading or removing that Homebrew Python can break every venv built on it, which is a common source of “my venv suddenly stopped working” after a brew upgrade. Recreating the venv is the fix, and it’s cheap if your dependencies are written down (more on that below).

You can confirm which environment a running interpreter is using:

.venv/bin/python -c "import sys; print(sys.prefix); print(sys.base_prefix); print(sys.prefix != sys.base_prefix)"
Terminal
/…/ve/.venv
/opt/homebrew/opt/python@3.14/Frameworks/Python.framework/Versions/3.14
True
Text

sys.prefix != sys.base_prefix is the reliable “am I in a venv?” check. A fresh venv contains only pip: site-packages held pip and pip-26.2.1.dist-info, and pip freeze printed nothing.

Activation is just PATH

The activate script feels like it switches Python into a special mode. It doesn’t. The relevant lines are:

_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
Terminal

It saves your PATH, puts .venv/bin in front, sets VIRTUAL_ENV, and changes your prompt. After that, typing python or pip finds the venv’s versions first. deactivate restores the old PATH.

Two consequences follow. First, you never need to activate. Running .venv/bin/python app.py uses the venv fully, because the interpreter finds pyvenv.cfg on its own. That’s the right way to run things in cron jobs, systemd units, Dockerfiles and CI scripts, where “remember to activate first” is easy to forget. Second, activation only affects the shell where you ran it. An IDE, a new terminal tab or a process started by another tool won’t see it.

Why python -m pip, not pip

pip on your PATH is just a script, and it installs into whichever Python its first line names. Here’s the first line of the venv’s pip:

#!/…/ve/.venv/bin/python3.14
Text

That absolute path is fine until something changes. When the venv directory was renamed in a test, running ./moved-venv/bin/pip --version failed with zsh’s bad interpreter: ... no such file or directory, because the shebang still pointed at the old location. Venvs aren’t relocatable, so don’t move, copy or commit them; recreate them.

The more common problem is a mismatch: the pip you run belongs to one Python, and the python you run belongs to another. You install requests, run your script, and get ModuleNotFoundError: No module named 'requests'. python -m pip install requests removes the ambiguity because it runs pip as a module of that exact interpreter, so packages land where that interpreter will look. The ModuleNotFoundError guide walks through diagnosing it.

On Homebrew, recent Debian and Ubuntu, and other distributions, the system Python refuses installs entirely:

error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try brew install
    xyz, where xyz is the package you are trying to
Text

That’s PEP 668 protecting the OS’s own packages. The intended answer is a venv (or pipx for command-line tools), not --break-system-packages. The externally-managed-environment guide covers the options.

Isolation isn’t reproducibility

A venv answers “where are packages installed?” It says nothing about which versions. A requirements.txt containing flask installs whatever Flask is newest on the day you run it, plus whatever versions of Werkzeug, Jinja2 and the rest pip resolves. Run it in March and again in September and you can get different code, on a branch nobody changed.

Reproducibility needs three things written down:

  1. Direct dependencies with compatible ranges, in pyproject.toml (dependencies = ["flask>=3.0,<4"]). These say what your code needs.
  2. A lock file with exact versions of everything, including transitive dependencies, ideally with hashes. pip freeze > requirements.txt is the simplest form. pip-tools (pip-compile), Poetry (poetry.lock) and uv (uv.lock) produce better ones, because they separate what you asked for from what was resolved and can include --hash lines so a tampered package fails to install.
  3. The Python version, in requires-python and in your CI and Docker images (python:3.14-slim). Lock files are resolved for a Python version and platform; a wheel that exists for 3.12 on Linux may not exist for 3.14 on macOS.

pip freeze has a weakness worth mentioning in an interview: it records everything in the environment, including tools you installed by hand to debug something, and it doesn’t distinguish direct from transitive dependencies. Over time nobody knows why half the pins exist. Tools that compile a lock from a short list of direct dependencies avoid that.

Install from the lock the same way everywhere: python -m pip install -r requirements.lock (add --require-hashes if it has hashes) or uv sync --frozen. Updating dependencies then becomes a deliberate change that shows up as a diff in code review.

Interview practice: works on my laptop

A service runs fine locally but crashes in production on startup with ImportError: cannot import name 'url_quote' from 'werkzeug.urls'. The repo has a requirements.txt containing flask==2.2.2 and nothing else. The production image was rebuilt yesterday. What happened, and what process changes prevent it?

Answer

flask==2.2.2 is pinned, but its dependency Werkzeug isn’t. Flask 2.2 declares a lower bound on Werkzeug but not an upper one, so the rebuilt image resolved the newest Werkzeug, a major version that removed url_quote, which that Flask version imports. The developer’s laptop still has the older Werkzeug installed from months ago in their venv, so it works there. The venv isolated packages correctly; nothing pinned the transitive version. To prevent it: keep direct dependencies in pyproject.toml, generate a lock file that pins every package (with pip-compile --generate-hashes or uv lock), install production images strictly from the lock, and pin the Python version in the Dockerfile. Upgrades then happen through a pull request that changes the lock, and CI runs the tests against exactly the versions that will ship. As an immediate fix, pin Werkzeug to the last compatible 2.x version or upgrade Flask to a release that supports the new Werkzeug, and rebuild.

Where to go next

More in Python

read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc