Environments & packaging: venv, uv, pex
Where your packages actually install, why uv is 10-100x faster than pip, and how to ship a Python app as one file.
What you'll learn
- What a virtual environment really is — an isolated site-packages dir + a python symlink
- Where venv, uv, and pip install packages, and how to inspect it
- Why uv is fast: a global content-addressed cache + hardlinks into each venv
- How a .pex file bundles your code + deps into one runnable artifact
- When to reach for venv vs uv vs pex
Before you start
Three questions that every Python developer eventually asks:
- Where do my packages actually go? Why does
import numpywork here but not there? - Why is
uvso much faster thanpip? Is it just marketing, or something structural? - How do I ship a Python app as one file? Without a
pip installstep on the target.
This lesson answers all three — with the plumbing made visible.
Virtual environments: isolation via a private site-packages
When Python resolves import requests, it walks sys.path — a list of directories. One of those directories is site-packages: the folder where pip install drops packages.
By default that’s your system or user site-packages, shared across every project. That breaks fast: project A wants requests==2.28, project B wants requests==2.31. They can’t coexist in the same directory.
A virtual environment (venv) is a lightweight directory that contains:
lib/pythonX.Y/site-packages/— an empty site-packages that belongs to this project alonebin/python— a symlink (or lightweight copy) pointing at the base interpreterbin/pip,bin/activate— helpers that know about this venv
The interpreter itself is not fully copied. bin/python is a symlink to the real Python binary on your machine. The venv is just a new home for packages.
Creating and activating a venv
# Create
python -m venv .venv
# Activate (macOS / Linux)
source .venv/bin/activate
# Activate (Windows)
.venv\Scripts\activate
# Verify — should show the venv path
which python
python -c "import sys; print(sys.prefix)"
# Install into the venv
pip install requests
# Deactivate when done
deactivate
Activating prepends .venv/bin to PATH. That makes python and pip resolve to the venv’s copies. When you run python -c "import sys; print(sys.prefix)" inside an activated venv, you’ll see the path to .venv/, not your system Python prefix.
When to use plain venv: simple projects, scripts, when you want no external tooling. It’s built into Python’s stdlib — zero install needed.
uv: Rust-speed installs with a global cache
uv (by Astral, written in Rust) is a drop-in replacement for pip, virtualenv, and pip-tools — plus it can install Python itself. The API is familiar: uv pip install requests works exactly like pip install requests.
So why 10–100x faster? Two structural reasons:
1. A faster resolver. The dependency resolver that figures out compatible versions for your whole requirement set is rewritten in Rust with a far more efficient algorithm than pip’s.
2. A global content-addressed cache. This is the key insight. When uv downloads a wheel, it unpacks it once into a global cache at ~/.cache/uv. On subsequent installs — in any project, any venv — uv finds the wheel already in the cache and hardlinks the files directly into the new venv’s site-packages.
A hardlink means both paths point to the exact same bytes on disk. No copying, no network round-trip. Near-zero time, near-zero disk duplication.
uv commands
# Make a new venv (fast)
uv venv
# pip-compatible install
uv pip install requests numpy
# Project workflow (uses pyproject.toml + uv.lock)
uv add requests # adds to pyproject.toml
uv lock # resolves + writes uv.lock
uv sync # installs exactly what's in the lock file
# Install a specific Python version too
uv python install 3.12
uv venv --python 3.12
Packages still end up in the venv’s site-packages — the isolation model is identical to plain venv. The only difference is how files get there: hardlinks from the cache instead of a network download + unpack each time.
When to use uv: almost always. CI pipelines where reinstalling deps on every run previously took 60 seconds now take 2. Local development where you create and delete venvs frequently. Any team that wants reproducible installs via uv.lock.
pex: ship your app as one executable file
pex (PEX = Python EXecutable, from the Pants project) solves a different problem: deployment without a pip install step.
A .pex file is an executable ZIP archive (a zipapp per PEP 441) with:
- A
#!/usr/bin/env python3shebang at the top — making it directly runnable - Your application code
- All third-party dependency wheels, bundled inside
Run it: ./app.pex or python app.pex. No venv, no pip install, no internet access required on the target machine.
Building and running a pex
# Install pex
pip install pex
# Build: bundle your script + its deps into one file
pex requests click -c my_cli:main -o my_cli.pex
# Run it — no venv, no install
./my_cli.pex --help
# Or ship a whole directory
pex my_package/ -r requirements.txt -e my_package.cli:main -o app.pex
# Cross-platform build (for a Linux cluster node from macOS)
pex requests -o app.pex --platform linux_x86_64-cp-311-cp311
On first run, pex unpacks the bundled deps into ~/.pex/ (its own local cache) and sets up an isolated sys.path — no global site-packages involved. Subsequent runs skip the unpack step.
Comparison: which tool for which job?
| Tool | Primary job | Where packages live | Speed | Use when |
|---|---|---|---|---|
venv | Isolation | .venv/lib/pythonX.Y/site-packages/ | Standard pip speed | Simple projects, no extra tooling, built into stdlib |
uv | Fast install + resolve + venv + Python management | Same venv site-packages/ (via hardlinks from global cache) | 10–100x pip | CI pipelines, day-to-day dev, teams needing lock files |
pex | Single-file distribution | Bundled inside the .pex ZIP; unpacked to ~/.pex/ at runtime | Build-time work, instant to run | Deploying CLIs, PySpark jobs, anywhere you can’t run pip on the target |
Key insight: venv and uv both end up with packages in a venv’s site-packages. They differ in how fast and how smartly they get there. pex sidesteps the question entirely by bundling everything at build time.
Quick inspection commands
# See which python you're running
which python
# See which environment you're in
python -c "import sys; print(sys.prefix)"
# List installed packages in the active env
pip list
# Where is a specific package installed?
python -c "import requests; print(requests.__file__)"
# uv: show cache info
uv cache dir
uv cache prune # remove unused entries
Quick check
Practice this in an interview
All questionsA virtual environment is an isolated Python installation with its own site-packages, preventing version conflicts between projects sharing the same machine. Modern tooling has moved from pip-plus-requirements.txt toward lock-file-based tools: pip-tools, Poetry, and uv, which pin exact transitive dependency versions for reproducible installs.
Docker encapsulates the full runtime environment — OS libraries, Python version, system packages — so the model runs identically everywhere. ONNX provides a hardware- and framework-agnostic model format so a model trained in PyTorch can be executed by a high-performance runtime like ONNX Runtime without the training framework as a dependency.