Project Setup

uv init vs poetry init for CLI tools

Compare uv init and poetry init for Python CLI projects — lockfile strategy, bootstrapping speed, entry point syntax, and pyproject.toml compatibility.

Updated

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 init plus the first uv add is dramatically faster than poetry init/poetry add because uv's Rust resolver and aggressive caching avoid most network round-trips.
  • Lockfile: uv writes a universal, cross-platform uv.lock; Poetry writes poetry.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.

Two-column comparison of uv init and poetry init across bootstrap, lockfile, scripts table, and speed.

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

Dimensionuv initpoetry init
Bootstrapping speedVery fast; non-interactive by defaultSlower; interactive prompt by default
ResolverRust, parallel, heavily cachedPure Python
Lockfileuv.lock (universal, cross-platform)poetry.lock (resolved for project)
Add a dependencyuv add typerpoetry add typer
Entry-point table[project.scripts] (PEP 621)[project.scripts] (2.x); [tool.poetry.scripts] historically
Default build backendHatchlingpoetry-core
Dev dependencies[dependency-groups] (--dev)[tool.poetry.group.*]
Run without activationuv run myclipoetry run mycli
Global tool installuv tool install .(use pipx)
Self-bootstrap installSingle static binarypip/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:

  1. Move name, version, description, and dependencies from [tool.poetry] into [project].
  2. Rename [tool.poetry.scripts] to [project.scripts].
  3. Ensure a valid [build-system] table exists (e.g. Hatchling or poetry-core>=2.0).
  4. Run uv sync to generate uv.lock, then delete poetry.lock.
  5. 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.lock belongs 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.lock and poetry.lock invites 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.