uv is an extremely fast Python package and project manager written in Rust. For CLI development it replaces the whole pip + virtualenv + pip-tools stack — and often Poetry too — with a single binary that resolves dependencies, manages a project virtual environment, writes a cross-platform lockfile, and installs your tool onto the user's PATH. This overview walks the commands you reach for daily when building and shipping a Python command-line tool with uv.
TL;DR
uv init --package mycliscaffolds a PEP 621pyproject.tomlwith a[project.scripts]entry point.uv add typeradds a dependency and updatesuv.lockin one step;uv syncmaterializes the environment.uv run mycliexecutes your console script inside the managed venv without manual activation.uv tool install .installs your CLI globally so end users can run it anywhere.- For a head-to-head with the other popular bootstrapper, see uv init vs poetry init for CLI tools.
Scaffolding a project: uv init
uv init creates the project skeleton. For a CLI you want the --package flag, which lays out an importable src/ package and registers a console-script entry point:
uv init --package mycli
cd mycli
This produces a PEP 621 pyproject.toml — the standardized project metadata table that any modern build backend understands:
[project]
name = "mycli"
version = "0.1.0"
description = "A friendly command-line tool."
requires-python = ">=3.9"
dependencies = []
[project.scripts]
mycli = "mycli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
The [project.scripts] table is the standard way to declare CLI entry points — the same syntax covered in best practices for Python CLI entry points.
Adding dependencies: uv add, uv lock, uv sync
uv add is the command you use most. It records the dependency in pyproject.toml, re-resolves the dependency graph, and updates uv.lock atomically:
uv add "typer>=0.12"
uv add --dev pytest ruff
Development-only tools go behind --dev, landing in a [dependency-groups] table rather than your runtime dependencies. Two lower-level commands sit underneath uv add:
uv lockre-resolves the full graph and writesuv.lockwithout touching the environment. Run it after editingpyproject.tomlby hand.uv syncmakes the installed environment matchuv.lockexactly — installing what's missing and removing what shouldn't be there.
The lockfile: uv.lock
uv.lock is a universal, cross-platform lockfile. Unlike a flat requirements.txt, it captures resolutions for every platform and Python version your project supports, so a single committed lockfile reproduces identically on Linux, macOS, and Windows. Commit it to version control. Because the lock is the source of truth, CI can install with a single reproducible command:
uv sync --frozen
--frozen errors out if uv.lock is stale relative to pyproject.toml, which is exactly what you want in CI — it guarantees nobody forgot to re-lock.
Managing the virtual environment
uv creates and manages a project venv at .venv/ automatically — you rarely create one by hand. The first uv run or uv sync provisions it, and uv will even download a managed CPython build if your requires-python isn't satisfied locally. If you do want an explicit environment, uv venv creates one, but for project work you can skip it entirely and let the commands below handle activation transparently.
Running code: uv run
uv run executes a command inside the project environment, syncing it first if needed — no source .venv/bin/activate required:
uv run mycli --help
uv run pytest
uv run python -c "import mycli; print(mycli.__file__)"
This is the single most useful day-to-day command. Because it auto-syncs, uv run mycli always reflects the current pyproject.toml and uv.lock, which makes it ideal for both local iteration and CI scripts.
Distributing the CLI: uv tool install
Once your tool is ready to use as a global command, uv tool install installs it into an isolated environment and puts its entry points on your PATH — the modern equivalent of pipx install:
uv tool install my-cli-tool --from .
mycli --version
Each installed tool gets its own venv, so global CLIs never clash over conflicting dependencies. For quick one-off invocations of a published tool without installing it permanently, uvx mycli (an alias for uv tool run) fetches and runs it in a throwaway environment.
Where to go next
uv and Poetry both bootstrap projects, declare entry points, and lock dependencies, but they make different trade-offs around speed and lockfile format. For a detailed, side-by-side decision guide with concrete pyproject.toml snippets, read:
- uv init vs poetry init for CLI tools — bootstrapping speed, lockfile strategy, and entry-point syntax compared.
A complete uv workflow for a CLI
The commands below take a project from nothing to an installable tool, and they are the whole day-to-day vocabulary.
uv init --package mytool && cd mytool # a src layout with a pyproject.toml
uv add typer rich # runtime dependencies, resolved and locked
uv add --dev pytest ruff mypy # tooling that never ships
uv run mytool --help # runs inside the project environment
uv run pytest # same environment, no activation
uv init --package matters: it produces the src/ layout and a [project.scripts] entry rather
than a bare script, which is what you want for anything that will be installed. The generated
metadata is standard, so nothing here locks you to uv:
[project]
name = "mytool"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["typer>=0.12", "rich>=13.7"]
[project.scripts]
mytool = "mytool.cli:app"
[build-system]
requires = ["uv_build>=0.8"]
build-backend = "uv_build"
Two commands do the environment work, and knowing which is which removes most confusion.
uv lock resolves the dependency graph and writes uv.lock without touching your environment.
uv sync makes the environment match the lockfile exactly — installing what is missing and
removing what is not in the lock, which is why a stale package cannot linger. uv add does
both, plus the edit to pyproject.toml.
For a one-off tool run, uvx skips the project entirely:
uvx ruff check . # download into a cache, run, leave nothing behind
The lockfile, and how to keep CI honest
uv.lock is a universal resolution: one file covering every platform and interpreter your
requires-python allows, with exact versions and hashes for each. Commit it. It is the artifact
that makes a clone reproducible, and it is meant to be read by tools rather than people — never
hand-edit it.
In CI, install from it strictly:
uv sync --frozen # fail if uv.lock is out of date rather than re-resolving
uv run pytest
Without --frozen, a job that finds a stale lockfile quietly resolves fresh versions and passes,
which means the drift reaches a user before it reaches you. With it, the failure names the file
and the fix is a one-line commit.
Two more flags earn their keep:
uv sync --no-dev # exactly what a user gets — catches dev deps that leaked into runtime
uv lock --upgrade-package httpx # move one dependency without touching the rest of the graph
The first belongs in a small CI job of its own. Installing only the runtime set and running
mytool --version is the cheapest way to catch the import you thought was a runtime dependency
and was not.
Interpreters as part of the project
uv manages Python versions as well as packages, which removes a separate tool from onboarding:
uv python install 3.11 3.12 # download and manage both
uv python list # what is available, and what is in use
uv venv --python 3.11 # build this project's environment from the older one
uv run --python 3.12 pytest # run the suite on a different interpreter, ad hoc
Combined with requires-python, this makes the interpreter part of the reproducible setup rather
than something each developer arranges. The practical pattern for a CLI is to declare the widest
range you genuinely support, and test both ends in CI — those are the versions that break.
Shipping the tool
Building and publishing are the same two commands regardless of what your users install with:
uv build # wheel and sdist into dist/
uv publish # upload (or use trusted publishing from CI)
For local verification before either, install the built artifact into a throwaway environment and run it once — the single check that catches a missing package or a broken entry point:
uv run --isolated --with dist/mytool-0.1.0-py3-none-any.whl mytool --version
And for your README, the line to give users is the isolated install:
uv tool install mytool # or: pipx install mytool
That gives the tool its own environment with only its command on PATH, which is what stops your
pinned dependencies colliding with somebody else's.
Dependency groups and optional extras
Two mechanisms look similar and mean different things, and a CLI usually wants both.
Dependency groups are for the people working on the project. They never reach a user:
uv add --dev pytest pytest-cov ruff mypy
uv add --group docs mkdocs mkdocs-material
[dependency-groups]
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11"]
docs = ["mkdocs>=1.6", "mkdocs-material>=9.5"]
uv sync installs the default groups; uv sync --no-dev installs only what a user would get, and
uv sync --group docs adds a specific one. Nothing in a group appears in the wheel.
Optional extras are for users who want a feature that carries weight:
[project.optional-dependencies]
aws = ["boto3>=1.35"]
parquet = ["pyarrow>=17"]
Now uv tool install "mytool[aws]" is a documented contract, and the base install stays small.
Pair an extra with a lazy import and a helpful error, so the failure names the fix:
def _s3_client():
try:
import boto3
except ImportError: # pragma: no cover - depends on install extras
raise typer.BadParameter(
"the s3 backend needs the aws extra: pipx install 'mytool[aws]'"
) from None
return boto3.client("s3")
The rule of thumb: if the code imports it at run time for some users, it is an extra; if only the maintainers ever run it, it is a group.
Scripts, tasks and the commands you type
uv has no task runner, which surprises people coming from Poetry plugins or npm. In practice
uv run plus a small Makefile — or a justfile — covers it, and keeps the commands visible:
.PHONY: test lint fmt check
test: ; uv run pytest -q
lint: ; uv run ruff check .
fmt: ; uv run ruff format .
check: fmt lint test
Two advantages over hiding them in a tool-specific table: they work for someone who does not have
uv installed yet, and make check is the same command in CI and on a laptop. Whatever you choose,
write it down somewhere a newcomer will look, because "which command runs the tests" is the most
common onboarding question there is.
For a genuinely one-off tool, uv run --with avoids installing anything at all:
uv run --with httpie http GET https://example.com
Moving an existing project onto uv
The migration is short and can be done without a big-bang commit.
From requirements.txt. Import the entries, then lock:
uv add -r requirements.txt
uv add --dev -r requirements-dev.txt
uv lock
Convert exact pins into ranges as you go — a lockfile is the right place for exactness, and
== pins in pyproject.toml make every future upgrade a manual edit.
From Poetry. If the project already uses the standard [project] table, there is almost
nothing to do: delete poetry.lock, run uv lock, and update the commands in your README and CI.
If it still uses [tool.poetry.dependencies], translate those into [project.dependencies]
first — mechanical, but not automatic.
Either way, keep both toolchains working for one release if the team is large. CI can run
uv sync --frozen while individual developers finish switching, and the lockfile swap becomes a
single reviewable commit rather than a coordination exercise.
What to put in CI
A workflow that uses uv well is short, and every line earns its place:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python: ["3.11", "3.13"] # the ends of requires-python
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: uv.lock
- run: uv sync --frozen
- run: uv run pytest -q
The matrix covers the two axes that actually break: the oldest and newest interpreter you claim to support, and Windows, where path handling and console encoding differ. Versions in between rarely surface anything distinct and mostly buy a slower pipeline.
Keying the cache on uv.lock is what makes a stale cache impossible rather than merely unlikely —
change a dependency and the key changes with it. And a second, tiny job is worth adding for a CLI:
package:
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv build
- run: uv run --isolated --with dist/*.whl mytool --version
That is the check no test suite can replace: it proves the wheel contains the package, the entry point resolves, and the tool starts with only its declared runtime dependencies present.
Frequently asked questions
Do I need to activate the virtual environment?
No, and it is better not to. uv run executes inside the project environment, and any script that
depends on having been activated will eventually break in CI or a cron job. Activation remains a
convenience for an interactive session; treat it as optional everywhere else.
What is the difference between uv add and uv pip install?
uv add is a project operation: it edits pyproject.toml, updates uv.lock and syncs the
environment, so the dependency is declared and reproducible. uv pip install is the escape hatch
that installs into the environment without recording anything — useful for a quick experiment, and
exactly how an undeclared dependency ends up in someone's working setup.
Why did uv sync remove a package I installed?
Because sync makes the environment match the lockfile, and anything not in the lock is not part of
the project. That is the property that keeps environments reproducible. If the package should be
there, add it properly with uv add; if it was a one-off, use uv run --with pkg instead.
Can uv read a requirements.txt?
Yes — uv pip compile and uv pip sync cover that workflow, and uv add -r requirements.txt
imports the entries into pyproject.toml. For a new CLI project, declaring dependencies in
pyproject.toml and locking with uv.lock is the better arrangement, because the same file also
carries your metadata and console script.
Is uv's lockfile compatible with pip?
Not directly — it is uv's own format. uv export --format requirements-txt produces a pinned
requirements file when you need one, for a Docker build or a system that only understands pip.
The lockfile stays the source of truth; the export is a derived artifact you regenerate rather
than edit.
Does uv replace the build backend too?
It can. uv_build is a fast backend for pure-Python projects, and uv build will use whichever
backend your pyproject.toml declares — hatchling, setuptools or anything else. The choice is
independent of using uv for dependency management, so switching one does not force the other.
How do I pin uv itself so CI is reproducible?
Pin the action or the installer version rather than tracking latest, the same way you pin a hook
revision. setup-uv accepts a version: input, and a .uv-version file records the version the
project expects. Doing this means a uv release cannot change your resolution behaviour on a
Tuesday morning, and upgrading becomes a deliberate commit you can revert.
Related
- Project Setup & Dependency Management — the parent track covering scaffolding, versioning, and packaging.
- Poetry workflows for CLI development — the same lifecycle managed with Poetry instead of uv.
- uv init vs poetry init for CLI tools — choose between the two bootstrappers.