M
Michael
Guest
Poetry and uv are both Python package managers built to solve the same problem, which is making sure every machine installs exactly the same dependencies. Here’s how the two compare on speed, lockfiles, and day-to-day workflow, and why I now point teams to uv.
On a Monday morning, Ada clones a Python project, runs the install command, and the tests pass. On Thursday, Sam clones the exact same commit, runs the exact same command, and three tests fail.
Nobody changed a line of code between Monday and Thursday. What changed was the outside world, as somewhere in the project’s dependency tree, unknowingly, a library the project uses had shipped a new minor version on Wednesday night. Ada got the old one while Sam got the new one. The project said “any version above 2.0 is fine”, and both of them were technically right.
Every Python developer, to be honest, every developer has lived some version of this. It’s the reason “works on my machine” is a joke that never stops being funny, and it’s the problem Python package managers like Poetry and uv were built to kill.
Recently, I approved a pull request on an open-source Python project I help co-maintain. This PR moved the whole project, all 111 locked dependency versions of it, from Poetry to uv, spanning the local development, CI, release publishing, and the Docker image. The thing I praised in my review wasn’t the speed, although the speed is real; it was a single flag,
But first, the problem both tools are solving.
Think of your project’s dependencies as a recipe.
A recipe says “two cups of flour, one stick of butter and a pinch of salt”; it doesn’t say which brand of flour, which batch, or which store to get it from. That’s fine for a recipe; it’s meant to be flexible, and in Python that flexible recipe lives in
A receipt is different; it says exactly what you bought, it tells you the brand, the size, the batch number and the timestamp of when you paid. If you hand the receipt to someone else, they can buy exactly the same things.
That receipt is a
Poetry and uv both give you a receipt; they just take very different amounts of time to write it, and they disagree on one important question: what should happen when the recipe and the receipt stop matching?
Poetry arrived in 2018, at a time when Python packaging was a pile of separate tools glued together with habit. You used
Poetry put all of that behind one command line and one file; now you declared dependencies in
For a lot of teams, including the project I mentioned earlier, Poetry was the first time Python dependency management felt calm. It deserves real credit for that; it made lockfiles normal in Python and treated publishing a library as a first-class job instead of an afterthought.
It also has costs you start to feel as a project grows.
It’s slow. Poetry is written in Python, and its dependency resolver works carefully through version constraints. On a small project you would barely notice this, but on a project with a deep tree, resolving and installing can take long enough that you go and make a cup of coffee.
It’s one tool among several. Poetry manages your project’s dependencies, but it doesn’t install Python itself. You still need something like
Its config was its own dialect for years. Until version 2.0, which was released in early 2025, Poetry kept project metadata under its own
None of these are dealbreakers; they’re the kind of friction you stop noticing until something removes it.
uv first appeared in February 2024. It's a Python package manager written in Rust, and it started life as a much faster
Today, one uv binary covers what used to take five or six tools:
If you've read older comparisons that say uv can't publish packages, that was true in its early months. It isn't anymore.
The commands look almost the same as Poetry's, but most importantly, the experience doesn't.
Rather than quote someone else's numbers, I ran my own. Same machine, same Python 3.12, the same nine dependencies (
Both tools installed the same set of packages. The cold run is about five times faster, while the warm reinstall, which is what happens every time someone recreates a virtual environment or CI restores a cache, is roughly 25 times faster. It finishes so quickly the first time that you'll probably check whether it actually did anything.
Your numbers will differ with your network, disk, and dependency tree, so run the comparison on your own project, but the gap is consistently large, not marginal.
Two things do most of the work.
The first is the obvious one: the resolver and installer are compiled code, and they download and unpack packages in parallel.
The second matters more day to day. uv keeps one global cache of every package it has ever downloaded, and when it builds a virtual environment, it links files out of that cache instead of copying them. A new environment for a package you've used before is mostly a matter of creating links, which is close to free. Ten projects that all use
Here's the part most comparisons skip. The headline is "uv is fast," but the bigger change is that you stop thinking about the machinery.
With uv, you never have to create a virtual environment, name it, activate it, or remember to deactivate it.
It also handles single files. You can put a script's dependencies at the top of the script itself:
Run it with
Since so much of uv's speed comes from the cache, it helps to know how to control it.
The difference between the last two is worth getting right.
Now back to Ada and Sam, and to that pull request.
uv is built to be convenient, and part of that convenience is that it keeps the receipt up to date for you. If you edit
On your own laptop, that's exactly what you want.
In CI, or in a Docker build, or in a release pipeline, it's a trap. Imagine someone changes a version range in
It installs strictly from the existing
Exit code 1. The build stops, and the person who forgot to commit the lockfile finds out in minutes instead of weeks.
There are three modes worth keeping straight:
That's what I liked about the migration. Every place that installs dependencies in an automated setting- the Makefile targets, the CI workflows, and the Docker build- used
To be fair to Poetry, it also refuses to install when
I'm going to point you towards uv, but there are honest reasons to wait.
If your project is stable, your team knows Poetry well, installs are fast enough, and nothing hurts, there's no prize for migrating this week. A working toolchain is worth a lot.
If you lean on Poetry plugins, for things like dynamic versioning from git tags or exporting in a particular format, check that each one has a uv equivalent before you start. Some do, some need a small script, and some don't yet.
And if your release process is deeply tied to
None of these are reasons to stay forever. They're reasons to plan the switch instead of doing it on a Friday afternoon.
For most projects, migration is a short afternoon, not a project. The same 111 dependencies came across in the PR I reviewed, with every locked version preserved.
There's a community tool that does most of the conversion:
It rewrites
After that, the work is mostly find-and-replace across the places that call Poetry:
Then check the places people forget:
Finally, run your full test suite on a clean machine, not just your laptop with its warm caches. Build the wheel, install it fresh, and make sure your command-line entry points still work. The PR I reviewed did exactly that, and the Docker image got its own manual check, because the existing compatibility tests only covered older images and wouldn't have caught a broken build.
Go back to Ada and Sam one last time.
Their problem was never really about which tool they used. It was that their project had a recipe and no reliable receipt, and nothing stopped the two from drifting apart. Poetry solved that years ago, and it deserves the credit. uv solves the same problem, then removes most of the waiting and most of the separate tools around it: Python installs, virtual environments, global CLI tools, and one-off scripts.
If I had to sum it up for a team choosing today, it would be this. Start new projects with uv, move existing ones when you next have a quiet week, and wherever a machine installs your dependencies without a human watching, add
On a Monday morning, Ada clones a Python project, runs the install command, and the tests pass. On Thursday, Sam clones the exact same commit, runs the exact same command, and three tests fail.
Nobody changed a line of code between Monday and Thursday. What changed was the outside world, as somewhere in the project’s dependency tree, unknowingly, a library the project uses had shipped a new minor version on Wednesday night. Ada got the old one while Sam got the new one. The project said “any version above 2.0 is fine”, and both of them were technically right.
Every Python developer, to be honest, every developer has lived some version of this. It’s the reason “works on my machine” is a joke that never stops being funny, and it’s the problem Python package managers like Poetry and uv were built to kill.
Recently, I approved a pull request on an open-source Python project I help co-maintain. This PR moved the whole project, all 111 locked dependency versions of it, from Poetry to uv, spanning the local development, CI, release publishing, and the Docker image. The thing I praised in my review wasn’t the speed, although the speed is real; it was a single flag,
--locked which was used everywhere. By the end of this piece, you’ll know why that flag matters more than any benchmark.But first, the problem both tools are solving.
A Recipe and a Receipt
Think of your project’s dependencies as a recipe.
A recipe says “two cups of flour, one stick of butter and a pinch of salt”; it doesn’t say which brand of flour, which batch, or which store to get it from. That’s fine for a recipe; it’s meant to be flexible, and in Python that flexible recipe lives in
pyproject.toml:
Code:
[project]
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
]
httpx>=0.27 means “any version from 0.27 upward”. Today that resolves to one version; next month it might resolve to another and httpx itself depends on other packages which depend on others, each with its own flexible range.A receipt is different; it says exactly what you bought, it tells you the brand, the size, the batch number and the timestamp of when you paid. If you hand the receipt to someone else, they can buy exactly the same things.
That receipt is a
lockfile, it records the precise version of every package in the tree, direct and indirect, plus a hash of each file so nobody can quietly swap one out. Ada and Sam’s problem was that their project had a recipe and no receipt; each of them shopped on a different day and came home with a different brand and batch of flour bought from different stores.Poetry and uv both give you a receipt; they just take very different amounts of time to write it, and they disagree on one important question: what should happen when the recipe and the receipt stop matching?
Poetry: The One That Fixed It First.
Poetry arrived in 2018, at a time when Python packaging was a pile of separate tools glued together with habit. You used
pip to install things, virtualenv to isolate them, pip-tools to pin them, setuptools and setup.py file to package them, and twine to upload them. Every project did it slightly differently.Poetry put all of that behind one command line and one file; now you declared dependencies in
pyproject.toml, it resolved them, wrote a poetry.lock receipt, created a virtual environment for you, and could build and publish your package when you were ready.
Code:
poetry new myapp # scaffold a package
poetry add httpx # add a dependency and update the lockfile
poetry install # install everything from poetry.lock
poetry run pytest # run a command inside the environment
poetry build # build a wheel and source distribution
poetry publish # upload to PyPI
For a lot of teams, including the project I mentioned earlier, Poetry was the first time Python dependency management felt calm. It deserves real credit for that; it made lockfiles normal in Python and treated publishing a library as a first-class job instead of an afterthought.
It also has costs you start to feel as a project grows.
It’s slow. Poetry is written in Python, and its dependency resolver works carefully through version constraints. On a small project you would barely notice this, but on a project with a deep tree, resolving and installing can take long enough that you go and make a cup of coffee.
It’s one tool among several. Poetry manages your project’s dependencies, but it doesn’t install Python itself. You still need something like
pyenv to get the right interpreter, and something like pipx to install command-line tools globally. The patchwork got smaller, not gone.Its config was its own dialect for years. Until version 2.0, which was released in early 2025, Poetry kept project metadata under its own
[tool.poetry] section instead of the standard [project] table every other tool reads. Version 2.0 adopted the standard, which helps, but many older projects still carry the old format.None of these are dealbreakers; they’re the kind of friction you stop noticing until something removes it.
uv: Same Job, Different Engine
uv first appeared in February 2024. It's a Python package manager written in Rust, and it started life as a much faster
pip. It didn't stay that small for long.Today, one uv binary covers what used to take five or six tools:
Code:
uv python install 3.12 # install a Python interpreter (replaces pyenv)
uv init myapp # start a project
uv add httpx # add a dependency and update uv.lock
uv add --dev pytest # add a development-only dependency
uv sync # make the environment match the lockfile
uv run pytest # run inside the environment
uvx ruff check . # run a CLI tool without installing it (replaces pipx)
uv build # build a wheel and source distribution
uv publish # upload to PyPI
If you've read older comparisons that say uv can't publish packages, that was true in its early months. It isn't anymore.
uv build and uv publish both exist, and uv now ships its own build backend, uv_build. The project I reviewed switched to it as part of the migration.The commands look almost the same as Poetry's, but most importantly, the experience doesn't.
uv vs Poetry: The Speed Is Not an Exaggeration
Rather than quote someone else's numbers, I ran my own. Same machine, same Python 3.12, the same nine dependencies (
fastapi, sqlalchemy, pydantic, httpx, cryptography, marshmallow, loguru, click, websockets), with every cache wiped before each round. Three rounds each, median shown:
Code:
Poetry uv
Fresh add, empty cache 22.2s 4.0s
Reinstall into a new venv 2.8s 0.11s
Both tools installed the same set of packages. The cold run is about five times faster, while the warm reinstall, which is what happens every time someone recreates a virtual environment or CI restores a cache, is roughly 25 times faster. It finishes so quickly the first time that you'll probably check whether it actually did anything.
Your numbers will differ with your network, disk, and dependency tree, so run the comparison on your own project, but the gap is consistently large, not marginal.
Where the speed comes from.
Two things do most of the work.
The first is the obvious one: the resolver and installer are compiled code, and they download and unpack packages in parallel.
The second matters more day to day. uv keeps one global cache of every package it has ever downloaded, and when it builds a virtual environment, it links files out of that cache instead of copying them. A new environment for a package you've used before is mostly a matter of creating links, which is close to free. Ten projects that all use
pydantic share one copy on disk instead of ten.Speed isn't really the point.
Here's the part most comparisons skip. The headline is "uv is fast," but the bigger change is that you stop thinking about the machinery.
With uv, you never have to create a virtual environment, name it, activate it, or remember to deactivate it.
uv run notices the project, checks that the environment matches the lockfile, fixes it if needed, and runs your command. You can clone a uv project and type uv run pytest as your very first command, and it will install Python if needed, build the environment, and run the tests.It also handles single files. You can put a script's dependencies at the top of the script itself:
Code:
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx"]
# ///
import httpx
print(httpx.get("https://example.com").status_code)
Run it with
uv run fetch.py, and uv builds a throwaway environment with httpx in it. No project, no requirements.txt, no "which venv was this for again?" uv add --script fetch.py httpx writes that header block for you. If you share small tools with teammates, this alone is worth the switch.The Cache: Three Commands Worth Knowing
Since so much of uv's speed comes from the cache, it helps to know how to control it.
Code:
uv cache clean # wipe the whole cache
uv cache clean httpx # remove just one package from the cache
uv sync --refresh # re-check package indexes online instead of trusting cached metadata
uv run --no-cache ... # ignore the cache completely for this one run
The difference between the last two is worth getting right.
--refresh still uses the cache, but it asks the package index again whether anything new has been published, which is what you want when you know a fix was just released. --no-cache doesn't read from or write to your cache at all. It uses a temporary directory for that one command, which is useful when you suspect a corrupted download and want to prove it.uv cache size and uv cache dir tell you how much space it's using and where. On a busy laptop, the answer is often "more than you'd guess," and uv cache prune clears out entries nothing uses anymore.uv sync --locked: The Flag I Praised
Now back to Ada and Sam, and to that pull request.
uv is built to be convenient, and part of that convenience is that it keeps the receipt up to date for you. If you edit
pyproject.toml by hand and then run uv sync or uv run, uv notices the recipe changed, quietly re-resolves, rewrites uv.lock, and carries on.On your own laptop, that's exactly what you want.
In CI, or in a Docker build, or in a release pipeline, it's a trap. Imagine someone changes a version range in
pyproject.toml and forgets to commit the updated lockfile. A convenient tool fixes it silently in the CI machine's working copy, the build goes green, and the receipt in your repository is now wrong. Nobody notices until someone else's machine resolves something different. That's Ada and Sam all over again, except now you have a lockfile, so everyone believes the problem is impossible.--locked turns that silent fix into a loud failure:
Code:
uv sync --locked
It installs strictly from the existing
uv.lock, and if the lockfile is out of date with pyproject.toml, it refuses and exits with an error. I tested this by changing a project's dependencies without relocking:
Code:
$ uv sync --locked
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
hint: To update the lockfile, run `uv lock`.
Exit code 1. The build stops, and the person who forgot to commit the lockfile finds out in minutes instead of weeks.
There are three modes worth keeping straight:
Code:
uv sync update the lockfile if needed, then install (local development)
uv sync --locked fail if the lockfile is out of date, then install (CI, Docker, releases)
uv sync --frozen install from the lockfile without checking it (when you trust it completely)
uv lock --check does the check on its own without installing anything, which makes a cheap first step in a CI job or a pre-commit hook.That's what I liked about the migration. Every place that installs dependencies in an automated setting- the Makefile targets, the CI workflows, and the Docker build- used
--locked. Outdated lockfiles fail out loud instead of being patched up quietly. The tool's convenience stays on developers' laptops, where it belongs, and strictness takes over everywhere else.To be fair to Poetry, it also refuses to install when
poetry.lock is badly out of sync with pyproject.toml, and poetry check --lock does the same job as uv lock --check. The difference is the default. uv leans towards fixing things for you, so you have to choose strictness explicitly. That's not a flaw, as long as you know it's a choice you need to make.When Poetry Still Makes Sense
I'm going to point you towards uv, but there are honest reasons to wait.
If your project is stable, your team knows Poetry well, installs are fast enough, and nothing hurts, there's no prize for migrating this week. A working toolchain is worth a lot.
If you lean on Poetry plugins, for things like dynamic versioning from git tags or exporting in a particular format, check that each one has a uv equivalent before you start. Some do, some need a small script, and some don't yet.
And if your release process is deeply tied to
poetry version and Poetry's publishing flow, budget time to rebuild it rather than assuming it will just carry over.None of these are reasons to stay forever. They're reasons to plan the switch instead of doing it on a Friday afternoon.
How to Migrate From Poetry to uv
For most projects, migration is a short afternoon, not a project. The same 111 dependencies came across in the PR I reviewed, with every locked version preserved.
There's a community tool that does most of the conversion:
Code:
uvx migrate-to-uv
It rewrites
[tool.poetry] sections into the standard [project] table, moves development dependencies into [dependency-groups], and generates uv.lock using your existing poetry.lock as a constraint, so you get the same versions you already had instead of whatever's newest today. I ran it on a test project, and it finished in about a second.After that, the work is mostly find-and-replace across the places that call Poetry:
Code:
poetry install -> uv sync --locked
poetry run <cmd> -> uv run <cmd>
poetry add <pkg> -> uv add <pkg>
poetry add -G dev <pkg> -> uv add --dev <pkg>
poetry lock -> uv lock
poetry build -> uv build
poetry publish -> uv publish
Then check the places people forget:
- CI workflows. Swap the Poetry setup step for uv's, point dependency caching at
uv.lock, and use--lockedon every install. - Dockerfiles. Copy the uv binary in, run
uv sync --locked --no-devfor production images, and make sure the container starts through the environment's Python rather than apoetry runwrapper that no longer exists. - Makefiles and scripts. Anything that shells out to
poetry. - A pip-compatible
requirements.txt, if anyone still needs one.uv export --format requirements-txtgenerates it from the lockfile, so it never drifts. - Docs. The README's "getting started" section is the first thing a new contributor runs.
Finally, run your full test suite on a clean machine, not just your laptop with its warm caches. Build the wheel, install it fresh, and make sure your command-line entry points still work. The PR I reviewed did exactly that, and the Docker image got its own manual check, because the existing compatibility tests only covered older images and wouldn't have caught a broken build.
Back to Monday and Thursday
Go back to Ada and Sam one last time.
Their problem was never really about which tool they used. It was that their project had a recipe and no reliable receipt, and nothing stopped the two from drifting apart. Poetry solved that years ago, and it deserves the credit. uv solves the same problem, then removes most of the waiting and most of the separate tools around it: Python installs, virtual environments, global CLI tools, and one-off scripts.
If I had to sum it up for a team choosing today, it would be this. Start new projects with uv, move existing ones when you next have a quiet week, and wherever a machine installs your dependencies without a human watching, add
--locked, so the day your recipe and receipt disagree, you hear about it immediately instead of three tests and three days later.