Skip to content

Instantly share code, notes, and snippets.

@pramoth
Created June 28, 2026 11:07
Show Gist options
  • Select an option

  • Save pramoth/4141d570a2ea1f4d51fcbeba1f196e7a to your computer and use it in GitHub Desktop.

Select an option

Save pramoth/4141d570a2ea1f4d51fcbeba1f196e7a to your computer and use it in GitHub Desktop.
Python Packaging, Demystified: interpreter → pip → venv → pipx → uv (a layered mental model with diagrams)

Python Packaging, Demystified: interpreter → pip → venv → pipx → uv

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.


TL;DR

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 uv for everything.


The root problem everything is solving

Python installs packages per interpreter, into a shared site-packages folder. Two pains fall out of that:

  1. Version conflicts — project A needs Django 4, project B needs Django 5. They can't share one global folder.
  2. Breaking the OS — your operating system ships its own Python and depends on it. pip install into 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.


The layers, bottom to top

1. The interpreter — python

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 --version

2. pip — the installer

Fetches 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.

3. venv — the isolation primitive (the foundation of everything above it)

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.

The fork in the road: two reasons you install packages

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, or uv.
  • CLI apps you want available everywhere (ruff, black, httpie) → you just want the command on PATH, isolated from everything. Handled by pipx (or uv tool / uvx).

4. pipx — venvs for applications

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.)

5. uv — the fast all-in-one (Rust)

Deliberately subsumes the whole stack:

  • replaces pipuv pip install (near drop-in, much faster)
  • replaces venvuv venv (millisecond creation)
  • replaces pipxuv tool install ruff, and uvx ruff for throwaway runs
  • replaces pyenvuv python install 3.13 (manages interpreters too)
  • adds a project manageruv init / uv add / uv sync with pyproject.toml + a uv.lock lockfile 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.


How the tools relate

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;
Loading

Note that uv shows up in two roles relative to pipx — see below.


"Why does Python need to build? It's just an interpreter."

Two different meanings of "build" get conflated:

  1. 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.
  2. Building a package — the real one. Your .py code 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)

Disk usage: does each pipx venv duplicate shared libraries?

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.0 in 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
Loading

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.08.2.0). So with uv you get isolation and deduplication at the same time — a big reason to standardize on it.


The confusing part: "uv as pipx's backend" vs uvx

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.


A real-world pitfall: shadowed installs

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 things

Lesson: pick one tool per job and don't install the same thing three ways.


Cheatsheet — classic vs uv

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment