A layered mental model of the Python tooling stack — what each tool is, the exact problem it solves, and how the pieces fit together. Written for developers coming from other ecosystems who want to understand it, not just copy-paste commands.
| Tool | One-line job | You use it for |
|---|---|---|
| Python | Runs your .py code |
The interpreter itself; many versions can coexist |
| pip | Installs packages from PyPI | Getting a package — but into whatever env is active |
| venv | Isolated interpreter + private site-packages |
One per project, so deps don't collide |
| pipx | One venv per CLI app, command put on PATH |
Tools you run (ruff, black, httpie) — not libs you import |
| uv | Fast (Rust) all-in-one | Replaces pip + venv + pipx + pyenv, adds lockfiles |
If you're starting fresh in 2026: learn the concepts once with the classic tools, then use
uvfor everything.
Python installs packages per interpreter, into a shared site-packages
folder. Two pains fall out of that:
- Version conflicts — project A needs Django 4, project B needs Django 5. They can't share one global folder.
- Breaking the OS — your operating system ships its own Python and depends
on it.
pip installinto that system Python can break system tools.
Every tool below exists to solve some flavor of "keep things isolated and reproducible." Keep that thread in mind and the whole zoo makes sense.
A program that reads .py files and executes them (CPython). You can have 3.11,
3.12, 3.13 installed side by side; each is independent with its own
site-packages. Always ask first: "which Python am I using?"
which python && python --versionFetches packages from PyPI (the central registry, like npm's) and installs them into the currently active interpreter. It resolves dependencies and unpacks wheels/sdists. Its limitation: no isolation of its own — it installs into whatever Python is active right now.
A virtual environment is just a folder containing a symlink to an
interpreter plus a private site-packages. "Activating" it simply puts that
folder's bin/ at the front of your PATH.
$PATH search order while a venv is "active":
.venv/bin/ ─► python pip ◄── these now point INSIDE the sandbox
/usr/bin/ (shadowed)
...
python -m venv .venv # create
source .venv/bin/activate # prepend .venv/bin to PATH
deactivate # remove it again
There's nothing mystical here — a venv is a directory plus a PATH trick.
Mental model to lock in: one venv per project.
This single distinction explains every tool above venv:
- Libraries for a project you're developing (Django, pandas) → belong in
that project's venv. Handled by
venv+pip, Poetry, oruv. - CLI apps you want available everywhere (ruff, black, httpie) → you just
want the command on
PATH, isolated from everything. Handled by pipx (oruv tool/uvx).
Automates the pattern "give each CLI tool its own private venv, then expose
only its command on PATH."
~/.local/pipx/venvs/
├── ruff/ ← isolated venv (ruff + its deps)
│ └── bin/ruff ──┐
├── black/ ← │ symlinked onto PATH
│ └── bin/black ─┤
└── httpie/ ← ▼
└── bin/http ~/.local/bin/{ruff,black,http}
Each tool's deps can never clash with another's. Don't install libraries with
pipx — it's for end-user apps, not the packages your code imports.
(pipx run TOOL runs a tool in a temporary env without installing it.)
Deliberately subsumes the whole stack:
- replaces pip →
uv pip install(near drop-in, much faster) - replaces venv →
uv venv(millisecond creation) - replaces pipx →
uv tool install ruff, anduvx rufffor throwaway runs - replaces pyenv →
uv python install 3.13(manages interpreters too) - adds a project manager →
uv init/uv add/uv syncwithpyproject.toml+ auv.locklockfile for byte-for-byte reproducible envs
Speed (often 10–100× pip) comes from being compiled, parallel downloads, a global cache, and hard/reflinking instead of copying.
flowchart TB
PyPI[("PyPI  ·  package registry")]
subgraph core["Core primitives"]
PIP["pip<br/><i>install packages</i>"]
VENV["venv<br/><i>isolated interpreter + site-packages</i>"]
end
subgraph apps["Job A — CLI apps (one venv per tool)"]
PIPX["pipx"]
UVTOOL["uv tool / uvx"]
end
subgraph proj["Job B — project dependencies"]
OLD["pip + requirements.txt<br/>Poetry / pipenv"]
UVPROJ["uv add / uv sync + uv.lock"]
end
UV(["uv — fast all-in-one (Rust)"])
PIP -->|downloads from| PyPI
PIP -->|installs into| VENV
PIPX -->|builds| VENV
PIPX -. "uses as backend" .-> PIP
PIPX -. "uses as backend" .-> UV
UV -. replaces .-> PIP
UV -. replaces .-> VENV
UVTOOL -. part of .-> UV
UVPROJ -. part of .-> UV
classDef new fill:#e8f5e9,stroke:#43a047,color:#1b5e20;
class UV,UVPROJ,UVTOOL new;
Note that uv shows up in two roles relative to pipx — see below.
Two different meanings of "build" get conflated:
- Building a venv — loose wording. No compilation. It just means creating the directory and copying/linking package files in. uv is "faster at building venvs" in the sense of setting them up, not compiling.
- Building a package — the real one. Your
.pycode is never compiled, but many libraries (numpy, pandas, cryptography, Pillow, pydantic-core) ship chunks of C / C++ / Rust for speed. That native code must be compiled for your OS + CPU.
What usually saves you from compiling is the wheel:
- wheel (
.whl) = pre-built for your platform → install = unzip + copy, no compiler needed. - sdist (
.tar.gz) = source only → must be built locally (this is where "missing C compiler" errors come from).
pure-Python lib (requests) → no build, just copy files
native lib WITH a wheel (numpy) → no local build, wheel was prebuilt for you
native lib, sdist only → local compile (needs a toolchain)
Logically, yes — every venv is independent and lists its own copy of each dependency. That's the price of isolation.
- With the pip backend: real byte-for-byte copies.
click==8.1.0in five tools = five physical copies. (Usually small for CLI tools; matters with heavy native deps.) - With uv: the bytes are deduplicated. uv stores each package version
once in a global cache and hard-links (or, on macOS APFS,
copy-on-write reflinks via
clonefile) it into each venv. The file appears in every venv but the data lives once.
flowchart LR
CACHE[("uv global cache · ~/.cache/uv<br/>click 8.1.0 — stored ONCE")]
V1["ruff venv<br/>…/click → link"]
V2["httpie venv<br/>…/click → link"]
V3["black venv<br/>…/click → link"]
CACHE -. "hardlink / reflink" .- V1
CACHE -. "hardlink / reflink" .- V2
CACHE -. "hardlink / reflink" .- V3
Verify it yourself:
uv cache dir # where the shared store lives
ls -li <venv>/.../site-packages/<file> # link count >1 ⇒ shared
du -sh ~/.local/pipx/venvs/* # apparent size per venv
du -sh ~/.local/pipx/venvs # whole set — often LESS than the sum
# (du counts shared inodes once)Caveats: sharing needs the cache and venv on the same filesystem; it's
per-exact-version (8.1.0 ≠ 8.2.0). So with uv you get isolation and
deduplication at the same time — a big reason to standardize on it.
uv appears in two unrelated roles next to pipx:
1. uv as pipx's backend (engine under pipx).
pipx is a manager — it decides "make a venv, install the app, symlink the
command." The grunt work of creating the venv and installing packages is
delegated to a backend: classically pip+venv, or (newer/faster) uv.
Modern pipx auto-picks uv when it can find it. You see this only when it breaks:
The uv backend was requested but the 'uv' executable could not be found.
That message = "pipx wants uv as its engine but can't find it." (Notably, this
makes pipx reinstall uv self-referential — pipx removes uv, then needs uv to
rebuild uv. Use pipx upgrade uv instead, or --backend pip to break the
cycle.)
2. uvx (uv doing pipx's job itself).
uvx is a command shipped with uv — shorthand for uv tool run — that runs a
CLI tool in a throwaway env. It's basically a competitor to pipx, not part
of it.
uv as BACKEND → works FOR pipx (hidden engine pipx calls)
uvx / uv tool → competes WITH pipx (uv installs/runs tools directly)
Same uv binary, opposite relationships. Because uv tool install + uvx
fully cover what pipx does, many people drop pipx once they have uv.
Installing the same tool three ways gives you three copies and endless confusion:
~/.local/bin/uv ← standalone binary ← THIS one runs
~/.local/pipx/venvs/pip/bin/uv ← from `pip install uv` (buried, off PATH)
~/.local/pipx/venvs/uv/bin/uv ← from `pipx install uv` (symlink blocked!)
pipx refuses to overwrite a ~/.local/bin/uv it didn't create, so a freshly
installed version stays invisible while an old standalone binary keeps winning
the PATH race. Diagnose and fix:
which -a uv # list every uv on PATH, in priority order
readlink ~/.local/bin/uv # real file? or a symlink — and to where?
hash -r # clear your shell's cached path after changing thingsLesson: pick one tool per job and don't install the same thing three ways.
| Task | Classic | uv |
|---|---|---|
| Install a Python version | pyenv install 3.13 |
uv python install 3.13 |
| New project env | python -m venv .venv && source .venv/bin/activate |
uv venv (auto-used) |
| Add a project dependency | pip install django (+ edit requirements) |
uv add django |
| Reproduce an env | pip install -r requirements.txt |
uv sync (from uv.lock) |
| Install a CLI tool globally | pipx install ruff |
uv tool install ruff |
| Run a CLI tool once | pipx run ruff |
uvx ruff |
| Upgrade a CLI tool | pipx upgrade ruff |
uv tool upgrade ruff |
Concepts current as of 2026. The packaging frontier moves fast — but the isolation/reproducibility ideas underneath every tool are stable, and that's the part worth internalizing.