How Python’s venv Works — Why It Keeps Projects From Fighting Over the System Python
Anyone who has worked with Python tooling has run into “virtual environments” (venv) sooner or later. A single command, python3 -m venv .venv, creates a directory that most Python projects treat as a given. What is that directory actually doing, and why has it become such a standard part of the workflow? This post looks at the problem venv solves and how it works under the hood.
The Problem: Projects Sharing One Python Installation
Working on multiple Python projects on the same machine eventually runs into a conflict: one project needs version 2 of a library, another needs version 3 of the same library. Running pip install directly against the system Python installs into one shared site-packages directory for the entire machine, so installing the second project’s dependencies overwrites the first project’s, and one of them breaks.
Note:
site-packagesis the directory where third-party libraries installed viapip installactually live. Python looks inside it whenever code does animport.
On some operating systems, the system Python is also used internally by OS-level tooling (package managers, for instance), so carelessly installing or upgrading libraries into that shared location risks affecting the OS itself, not just your own projects. A virtual environment sidesteps this by giving each project its own independent site-packages, rather than everyone sharing one pool.
What Actually Happens Inside a venv
Running python3 -m venv .venv creates a directory roughly structured like this:
.venv/
├── bin/ # python3/pip executables (symlink or copy)
├── lib/.../site-packages/ # this project's own library directory
├── pyvenv.cfg # records where the base system Python lives
The key detail is that .venv/bin/python3 isn’t a separate Python implementation — it’s the same interpreter as the system Python, either copied or symlinked. What changes is where it looks for libraries. When .venv/bin/python3 starts up, it reads pyvenv.cfg and redirects its site-packages search path to .venv/lib/.../site-packages instead of the system-wide location. Any pip install run through that interpreter installs into this project-local directory, leaving the system Python’s environment untouched.
The familiar source .venv/bin/activate command isn’t the mechanism that provides this isolation — it’s a convenience shell script layered on top. Running it just prepends .venv/bin to the PATH environment variable and sets a VIRTUAL_ENV variable, so that typing python or pip in the terminal afterward resolves to the venv’s copies first. The isolation itself doesn’t require activation at all: calling .venv/bin/python3 script.py by its full path gets you the same isolated environment, activated or not.
A Real Example: Startup-Time venv Management in _launcher.sh
This app’s Mac/Linux launch script, _launcher.sh, relies on exactly that direct, activation-free call for a health check:
if [ ! -f ".venv/bin/python3" ]; then
NEEDS_SETUP=1
elif [ ! -f "$STAMP" ] || [ "$(cat $STAMP)" != "${ARCH}:${PY_VER}:${REQ_HASH}" ]; then
NEEDS_SETUP=1
elif ! .venv/bin/python3 -c "import sys; sys.exit(0)" >/dev/null 2>&1; then
NEEDS_SETUP=1
fi
Rather than rebuilding .venv on every launch, the script keeps a stamp file at .venv/.install_stamp containing three values as a single string: the machine architecture (uname -m), the Python version, and a hash of requirements.txt. On the next launch, it compares this composite key against the stored one. If they match, .venv is assumed unchanged and reused as-is — the script just runs source .venv/bin/activate and moves on. If any of the three has changed (a dependency bump shipped in an app update, or the machine changing from Intel to Apple Silicon, for example), .venv is deleted entirely and rebuilt from scratch with python3 -m venv .venv followed by pip install -r requirements.txt.
The line .venv/bin/python3 -c "import sys; sys.exit(0)" looks like it does nothing, but it’s a health check confirming the venv’s Python binary can actually run. This can fail, for example, after an OS update moves the system Python’s location out from under a symlink the venv still points to — in which case a rebuild is triggered as well.
This “don’t rebuild unless the requirements.txt hash changes” design carries a hidden assumption worth naming. As covered in our post on pip’s version-specifier syntax, this app’s own requirements.txt uses >= lower-bound specifiers (like flask>=3.0.0) rather than == exact pins. That means the file’s hash can stay identical across two separate pip install runs, while the actual library versions installed can still differ depending on when the install happened. A matching hash guarantees the venv was built from the same requirements.txt, not that its installed contents are byte-for-byte identical every time — a distinction worth keeping in mind.
Summary
A venv solves the problem of multiple projects fighting over one shared Python installation by giving each project its own isolated site-packages. activate is a convenience layer that temporarily rewrites PATH; the actual isolation works by simply calling .venv/bin/python3 directly. This app’s launcher script leans on that property, checking for environment drift (architecture, Python version, dependency changes) via a hash-based stamp file instead of rebuilding the environment on every single launch — while still catching the cases where it genuinely needs to.