Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

Python Packaging Was a Disaster. It's Called uv Now.

A cluttered Victorian workshop of tangled pipes, hissing snakes, and competing label-makers being swept aside as a single sleek brass multi-tool does every job at once on a clean bench — 1960s gouache.

There is a rite of passage in Python that has nothing to do with the language. You write a small script, it works on your machine, you send it to a colleague, and it doesn't work on theirs. Somewhere in the next hour you will type python -m venv, then pip install -r requirements.txt, then discover the requirements file pinned nothing, then learn that the colleague is on a different Python entirely, and you will reach for pyenv to fix that, at which point you have used three tools to run twelve lines of code that imports requests. This is normal. This has been normal for fifteen years, and we mostly stopped noticing it the way you stop noticing a draught under a door you've walked past for a decade.

The packaging story was a Tower of Babel because each tool was built to fix exactly one crack in the previous one, and nobody was ever in a position to rebuild the foundation. pip installs packages but has no opinion about isolation. venv gives you isolation but doesn't manage Python versions. pyenv manages Python versions but knows nothing about your dependencies. pip-tools bolts a real lockfile onto pip because pip never had one. poetry tried to do the whole job at once and largely succeeded, but with its own resolver, its own metadata format that predated the standards, and an install speed that turned CI into a coffee break. Five tools, four overlapping mental models, and a new contributor who has to learn all of it before they can change a line.

What uv Actually Consolidates

uv is a single binary, written in Rust by Astral — the people behind ruff — and it does the jobs of that entire list. Not "integrates with"; replaces. It resolves and installs dependencies like pip, manages virtual environments like venv, downloads and pins Python interpreters like pyenv, produces a real cross-platform lockfile like pip-tools, and manages a project's full lifecycle like poetry. One thing to install, one thing to teach, one thing to keep current.

The mapping is the clearest way to see the consolidation:

You used to reach for uv equivalent
python -m venv .venv uv venv (or implicit — uv makes it for you)
pip install requests uv add requests
pip install -r requirements.txt uv sync
pip-compile to lock uv lock (automatic on uv add)
pyenv install 3.12 uv python install 3.12
python script.py uv run script.py
poetry for the whole project uv for the whole project

The uv run line is the one that quietly changes your habits. It guarantees the command runs inside the project's environment with the locked dependencies present, creating or updating that environment first if it has drifted. You stop activating virtualenvs by hand, because the activation was always just ceremony around "use the right interpreter and the right packages", and uv does that as a precondition of running anything.

Lockfiles and Reproducibility, Done Properly

The part that matters more than the convenience is the lockfile, because a lockfile is the difference between "it works on my machine" being a joke and being a guarantee. requirements.txt was never a lockfile; it was a wish list that people sometimes pinned and sometimes didn't, with no record of the transitive tree, no hashes, and no notion of which platform it was resolved for. pip-tools gave you a locked requirements.txt with hashes, which was a real improvement that roughly nobody adopted because it was an extra step you had to remember.

uv produces a uv.lock automatically, and updates it every time you change a dependency. It is a universal lockfile — it resolves for every platform and Python version your project declares it supports, not just the one you happened to run the resolve on. That last point is subtle and load-bearing. The common failure with single-platform locks is that you develop on macOS, lock there, deploy to Linux, and discover a dependency that ships a different wheel — or no wheel — on the target. A universal lock pins the whole matrix up front, so uv sync --frozen on the CI runner installs precisely what the lock says and fails loudly if the lock is stale rather than quietly resolving something new.

$ uv sync --frozen
Resolved 47 packages in 3ms
Installed 47 packages in 211ms

That is a cold reconcile against an existing lock. The numbers are not a typo, and they are the whole argument for the next section.

Why the Speed Changes How You Work

It is tempting to treat the speed as a vanity metric — nice, but not a reason to migrate. That underrates it. Speed below a certain threshold stops being a quantitative improvement and becomes a qualitative one, because it changes what you are willing to do casually. A resolution that took ninety seconds was something you avoided; you'd nurse a broken environment for an afternoon rather than blow it away and rebuild. When the rebuild takes under a second, you stop nursing anything. Environment broken? Delete .venv, uv sync, carry on. The environment becomes disposable, which is exactly what it should always have been.

The same shift happens in CI. When installs dominated the pipeline, people cached aggressively, and cache invalidation being what it is, they shipped subtly wrong dependency sets more often than anyone admits. When the install is faster than the cache restore, you can stop caching dependencies and resolve from the lock every time — both faster and more correct. Speed buys you the right to be simpler.

uvx — the throwaway-tool runner, equivalent to pipx but instantaneous — is the small feature that demonstrates the principle. uvx ruff check . fetches ruff, runs it in an ephemeral environment, and discards it, fast enough that you'd never think to install it permanently. The cost of running a tool once dropped low enough that "just run it once" became the default.

Migrating Off Poetry, pip-tools, and Bare pip

The migration is far less painful than the years of accumulated tooling would suggest, mostly because uv speaks the standards rather than inventing its own.

From bare pip and venv, there is almost nothing to do. uv init, then uv add -r requirements.txt to import the file, and uv writes a pyproject.toml and a lock. Delete the requirements.txt and the manual venv dance. This is the path of least resistance and the one I'd start with if you want to feel the difference in twenty minutes.

From pip-tools, your requirements.in is your declared-dependency list and maps cleanly onto pyproject.toml's dependencies array. Move the top-level pins across, run uv lock, and uv.lock supersedes your compiled requirements.txt. You lose nothing — you had a real lockfile already — and you gain Python-version management and the speed.

From poetry, the project metadata is already in pyproject.toml, which helps, but poetry historically used a [tool.poetry] table rather than the standard [project] table from PEP 621. uv reads the standard table, so the one real task is moving your dependencies, version, and metadata out of [tool.poetry] and into [project]. It's mechanical, it's a handful of fields, and the dependency-specifier syntax is nearly identical. Once moved, uv lock replaces poetry.lock and uv sync replaces poetry install. The resolver is different and occasionally stricter, so do the migration on a branch and run your test suite — the only surprises I've hit were dependencies that were quietly broken under poetry's looser resolution and that uv correctly refused to install.

When Not to Bother

Honesty keeps this from reading as a sales pitch. If you have a large, stable poetry project with a working CI pipeline, a team that knows the tooling, and no pain, you do not have to migrate this week. uv is not going to abandon you to a broken build if you stay — but neither is poetry going to get faster, and the standards are moving uv's way, not the other direction. PEP 751 is standardising the lockfile format that tools like uv pioneered, which means the format you adopt now is the one the ecosystem is converging on rather than another dialect you'll have to leave behind.

The honest position is this: for anything new, start with uv and don't look back. For anything existing and painful, migrate it the next time the pain flares — a slow CI run, a dependency conflict poetry can't resolve, a contributor who can't get set up. And for the genuinely settled project that nobody is suffering over, leave it, and migrate it the day someone touches it next. The disaster is over. It just took fifteen years and a Rust binary to admit the problem was the foundation all along.