Project Setup

Migrating a Python CLI from Poetry to uv

Move a Poetry-managed Python CLI to uv safely: convert to PEP 621 metadata, translate caret constraints, keep entry points, compare locked versions and update CI.

Updated

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

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].

Poetry fields and their standard equivalents How Poetry-specific pyproject sections map to standard PEP 621 project metadata and PEP 735 dependency groups used by uv. Poetry fields and their standard equivalents Poetry Standard / uv [tool.poetry] name, version [project] name, version [tool.poetry.dependencies] [project] dependencies python = "^3.10" requires-python = ">=3.10,<4" [tool.poetry.group.dev] [dependency-groups] dev [tool.poetry.scripts] [project.scripts] poetry.lock uv.lock (regenerated) Caret constraints translate to explicit ranges; review each one rather than trusting a tool blindly.

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.

A migration that can be reviewed Migration steps from Poetry to uv: convert metadata, lock with uv, compare resolved versions against the old lock, then switch CI and documentation. A migration that can be reviewed Convert PEP 621 metadata uv lock new lockfile Compare versions vs poetry.lock Switch CI and docs pyproject resolve no surprises Comparing resolved versions is the step that turns a leap of faith into a reviewed change.

Step 5: switch the commands

Poetryuv
poetry installuv sync
poetry install --only mainuv sync --no-dev
poetry add httpxuv add httpx
poetry add --group dev pytestuv add --dev pytest
poetry run mytooluv run mytool
poetry lockuv lock
poetry builduv build
poetry publishuv 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 = true and lists them under [tool.poetry.extras]. The standard form is [project.optional-dependencies], keyed by extra name — the same pip 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] with path = "../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 (a justfile, nox sessions, or a small scripts/ directory run with uv run), not in the package's public entry points.
  • poetry version patch. uv provides uv version --bump patch for 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 sync instead of poetry 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.
Converting with migrate-to-uv Terminal output of running the migrate-to-uv tool on a Poetry project, then locking and running the tests with uv. Converting with migrate-to-uv bash $ uvx migrate-to-uv Locking dependencies with "uv lock"... Successfully migrated project from Poetry to uv! $ uv run pytest -q 118 passed in 4.02s Keep the old lockfile in the branch until the comparison is done, then delete it.

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.