Your CLI has been managed with Poetry for years and it works. The reasons to move to uv are usually practical: much faster installs in CI, one tool that also manages Python versions and globally installed tools, standard [project] metadata that every other tool understands, and a team where most other projects already use it. The risk is that a migration quietly changes something users depend on — a dependency resolving to a different version, a console script that disappears, a constraint that becomes looser or tighter than intended. This guide walks through a migration that can be reviewed like any other change: converting metadata to the standard format, translating Poetry-specific constraints deliberately, regenerating the lockfile, comparing what actually resolves, and switching CI. It belongs to the Poetry workflows for CLI development topic.
Prerequisites
- A CLI project using Poetry, with
pyproject.tomland a committedpoetry.lock. - uv installed. The uv for Python CLI dependency management overview covers what changes day to day, and uv init vs Poetry init for CLI tools compares the two starting points.
- A branch, so the old and new configurations can be compared side by side.
What changes and what does not
Most of a Poetry project maps directly onto standards that uv reads. Recent Poetry releases (2.x) already support the standard [project] table, so some projects are halfway there; older projects keep everything under [tool.poetry].
Your package code, tests and entry point functions do not change at all. What changes is metadata syntax, the lockfile format, the commands developers and CI run, and — optionally — the build backend.
The recipe
Step 1: convert the metadata
The migrate-to-uv tool automates the mechanical part: it rewrites [tool.poetry] sections into [project], [project.scripts] and [dependency-groups], converts constraints, and runs uv lock.
git switch -c migrate-to-uv
uvx migrate-to-uv --dry-run # print the proposed pyproject.toml, change nothing
uvx migrate-to-uv # rewrite pyproject.toml and create uv.lock
Always read the dry-run output. A before-and-after for a typical CLI looks like this:
# Before: Poetry 1.x style
[tool.poetry]
name = "mytool"
version = "2.3.1"
description = "Deploy helper for the platform team"
packages = [{ include = "mytool", from = "src" }]
[tool.poetry.dependencies]
python = "^3.10"
typer = "^0.12"
httpx = "~0.27"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
[tool.poetry.scripts]
mytool = "mytool.cli:app"
# After: standard metadata that uv (and every other tool) reads
[project]
name = "mytool"
version = "2.3.1"
description = "Deploy helper for the platform team"
requires-python = ">=3.10,<4"
dependencies = [
"typer>=0.12,<0.13",
"httpx>=0.27,<0.28",
]
[project.scripts]
mytool = "mytool.cli:app"
[dependency-groups]
dev = ["pytest>=8.0,<9"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Step 2: decide what the constraints should be
Poetry's caret (^) is a compact way to write an upper bound, and a faithful translation preserves it: ^0.12 becomes >=0.12,<0.13, because for 0.x versions the caret pins the minor version. Keep the faithful translation for the migration itself — changing resolution and syntax in one step makes problems hard to attribute — but review the result afterwards. Many libraries' guidance for CLIs and libraries alike is to avoid upper bounds unless a known incompatibility exists, and requires-python = ">=3.10,<4" in particular is an upper bound worth removing in a follow-up. The trade-offs are covered in semantic versioning policy for CLI tools.
Step 3: choose the build backend
Poetry projects build with poetry-core. You can keep it — uv builds any PEP 517 backend — but poetry-core reads package locations from [tool.poetry] settings you may have just removed. Switching to hatchling or uv_build is usually simpler for a src/ layout CLI. If you switch, check that data files are still included; see bundling data files with importlib.resources.
Step 4: compare what resolves
This is the step that makes the migration safe. Export both lockfiles to a flat list of pinned versions and diff them:
git show main:poetry.lock > /tmp/poetry.lock
# Poetry side: list what the old lock pinned (runtime + dev)
python - <<'EOF' > /tmp/before.txt
import tomllib
lock = tomllib.load(open("/tmp/poetry.lock", "rb"))
for p in sorted(lock["package"], key=lambda p: p["name"].lower()):
print(f'{p["name"].lower()}=={p["version"]}')
EOF
# uv side
uv export --all-groups --no-hashes --no-emit-project --format requirements-txt \
| grep "==" | sed 's/ ;.*//' | sort -f > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt
An empty diff is the ideal. Small differences are normal — a newer patch release of a transitive dependency, a platform marker resolved differently — but each one should be understood. If something major moved, pin it temporarily with uv lock --upgrade-package name==old or a constraint, migrate, and upgrade it in a separate change.
Step 5: switch the commands
| Poetry | uv |
|---|---|
poetry install | uv sync |
poetry install --only main | uv sync --no-dev |
poetry add httpx | uv add httpx |
poetry add --group dev pytest | uv add --dev pytest |
poetry run mytool | uv run mytool |
poetry lock | uv lock |
poetry build | uv build |
poetry publish | uv publish |
Update CI to install with uv sync --locked, which fails if the lockfile is stale — see caching uv dependencies in CI — and update the README, CONTRIBUTING.md, pre-commit hooks and any Dockerfiles. Delete poetry.lock once the comparison is done and the branch is ready to merge.
Features without a one-to-one equivalent
A few Poetry features need a decision rather than a translation:
- Extras defined through optional dependencies. Poetry marks dependencies
optional = trueand lists them under[tool.poetry.extras]. The standard form is[project.optional-dependencies], keyed by extra name — the samepip install mytool[s3]syntax works for users afterwards. - Path and git dependencies. Poetry's
{ path = "../lib", develop = true }becomes an entry in[tool.uv.sources]withpath = "../lib", editable = true, while the dependency itself is listed normally in[project]. If several local packages depend on each other, a workspace is usually the better model; see uv workspaces for multi-package CLIs. - Scripts that are not console scripts. Some projects abuse
[tool.poetry.scripts]to run maintenance tasks. Those belong in a task runner (ajustfile,noxsessions, or a smallscripts/directory run withuv run), not in the package's public entry points. poetry version patch. uv providesuv version --bump patchfor static versions; projects that derive versions from git tags do not need either.
UX considerations
The people affected by the migration are your contributors and your users:
- Users should notice nothing. The package name, version, console script and runtime dependencies must be identical after the migration. Verify by building and installing the wheel:
uv build && uv tool install dist/*.whl && mytool --version. - Contributors need one clear message. A short note in the pull request and the changelog — "we now use uv; run
uv syncinstead ofpoetry install" — plus updated docs prevents a week of confused questions. - Keep the migration separate from upgrades. One pull request converts; later pull requests change versions or loosen constraints. Reviewers can then reason about each.
Testing the behaviour
Beyond the dependency diff, three checks confirm nothing user-facing changed:
# 1. The test suite passes in the new environment.
uv sync --locked && uv run pytest -q
# 2. The built wheel has the same metadata and entry points as before.
uv build
unzip -p dist/mytool-*.whl '*.dist-info/entry_points.txt'
unzip -p dist/mytool-*.whl '*.dist-info/METADATA' | grep -E '^(Name|Version|Requires-Python|Requires-Dist):'
# 3. The installed command works from outside the repository.
uv tool install --force dist/mytool-*.whl
(cd /tmp && mytool --version && mytool --help >/dev/null)
Compare the METADATA output with the same command run against a wheel built from main with Poetry; the dependency list may be written differently but should express the same constraints. The third check is the wheel smoke test in miniature.
Conclusion
Migrating a CLI from Poetry to uv is mostly mechanical, and the mechanical part is automated by migrate-to-uv. What makes it safe is treating it as a reviewed change: read the converted metadata, translate constraints faithfully first and relax them later, choose a build backend deliberately, diff the resolved versions against the old lockfile, and prove with a built wheel that users will see no difference. Then switch CI and the docs in the same pull request, and the team's next install is simply faster.
Frequently asked questions
Can I keep using Poetry for some things?
Yes. With standard [project] metadata, Poetry 2.x and uv can both read the same pyproject.toml, and some teams keep Poetry for publishing during a transition. Maintaining two lockfiles is the part to avoid; pick one tool to own resolution.
What about Poetry plugins such as poetry-dynamic-versioning?
Replace them with build-backend features. Dynamic versions from git tags move to hatch-vcs, as in deriving versions from git tags with hatch-vcs; export plugins are replaced by uv export.
Does uv support private package indexes like Poetry sources?
Yes, via [[tool.uv.index]] entries with names, URLs and optional explicit = true to limit a package to that index. Credentials come from environment variables or keyring, never from pyproject.toml.
How long does a migration take?
For a typical CLI, the conversion and comparison take under an hour; most of the time goes into updating CI configuration and documentation. Projects with many Poetry plugins or unusual packaging settings take longer.