Where your Python packages actually live
venv isolates them, uv makes installing them almost free, and pex bundles them into one file you can throw at any machine. A field guide to three tools and the one question they each answer.
You run pip install pandas. It says “Successfully installed.” You feel good.
Then you ask: installed where, exactly?
If you have been writing Python for more than a week, you have probably bumped into the answer at the worst possible time. The package installed fine on your laptop. The deploy fails. Your colleague’s script breaks after you install something unrelated. A job you wrote six months ago silently starts behaving differently because something upstream upgraded. The phrase “works on my machine” was practically coined for Python packaging.
The confusion is not accidental. Python’s default install target is a global
site-packages directory shared by every script on the machine. Everything
you pip install lands there together, which means every project shares the
same dependency graph whether you want that or not. That is a workable setup
for a single script and a slow-motion disaster for anything larger.
Three tools exist to fix three different parts of this problem. They are not competitors. They are answers to three distinct questions.
venv: where exactly does “isolated” mean?
A virtual environment is not magic. It is a folder.
Run python -m venv .venv and Python creates a directory with a specific
structure: a bin/ folder containing a python symlink (not a copy of the
interpreter — just a lightweight link back to the Python binary you called
the command with), and a lib/pythonX.Y/site-packages/ directory that starts
completely empty.
That is it. The isolation lives in that empty site-packages.
When you “activate” the environment with source .venv/bin/activate, your
shell prepends .venv/bin to the PATH. The python that now answers when
you type python is the one inside .venv/bin/. And because that binary has
its own site-packages baked in as its home, any pip install you run after
activation writes packages there — not into the global directory, not into any
other project’s directory.
Open up .venv/lib/pythonX.Y/site-packages/ after installing something and
you will see the package sitting right there. No abstraction, no container. A
folder with wheel contents unpacked into it.
The practical consequence is large and simple: two projects can each have their
own pandas at their own version, with no conflict, because each project looks
in its own isolated directory. A requirements.txt or pyproject.toml pinned
to specific versions means the environment is reproducible: delete the .venv
folder, recreate it, run pip install -r requirements.txt, and you have the
exact same graph back.
The one thing venv does not solve is speed. Creating an environment and
installing fifty packages still takes meaningful time because pip downloads
each wheel from PyPI every single time.
uv: why does it feel instant?
Astral’s uv is written in Rust, which is part of the story. But the bigger
part is architectural, and understanding it changes how you think about the tool.
Every wheel uv ever downloads lives in a single global content-addressed cache
at ~/.cache/uv. When you run uv pip install pandas in a fresh project, uv
resolves the dependency graph, fetches any wheels it does not have yet, and
unpacks them into the cache. The first install of a given wheel version takes as
long as downloading it takes.
The second install — in any project on the same machine — takes almost no time
at all. uv does not download the wheel again. It creates a hardlink from
the cache into the new venv’s site-packages. On filesystems that support
reflinks (like APFS on macOS and many modern Linux filesystems), it uses a
reflink instead, which is even more efficient. Either way, the bytes live exactly
once on disk. The “installation” is just a directory entry pointing at data that
already exists.
This is the thing worth understanding about uv. It is not a faster download
tool. It is a tool that largely eliminates the download from every install after
the first. If you work across five projects that all use the same version of
numpy, numpy exists once on disk and each project’s venv has a hardlink to
it. Creating a fresh environment in CI, a Docker build that reuses the cache
layer, or a colleague’s machine after they have already worked on similar
projects — all of these become nearly instant.
uv also quietly absorbs jobs that used to require separate tools. uv venv
replaces virtualenv. uv pip install replaces pip. uv add, uv lock,
and uv sync give you project-level dependency management with a lockfile.
uv python install can download and manage Python versions themselves, so you
can stop thinking about pyenv too. The surface area is large, but the mental
model is simple: one fast tool, one cache, everything else follows from that.
pex: the question nobody thinks to ask until deploy day
Both tools above assume the target machine will run pip install at some point.
For most development workflows that is fine. For shipping code — particularly a
CLI tool, an internal service, or a PySpark job running on a cluster you do not
control — it becomes a liability.
The pex project (from pantsbuild) answers a different question: what if the
code and all its dependencies could travel as a single file?
A .pex file is an executable zip. It bundles your code alongside the wheel
archives for every dependency. You produce it once with something like
pex my_package -e my_package.main:run -o app.pex, and the result is a single
file you can scp to a server, drop into a Docker image, or hand to a Spark
executor. On the target machine, running ./app.pex launches it directly.
The mechanism on the target is clean: when a .pex runs, it extracts its
bundled wheels into a local cache (typically under ~/.pex) and adjusts
sys.path to point at those extracted packages, bypassing the system’s
site-packages entirely. The result is hermetic in the ways that matter most:
no version conflicts with whatever else is installed on the host, no pip
invocation, no network access at runtime.
This is especially valuable in data infrastructure. A PySpark job needs a
specific version of a dozen libraries. The Spark workers may have an arbitrary
Python environment — or a minimal one. Bundling the job as a .pex and
passing it to the cluster means the job brings its own dependency universe.
No coordinating with the cluster admin, no “this worked yesterday” mysteries.
Internal CLI tools distributed to teammates who should not need to care about
virtual environments also become much simpler: one file, one permission bit,
one command.
Three tools, three questions
The mental model that makes all of this click is that each tool answers exactly one question:
venv answers: where do my packages live, and how do I keep them away from everything else?
uv answers: how do I install packages without waiting, and without managing five separate tools?
pex answers: how do I ship my code plus its dependencies to a machine I do not control, without running pip on it?
Using all three is not redundant. You develop in a uv-managed venv, which is
fast and reproducible. You build a .pex for distribution, which is hermetic
and portable. The venv gives you isolation during development; the .pex gives
you isolation at runtime, somewhere else entirely.
Most packaging confusion comes from treating “where do packages live” as a single problem with a single answer. It is three problems. These are three answers.
The full mechanics — with diagrams showing what exactly is inside a .venv
folder, how uv’s content addressing works, and how to build and inspect a
.pex — are in the course notes under Python › Pythonic Mid-Level
› Environments & Packaging.