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.
uv init vs poetry init — side by side uv init vs poetry init uv Poetry Bootstrap Lockfile Scripts table Speed uv init poetry init uv.lock poetry.lock [project.scripts] [project.scripts] or [tool.poetry.scripts] fast slower

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.

Two lockfiles, two philosophies A comparison of uv.lock and poetry.lock across resolution scope, platform coverage, install command and refresh behaviour. Two lockfiles, two philosophies Lockfile uv.lock poetry.lock Resolution one universal resolution one resolution per project Platforms markers cover all of them markers cover all of them Install from it uv sync poetry install Strict mode uv sync --frozen poetry check --lock Speed of a re-lock sub-second on cache seconds Both are honest lockfiles; the difference you feel day to day is how long a re-lock takes.

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

uv or Poetry for a new CLI? A decision diagram: choose uv for speed and interpreter management, choose Poetry when the team already relies on its plugin ecosystem and publishing workflow. uv or Poetry for a new CLI? What does the team already depend on? Nothing yet — this is a fresh repo uv fastest loop, manages interpreters Poetry plugins, groups and publish flows Poetry no migration to justify Both write a standards-compliant pyproject.toml, so this is a reversible decision, not a fork in the road.

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.

Frequently asked questions

Can I switch from Poetry to uv without rewriting pyproject.toml?

Mostly. If your project already uses the standard project table, uv reads it directly and the change is dropping poetry.lock for uv.lock and swapping the commands you type. If it still uses the older tool.poetry dependency tables, those need translating into project.dependencies first, which is mechanical but not automatic.

Which lockfile should be committed?

Whichever one your project uses — always commit it. Both uv.lock and poetry.lock are designed to be checked in; they are what make a clone reproducible and what CI should install from with a strict flag that fails rather than silently re-resolving.

Is uv's speed actually noticeable on a small CLI?

On a first install with a cold cache, less than you would think. Where it shows is everything after that: re-locking after adding a dependency, creating a fresh environment in CI, or a colleague setting up the repo. Those go from tens of seconds to about one, which changes how often people are willing to do them.

Does uv replace pyenv as well?

For most workflows, yes. uv can download and manage interpreters itself, so uv python install 3.12 and a requires-python floor replace the pyenv dance for a project. If your team already standardises on pyenv, there is no need to rip it out — uv will happily use the interpreter it finds.

Which should a brand-new CLI project pick?

uv, unless the team depends on something in Poetry's plugin ecosystem. Both produce a standards-compliant project, so this is a reversible decision; pick the faster loop by default and change your mind later if a specific Poetry feature turns out to matter.

What happens to the generated project if I change my mind later?

Very little, because both tools generate a standards-compliant [project] table. Moving from Poetry to uv means deleting poetry.lock, running uv lock, and updating the commands in your README and CI; moving the other way is the mirror image. The only migration with real work in it is a project still using the older [tool.poetry.dependencies] layout, where the dependency lists need translating into the standard tables first — mechanical, but not automatic.

Which one creates a virtual environment automatically?

Both, at slightly different moments. Poetry creates one the first time you install or add a dependency, in a central cache directory unless configured otherwise. uv creates .venv inside the project on the first uv add, uv sync or uv run. The in-project default is easier to reason about and easier to delete when something has gone strange.