Python Packaging Is Finally Solved: Migrating From Poetry to UV in Production
The Python packaging ecosystem has been a mess for over a decade. UV might finally be the tool that fixes it - and I have the benchmarks to prove it.
If you've been in the Python ecosystem long enough, you've developed a certain cynicism about packaging tools. We've seen the parade: easy_install, pip, virtualenv, pipenv, poetry, conda, pyenv - each promising to finally solve dependency management, each introducing its own quirks and friction. For years, Python's packaging story was embarrassingly similar to JavaScript's node_modules meme. The language that prides itself on "there should be one obvious way to do it" had approximately seventeen ways to manage dependencies, none of them obviously correct.
So when UV arrived on the scene, backed by Astral (the team behind Ruff), I was cautiously skeptical. Another tool? Really? But the Rust-based architecture and the bold claims of 10-100x performance improvements were too intriguing to ignore.
I decided to put UV through a rigorous real-world test: migrating three production Python services from Poetry and measuring everything. The results weren't just good - they fundamentally changed how I think about Python development workflows.
Spoiler alert: UV delivered an average 80% performance improvement on lock operations in my benchmarks. And yes, we've now migrated most of our services to UV.
A Brief History of My Packaging Journey
Let me take you back. I remember when I first switched from raw requirements.txt files to Poetry. It felt revolutionary. Suddenly I had:
- Deterministic builds
- Proper dependency resolution
- A lock file that actually worked
- Virtual environment management built-in
Going from pip install -r requirements.txt and hoping for the best to poetry install was a genuine productivity leap. I thought: "This is it. We've solved Python packaging."
Little did I know, I was wrong. We had merely made it tolerable.
Switching to UV feels like the leap from requirements.txt to Poetry but even bigger. It's not just incrementally better; it's categorically different.
The Experiment Setup
I chose three production microservices for this migration. Service names are anonymized, but the numbers are 100% real:
- Service Alpha: ~45 dependencies, moderate complexity
- Service Beta: ~60 dependencies, heavier test suite
- Service Gamma: ~80 dependencies, complex dependency tree with multiple sub-dependencies
Each service was running Poetry for dependency management and using Docker for containerization. I ran all benchmarks on a MacBook M1 Pro using Hyperfine, executing each command 10 times with warm-up phases. I also captured standard deviation to ensure statistical validity.
The methodology matters here: I wanted to compare apples to apples. Same hardware, same services, controlled conditions. Will this hold up in a peer-reviewed journal? Probably not. Fortunately it's not a dissertation defense, it's an engineering decision. And for engineering decisions, this is exactly the data I'd want.
The Numbers Don't Lie
I run these operations:
| Operation | Poetry Command | UV Command |
|---|---|---|
| Lock dependencies | poetry lock |
uv lock |
| Lock (no cache) | poetry lock --no-cache |
uv lock --no-cache |
| Sync/Install | poetry install |
uv sync |
| Sync (no cache) | poetry install --no-cache |
uv sync --no-cache |
| Docker build | Dockerfile with Poetry | Dockerfile with UV |
Service Alpha (~45 deps)
| Operation | Poetry (mean ± σ) | UV (mean ± σ) | Improvement |
|---|---|---|---|
docker build . |
2.071s ± 0.287s | 1.674s ± 0.053s | 19.2% |
docker build . --no-cache |
63.866s ± 4.932s | 51.842s ± 3.496s | 18.8% |
lock |
6.194s ± 0.176s | 806.2ms ± 147.5ms | 87% |
lock --no-cache |
23.078s ± 0.728s | 7.556s ± 1.270s | 67% |
That lock operation improvement isn't a typo. 6.2 seconds down to 806 milliseconds, nearly an order of magnitude faster.
Service Beta (~60 deps)
| Operation | Poetry (mean ± σ) | UV (mean ± σ) | Improvement |
|---|---|---|---|
docker build . |
1.586s ± 0.063s | 1.775s ± 0.137s | -12% ⚠️ |
docker build . --no-cache |
70.281s ± 3.287s | 83.882s ± 2.205s | -19.4% ⚠️ |
lock |
6.851s ± 0.447s | 808.5ms ± 213.2ms | 88.2% |
sync --no-cache |
32.139s ± 1.478s | 7.103s ± 1.758s | 78% |
Service Beta is the outlier, and I'm including it precisely because it complicates the narrative. Docker builds were slower with UV (12% cached, 19% uncached) probably due to how UV is installed and cached in this particular Dockerfile. However, the commands developers actually run repeatedly during development: lock and sync, still showed 78-88% improvements. In practice, you rebuild Docker images occasionally; you run lock/sync constantly. The net developer experience is still dramatically better.
Service Gamma (~80 deps)
| Operation | Poetry (mean ± σ) | UV (mean ± σ) | Improvement |
|---|---|---|---|
docker build . |
2.240s | 1.004s | 55.1% |
docker build . --no-cache |
83.035s | 38.507s | 53.7% |
lock |
7.479s | 1.321s | 82.3% |
sync --no-cache |
34.514s | 7.826s | 77.3% |
Service Gamma showed the most consistent improvements across the board, with even Docker operations seeing 50%+ gains.
The pyenv Problem (And How UV Solves It)
Let's talk about something that has caused me countless hours of frustration: Python version management.
If you've used pyenv, you know the pain:
- Mysterious compilation failures on macOS after system updates
pyenv: python3.11 not installedafter you're certain you installed it- The shim system occasionally just... not working
- Needing to install build dependencies for each Python version
- Different behaviors across macOS, Linux, and various CI environments
I've lost entire afternoons to pyenv issues. "It works on my machine" became "pyenv works on my machine, sometimes."
UV completely eliminates this problem.
# That's it. That's the whole thing.
uv venv --python 3.12
If Python 3.12 isn't installed? UV downloads and installs it automatically. No pyenv. No system Python conflicts. No compilation. No shims. It just works.
Want to switch versions?
uv venv --python 3.11
Done. UV manages Python binaries as first-class citizens. The version is pinned in .python-version, committed to your repo, and every developer gets the exact same Python without any setup beyond having UV installed.
This alone would be worth the switch.
Why Is UV So Fast?
The performance differential isn't marketing fluff. It's architectural. UV is built in Rust, which gives it several fundamental advantages:
1. No Python Runtime Overhead
Traditional Python package managers like Poetry are... written in Python. This creates a bootstrap problem: you need Python to install Python packages. UV sidesteps this entirely, it's a self-contained binary with zero runtime dependencies.
Compare the setup flows:
Poetry flow:
pyenv local 3.12
python3 -m venv .venv
pip3 install poetry
poetry shell
poetry lock
UV flow:
curl -LsSf https://astral.sh/uv/install.sh | sh # install uv
uv venv --python 3.12
uv sync
Three commands versus five. No pip bootstrap. No separate Python version manager.
2. Parallel Resolution
UV's dependency resolver operates concurrently, leveraging Rust's fearless concurrency model. Poetry's resolver, constrained by Python's GIL, cannot achieve the same level of parallelism.
3. Content-Addressable Caching
UV uses a global cache that deduplicates packages across all your projects. Poetry maintains per-project caches, leading to redundant storage and slower cache hits.
The CI/CD Reality Check
Here's where I need to be honest: the improvements in GitHub Actions weren't as dramatic as local benchmarks.
| Service | With UV | Without UV |
|---|---|---|
| Service Alpha | 1m 55.75s | 3m 18.38s |
| Service Beta | 1m 13s | 2m 12s |
Still faster, but the 40-45% improvement is more modest than the 80%+ we saw locally. Why? CI runners have fewer cores and less aggressive caching. The parallelism advantages of UV are partially negated by constrained compute.
Important caveat: These CI tests were conducted on a single branch with re-runs rather than across multiple PRs. Cache warming effects may have influenced results. Your mileage may vary.
The Migration Path
Replacing Poetry with UV is straightforward but requires attention to detail. Here's the practical guide based on my experience across three repositories:
Step 1: Convert pyproject.toml
UV provides a conversion utility via PDM:
uvx pdm import pyproject.toml
Then manually update the configuration:
# Change this:
[[tool.poetry.source]]
# To this:
[[tool.uv.index]]
And convert dev dependencies:
# From:
[tool.pdm.dev-dependencies]
dev = [
# To:
[tool.uv]
dev-dependencies = [
Step 2: Update Dockerfile
Replace Poetry installation with UV:
# UV installation - single binary, no dependencies
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Install dependencies with caching
RUN --mount=type=cache,target="/root/.cache" \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
uv sync --frozen
COPY . .
CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Step 3: Update Runtime Commands
All Python commands in your Makefile should be prefixed with uv run.
Step 4: Version Pin Python
Add .python-version to your repository root. You may need to remove it from .gitignore if it was previously excluded.
Et voilà, you're done. Seriously, that's it. Now the real question: what will you do with all this reclaimed time? One extra coffee break? Five? Maybe it's finally time to hit the gym. Your uv lock finishes before you can even alt-tab to Slack. This is the future we were promised.
⚠️ Watch Out: Dependency Version Drift
Here's something that caught me off guard during migration:
UV might pull in newer versions of packages if your version pins are loose.
I discovered this the hard way when httpx had a breaking change in how AsyncClient is invoked. The app parameter handling changed between versions. My tests caught it, but it was a reminder that migrations aren't just about tooling.
Before you migrate:
- Ensure your test suite has good coverage
- Audit your dependency pins (consider tightening loose ones)
- Run your full test suite immediately after
uv sync - Pay special attention to packages with frequent releases
The Team Migration
After running these benchmarks, I presented the findings to my team. The conversation was short.
The data spoke for itself: 80%+ improvement on the operations developers run dozens of times per day.
As of writing, we've migrated most of our Python services to UV. The rollout has been remarkably smooth - no production incidents, no major blockers. The one-time migration effort paid for itself within the first week of accumulated time savings.
More importantly: developers love it ❤️
The feedback has been consistently positive. Faster local development loops. No more pyenv headaches. Simpler onboarding for new team members. When's the last time your team was excited about a build tool?
Python Packaging: A Solved Problem?
I never thought I'd write this sentence, but here it is:
Python packaging finally feels like a solved problem.
For over a decade, we've been the butt of jokes. "Just use a virtualenv" we'd say, glossing over the seventeen steps required to actually set one up correctly. We'd watch Rust developers with their cargo and Go developers with their modules and feel a twinge of envy.
UV changes the narrative. It's fast. It's unified. It handles Python versions, virtual environments, dependency resolution, and package installation - all in one tool that just works.
The Python community owes Astral a debt of gratitude. First Ruff fixed linting. Now UV is fixing packaging. They're methodically eliminating the friction points that have held Python back, and they're doing it with tools that are genuinely delightful to use.
The Verdict
After migrating three production services, running comprehensive benchmarks, rolling out UV across our team, and living with it in daily development for a year now, my conclusion is unambiguous:
UV is a generational improvement in Python tooling.
The 80%+ improvements on lock operations directly translate to developer productivity. When you run uv lock and it completes in under a second instead of 6-7 seconds, you stay in flow state. When Docker builds complete 50% faster, your feedback loops tighten. When you never have to debug pyenv again, you reclaim hours of your life.
But the real game-changer isn't any single metric - it's the unification. UV replaces pyenv + virtualenv + pip + poetry with a single, fast, well-designed tool. That simplification compounds across every developer on your team, every CI run, every onboarding session.
If you're starting a new Python project today, use UV. If you're maintaining existing projects on Poetry, start planning your migration. The future of Python dependency management is written in Rust, and it's already here.
Resources
Need Help With Your Python Environment?
Thinking about migrating to UV but not sure where to start? Struggling with Python packaging, dependency management, or development workflow optimization?
I'd be happy to help. DM me and let's chat about improving your Python setup.
Have you migrated to UV? What was your experience? I'd love to hear about your benchmarks in the comments or on social media.
Member discussion