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

Three questions that every Python developer eventually asks:

  1. Where do my packages actually go? Why does import numpy work here but not there?
  2. Why is uv so much faster than pip? Is it just marketing, or something structural?
  3. How do I ship a Python app as one file? Without a pip install step 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 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

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?

Practice this in an interview

All questions

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.

Explore further

Related lessons

Skip to content