uv init and poetry init both bootstrap a new Python project and end with a pyproject.toml you can build a CLI on. But they take meaningfully different paths to get there — different lockfile formats, different default entry-point conventions, and a roughly order-of-magnitude difference in how fast they resolve dependencies. This article puts the two side by side for the specific case of shipping a console-script CLI, with concrete pyproject.toml snippets for each so you can see exactly what you're committing to.
TL;DR
- Speed:
uv initplus the firstuv addis dramatically faster thanpoetry init/poetry addbecause uv's Rust resolver and aggressive caching avoid most network round-trips. - Lockfile: uv writes a universal, cross-platform
uv.lock; Poetry writespoetry.lock. Neither is interchangeable, and you commit exactly one. - Entry points: both now support PEP 621
[project.scripts]. Poetry historically used[tool.poetry.scripts]; uv has always used the standard table. - Choose uv for speed and standards-first metadata; choose Poetry if your team already standardizes on it or relies on its publishing/group ergonomics.
Bootstrapping: what each command generates
poetry init runs an interactive prompt (or takes flags) and writes metadata. Until recently it emitted a Poetry-specific [tool.poetry] table; Poetry 2.x defaults to the PEP 621 [project] table instead. A typical CLI-ready file looks like this:
[project]
name = "mycli"
version = "0.1.0"
description = "A friendly command-line tool."
requires-python = ">=3.9"
dependencies = ["typer>=0.12"]
[project.scripts]
mycli = "mycli.__main__:app"
[tool.poetry]
packages = [{ include = "mycli", from = "src" }]
[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"
uv init --package mycli is non-interactive and standards-first from the start — it never writes a [tool.poetry] table and uses Hatchling as the default build backend:
[project]
name = "mycli"
version = "0.1.0"
description = "A friendly command-line tool."
requires-python = ">=3.9"
dependencies = ["typer>=0.12"]
[project.scripts]
mycli = "mycli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Both declare the CLI through the standardized [project.scripts] table, which maps the command name to an import.path:callable target — the convention detailed in best practices for Python CLI entry points.
Side-by-side comparison
| Dimension | uv init | poetry init |
|---|---|---|
| Bootstrapping speed | Very fast; non-interactive by default | Slower; interactive prompt by default |
| Resolver | Rust, parallel, heavily cached | Pure Python |
| Lockfile | uv.lock (universal, cross-platform) | poetry.lock (resolved for project) |
| Add a dependency | uv add typer | poetry add typer |
| Entry-point table | [project.scripts] (PEP 621) | [project.scripts] (2.x); [tool.poetry.scripts] historically |
| Default build backend | Hatchling | poetry-core |
| Dev dependencies | [dependency-groups] (--dev) | [tool.poetry.group.*] |
| Run without activation | uv run mycli | poetry run mycli |
| Global tool install | uv tool install . | (use pipx) |
| Self-bootstrap install | Single static binary | pip/pipx/installer script |
Lockfile strategy: uv.lock vs poetry.lock
This is the difference that matters most in CI and on teams. poetry.lock pins a single resolution that Poetry computes for your project; it's mature and well understood, but reproducing it elsewhere still depends on Poetry being installed.
uv.lock is a universal lockfile: it stores resolutions spanning every platform and Python version permitted by your requires-python, so one committed file reproduces byte-identically on Linux, macOS, and Windows. In CI you enforce reproducibility with a single command:
# uv — fails if uv.lock is stale relative to pyproject.toml
uv sync --frozen
# Poetry — installs strictly from poetry.lock
poetry install --no-root
You commit exactly one of these files. The two are not interchangeable — uv reads uv.lock, Poetry reads poetry.lock — so picking a tool is also picking a lockfile.
Entry-point syntax and pyproject compatibility
The historical wrinkle is the scripts table. Poetry projects created before the PEP 621 era declared CLIs under [tool.poetry.scripts]:
# Legacy Poetry style — still works, but non-standard
[tool.poetry.scripts]
mycli = "mycli.__main__:app"
The portable, tool-agnostic form is [project.scripts], which both uv and modern Poetry emit and which any PEP 517 build backend understands:
[project.scripts]
mycli = "mycli.__main__:app"
Because uv builds on the standardized [project] metadata, a uv project is broadly compatible with the wider packaging ecosystem out of the box — pip install ., python -m build, and other PEP 621-aware tools all read the same fields. A Poetry 2.x project using [project] enjoys the same portability. A legacy Poetry project still leaning on [tool.poetry] for its name/version/dependencies is the one case where another tool can't fully read the metadata without translation.
Migrating a legacy Poetry project to uv
If you're moving an older Poetry CLI to uv, the work is mechanical:
- Move
name,version,description, anddependenciesfrom[tool.poetry]into[project]. - Rename
[tool.poetry.scripts]to[project.scripts]. - Ensure a valid
[build-system]table exists (e.g. Hatchling orpoetry-core>=2.0). - Run
uv syncto generateuv.lock, then deletepoetry.lock. - Remove the now-redundant
[tool.poetry]metadata once everything resolves.
After that, uv run mycli should behave exactly like poetry run mycli did.
When to choose each
Reach for uv init when: you're starting fresh, you care about cold-cache speed (CI, contributor onboarding, Docker layers), or you want PEP 621-native metadata and a single static binary with no Python bootstrap of its own. uv also bundles uv tool install for shipping the finished CLI globally, so the whole lifecycle lives in one tool.
Reach for poetry init when: your team already standardizes on Poetry, you depend on its mature publishing flow (poetry publish) or its dependency-group ergonomics, or your CI and tooling are already wired around poetry.lock. Poetry remains a solid, well-documented choice — the deeper end-to-end workflow is covered in Poetry workflows for CLI development.
The good news is the choice is reversible. Because both converge on PEP 621 [project] metadata, switching mostly means regenerating the lockfile — the entry points and dependency declarations carry over unchanged.
Production notes
- Commit the lockfile, ignore the venv. Whichever tool you pick,
uv.lock/poetry.lockbelongs in version control and.venv/does not. - Pin the tool version in CI. Resolver behavior evolves; pin uv or Poetry in your CI image so a tool upgrade can't silently change a resolution.
- Don't keep both lockfiles. A repo with both
uv.lockandpoetry.lockinvites drift — delete the one you're not using. - Cross-platform CLIs favor uv.lock. If you ship wheels for Windows, macOS, and Linux, uv's universal lock removes a class of "works on my OS" resolution bugs.
Related
- uv for Python CLI dependency management — the uv command reference this comparison builds on.
- Poetry workflows for CLI development — the full Poetry lifecycle for CLI projects.
- Project Setup & Dependency Management — the parent pillar covering scaffolding, versioning, and packaging.