Project Setup

uv for Python CLI Dependency Management

Use uv to manage Python CLI dependencies with fast lockfile resolution, virtual env creation, and PEP 621-compliant pyproject.toml workflows.

Updated

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 mycli scaffolds a PEP 621 pyproject.toml with a [project.scripts] entry point.
  • uv add typer adds a dependency and updates uv.lock in one step; uv sync materializes the environment.
  • uv run mycli executes 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.
The uv workflow — from init to run The uv workflow uv init pyproject.toml uv add <pkg> uv lock uv.lock uv sync .venv uv run mytool uv tool install — ship the CLI to end users

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:

What uv add actually does The sequence behind uv add: the dependency is written to pyproject, the lockfile is resolved and updated, and the environment is synced to match. What uv add actually does You pyproject.toml uv.lock .venv uv add httpx resolve the whole graph install exactly what is locked ready to run, no activation needed One command touches all three: that is why an out-of-date lockfile is rare in a uv project.
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 lock re-resolves the full graph and writes uv.lock without touching the environment. Run it after editing pyproject.toml by hand.
  • uv sync makes the installed environment match uv.lock exactly — 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:

What the lockfile pins The contents of a uv lockfile: exact versions, hashes, resolution markers for each platform, and the Python version the resolution targets. What the lockfile pins uv.lock committed to the repo Exact versions every transitive dependency Artifact hashes the wheel that was resolved Platform markers one lock, many machines requires-python the interpreter range it holds for Cross-platform by design: one file covers macOS, Linux and Windows uv sync --frozen fails rather than silently re-resolving Never hand-edit it; change pyproject.toml and re-lock The lockfile is the reproducibility guarantee — the CI job that ignores it is the one that breaks.
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:

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.