datarekha

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.

10 min read Intermediate Python Lesson 19 of 41

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

Sooner or later every Python developer trips over the same three questions, and they turn out to be questions about plumbing — where files live, and how they get there. The first is the one everyone hits first: why does import numpy work in one project and fail in another, when you are certain you installed it? The second arrives the day slow installs start to hurt: is uv genuinely faster than pip, or is that just marketing? And the third shows up when you finally need to deploy: how do you hand someone a Python app as a single file, with no pip install step on their machine?

This lesson answers all three by making the plumbing visible — what a virtual environment actually is, why uv is structurally fast rather than merely tuned, and how a .pex file folds an entire app into one runnable artifact.

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 alone
  • bin/python — a symlink (or lightweight copy) pointing at the base interpreter
  • bin/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.

System / globalVirtual env (.venv/)site-packages/requests==2.28numpy==1.24… every project shares this/usr/bin/python3pip install → anywhere.venv/bin/python → symlink(points at base interpreter).venv/lib/pythonX.Y/site-packages/requests==2.31numpy==2.0isolated to this projectpip install → venv only
Left: global site-packages, shared by all projects. Right: a venv gives each project its own isolated site-packages. The interpreter is a symlink, not a full copy.

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.

~/.cache/uv(global content-addressed cache)numpy-2.0-cp311-*.whlrequests-2.31-*.whldownloaded once — lives hereproject-a / .venv/site-packages/numpy/→ hardlink to cache bytesproject-b / .venv/site-packages/numpy/→ hardlink to cache byteshardlinkhardlinkdownloaded once from PyPI →
uv downloads each wheel once into a global cache. Every subsequent install in any venv gets the same bytes via a hardlink — no network, no disk copy.

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 python3 shebang 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.

Build stepyour_code/requests, pandas, …(third-party wheels)pex your_code/ -r req.txt -o app.pexapp.pexexecutable ZIP#!/usr/bin/env python3code + dep wheelsone file to shipHost Ahas Python 3.11./app.pex ✓ just worksHost B (cluster node)has Python 3.11./app.pex ✓ just worksno pip install on target needed
pex bundles your code and all dep wheels into one executable ZIP. Copy it to any machine that has a compatible Python — no install step required.

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?

ToolPrimary jobWhere packages liveSpeedUse when
venvIsolation.venv/lib/pythonX.Y/site-packages/Standard pip speedSimple projects, no extra tooling, built into stdlib
uvFast install + resolve + venv + Python managementSame venv site-packages/ (via hardlinks from global cache)10–100x pipCI pipelines, day-to-day dev, teams needing lock files
pexSingle-file distributionBundled inside the .pex ZIP; unpacked to ~/.pex/ at runtimeBuild-time work, instant to runDeploying 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

In one breath

  • A virtual environment is an isolated site-packages/ plus a bin/python symlink to the base interpreter — not a second copy of Python.
  • pip install lands in whichever site-packages is active; activating a venv makes that the venv’s own.
  • uv fills that same venv site-packages, but by hardlinking from a global content-addressed cache — each wheel downloaded once, near-instant ever after.
  • A .pex bundles your code and dependency wheels into one executable ZIP; the target still needs a compatible Python, but no pip install.
  • Reach for venv (simple, built in), uv (almost always), or pex (ship one file).

Quick check

0/3
Q1You run `python -m venv .venv` and then `source .venv/bin/activate`. What does the `.venv/bin/python` file actually contain?
Q2You run `uv pip install numpy` in project-a's venv, then again in project-b's venv. How many times is numpy's wheel downloaded from PyPI?
Q3Your team ships a PySpark job to a cluster using a .pex file. A cluster node does NOT have requests or pandas installed. What happens when app.pex runs?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions

Related lessons

Explore further

Skip to content