Python Packaging: What Most Devs Get Wrong

2026/02/14

BUILDdependencies

You run pip install dozens of times a week. You probably have no idea what actually happens when you do.

The machinery is surprisingly simple — and once you see it, every tool choice in the ecosystem starts making sense.

Wheels are zip files

Literally. Rename a .whl to .zip and unzip it.

cp requests-2.31.0-py3-none-any.whl requests.zip
unzip requests.zip -d requests_contents/

You’ll find the package source and a metadata directory. That’s the whole format. “Installing” a package is mostly extracting a zip into a specific folder. This is a big part of why uv is fast — there’s no compilation step for wheels, no complex transaction. It’s unzipping.

site-packages is just a folder

When you write import numpy, Python walks a list of directories — sys.path — looking for a match. The most important one is site-packages. That’s where your installed packages live.

python -c "import site; print(site.getsitepackages())"

The entire “install a package” operation is really just “put files in this folder.” Different tools do it differently:

That’s the whole mechanism. There’s no registry, no database. A folder.

A virtualenv is a PATH trick

A virtualenv is a directory with a bin/python symlink and its own site-packages. Activating it just prepends the venv’s bin/ to your PATH so that python resolves to the venv’s interpreter.

python -m venv .venv
source .venv/bin/activate
which python  # .venv/bin/python

The “isolation” is that each venv has its own site-packages folder. Python sees that folder first, ignores the system one. That’s it — no containers, no namespaces, no magic. A symlink and a directory.

This is also why creating a venv is fast. There’s barely anything to create.

install vs sync — additive vs declarative

This distinction trips people up constantly.

Mental model: install is apt install. sync is terraform apply.

If you’ve ever wondered why your environment has 200 packages when your requirements file lists 12 — it’s because you’ve been using install for months and never cleaned up. sync is the fix.

The .dist-info folder matters

Next to every package in site-packages, there’s a metadata directory:

site-packages/
├── numpy/
├── numpy-1.26.4.dist-info/
│   ├── METADATA
│   ├── RECORD
│   ├── WHEEL
│   └── entry_points.txt

This is how tools know what’s installed. importlib.metadata.version("numpy") reads from here. pip list reads from here. Dependency resolution reads from here.

This is also why hacking site-packages — like Bazel’s rules_python sometimes does — breaks things. Packages expect .dist-info to exist in a specific location with specific contents. Fake it wrong and pkg_resources throws errors that make no sense until you know this.

This comes up constantly in packaging tools, and most people hand-wave past it.

# hardlink — same inode
ln original.txt hardlink.txt
stat original.txt hardlink.txt  # same inode number

# symlink — pointer
ln -s original.txt symlink.txt

uv uses hardlinks from a global cache by default. First install downloads and caches the wheel. Every subsequent install into any venv just creates hardlinks — no bytes copied, near-instant. This is probably the single biggest reason uv pip install feels unreasonably fast on a warm cache.


Python packaging feels broken because people don’t have a mental model of what’s happening. But the machinery is dead simple — zip files, a folder, a PATH change, and some metadata. Once that clicks, the tool choices — uv’s hardlinks, venvs over global installs, sync over install — stop being opinions and start being obvious.