TL;DR

uv replaces pip, poetry, pyenv, and virtualenv with a single Rust-powered binary that runs 10–100x faster. This tutorial walks through a real project (a weather CLI) from uv init to Docker deployment. You’ll add dependencies, write tests, lint with ruff, build a wheel, and ship a container image, all without ever typing pip install.

Why Another uv Tutorial?

Most uv guides stop at uv add requests. That covers maybe 5% of what you need for a production project. What about dev dependencies? Linting? Testing? Building distributable packages? Docker?

I switched my entire Python workflow to uv about four months ago after writing our uv vs pip vs Poetry comparison. The comparison convinced me uv wins on speed. Living with it since convinced me it also wins on workflow simplicity — one tool handles what used to take five. The lockfile alone saved me from a dependency conflict that cost me half a day on an older project.

This tutorial covers everything I wish existed when I started: a complete project lifecycle with a real application. You’ll build a CLI tool called weather-cli that fetches weather data from the Open-Meteo API (free, no API key required) and prints a formatted forecast. It uses three popular libraries:

  • typer — CLI argument parsing
  • httpx — async-capable HTTP client
  • rich — terminal formatting and tables

By the end, you’ll have a tested, linted, buildable Python package in a Docker image. All you need is a machine with macOS, Linux, or Windows (WSL works). No Python installation required — uv handles that.

Step 1: Install uv

The standalone installer works on all platforms and doesn’t need Python or Rust:

curl -LsSf https://astral.sh/uv/install.sh | sh

On macOS with Homebrew:

brew install uv

Verify the installation:

uv --version
uv 0.11.17 (be97c0c5a 2026-05-28)

uv also installs Python for you. No pyenv, no deadsnakes PPA, no manual downloads.

Step 2: Create the Project

uv init weather-cli --python 3.13
cd weather-cli

This creates a project directory with:

weather-cli/
├── .python-version      # pinned to 3.13
├── README.md
├── main.py              # starter script
└── pyproject.toml       # project metadata

uv downloaded Python 3.13 automatically if you didn’t have it. Check with:

uv run python --version
Python 3.13.4

The pyproject.toml starts minimal:

[project]
name = "weather-cli"
version = "0.1.0"
description = "A CLI tool for weather forecasts"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []

Step 3: Add Dependencies

uv add typer httpx rich
Resolved 12 packages in 214ms
Installed 12 packages in 38ms
 + annotated-types==0.7.0
 + anyio==4.9.0
 + certifi==2025.4.26
 + click==8.2.1
 + h11==0.16.0
 + httpcore==1.0.9
 + httpx==0.28.1
 + markdown-it-py==3.0.0
 + mdurl==0.1.2
 + rich==14.0.0
 + shellingham==1.5.4
 + typer==0.17.2

214 milliseconds to resolve 12 packages. With pip, the same operation takes 4–8 seconds on a warm cache. On a cold cache, the gap is wider.

uv created two files:

  • uv.lock — cross-platform lockfile with exact hashes. Commit this.
  • .venv/ — virtual environment. Gitignore this.

Now add dev dependencies:

uv add --dev pytest ruff
Resolved 16 packages in 187ms
Installed 4 packages in 14ms
 + iniconfig==2.1.0
 + packaging==25.0
 + pluggy==1.6.0
 + pytest==8.4.0

Dev dependencies go under [dependency-groups] in pyproject.toml and won’t ship with your package.

Step 4: Write the CLI

Delete the starter main.py and create a proper package structure:

weather-cli/
├── src/
│   └── weather_cli/
│       ├── __init__.py
│       └── main.py
├── tests/
│   └── test_main.py
├── pyproject.toml
└── uv.lock

Update pyproject.toml to point at the package:

[project]
name = "weather-cli"
version = "0.1.0"
description = "A CLI tool for weather forecasts"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "httpx>=0.28.1",
    "rich>=14.0.0",
    "typer>=0.17.2",
]

[dependency-groups]
dev = [
    "pytest>=8.4.0",
    "ruff>=0.11.13",
]

[project.scripts]
weather-cli = "weather_cli.main:app"

The [project.scripts] entry gives you a weather-cli command after installation.

Now write src/weather_cli/__init__.py:

"""Weather CLI — fetch forecasts from your terminal."""

And the actual CLI in src/weather_cli/main.py:

import httpx
import typer
from rich.console import Console
from rich.table import Table

app = typer.Typer(help="Fetch weather forecasts from your terminal.")
console = Console()

API_BASE = "https://api.open-meteo.com/v1/forecast"


def fetch_forecast(latitude: float, longitude: float, days: int = 3) -> dict:
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
        "timezone": "auto",
        "forecast_days": days,
    }
    response = httpx.get(API_BASE, params=params)
    response.raise_for_status()
    return response.json()


@app.command()
def forecast(
    latitude: float = typer.Argument(..., help="Latitude (e.g. 34.89 for Limassol)"),
    longitude: float = typer.Argument(..., help="Longitude (e.g. 33.63 for Limassol)"),
    days: int = typer.Option(3, "--days", "-d", help="Forecast days (1-16)"),
):
    """Show a weather forecast table for the given coordinates."""
    if not 1 <= days <= 16:
        console.print("[red]Days must be between 1 and 16.[/red]")
        raise typer.Exit(code=1)

    data = fetch_forecast(latitude, longitude, days)
    daily = data["daily"]

    table = Table(title=f"Weather Forecast ({data.get('timezone', 'UTC')})")
    table.add_column("Date", style="cyan")
    table.add_column("High °C", justify="right", style="red")
    table.add_column("Low °C", justify="right", style="blue")
    table.add_column("Rain mm", justify="right", style="green")

    for i, date in enumerate(daily["time"]):
        table.add_row(
            date,
            f"{daily['temperature_2m_max'][i]:.1f}",
            f"{daily['temperature_2m_min'][i]:.1f}",
            f"{daily['precipitation_sum'][i]:.1f}",
        )

    console.print(table)


@app.command()
def cities():
    """List a few built-in city coordinates."""
    presets = [
        ("Limassol", 34.69, 33.03),
        ("Berlin", 52.52, 13.41),
        ("New York", 40.71, -74.01),
        ("Tokyo", 35.68, 139.69),
        ("San Francisco", 37.77, -122.42),
    ]
    table = Table(title="Built-in Cities")
    table.add_column("City")
    table.add_column("Latitude", justify="right")
    table.add_column("Longitude", justify="right")
    for name, lat, lon in presets:
        table.add_row(name, str(lat), str(lon))
    console.print(table)

Step 5: Run It

uv run weather-cli forecast 34.69 33.03 --days 5
      Weather Forecast (Europe/Athens)
┏━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓
┃ Date       ┃ High °C ┃ Low °C ┃ Rain mm ┃
┡━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩
│ 2026-06-02 │   31.2  │  22.4  │    0.0  │
│ 2026-06-03 │   32.7  │  22.8  │    0.0  │
│ 2026-06-04 │   33.1  │  23.0  │    0.0  │
│ 2026-06-05 │   30.8  │  22.1  │    0.2  │
│ 2026-06-06 │   29.5  │  21.7  │    1.4  │
└────────────┴─────────┴────────┴─────────┘

uv run ensures the virtual environment is synced with the lockfile before every execution. If you’ve added a dependency since the last run, it installs automatically — no manual pip install step to forget.

Step 6: Write Tests

Create tests/test_main.py:

from unittest.mock import patch

from typer.testing import CliRunner

from weather_cli.main import app, fetch_forecast

runner = CliRunner()

MOCK_RESPONSE = {
    "timezone": "Europe/Athens",
    "daily": {
        "time": ["2026-06-02", "2026-06-03"],
        "temperature_2m_max": [31.2, 32.7],
        "temperature_2m_min": [22.4, 22.8],
        "precipitation_sum": [0.0, 0.0],
    },
}


def test_forecast_displays_table():
    with patch("weather_cli.main.fetch_forecast", return_value=MOCK_RESPONSE):
        result = runner.invoke(app, ["forecast", "34.69", "33.03", "--days", "2"])
    assert result.exit_code == 0
    assert "2026-06-02" in result.stdout


def test_forecast_rejects_invalid_days():
    result = runner.invoke(app, ["forecast", "34.69", "33.03", "--days", "20"])
    assert result.exit_code == 1


def test_cities_command():
    result = runner.invoke(app, ["cities"])
    assert result.exit_code == 0
    assert "Limassol" in result.stdout
    assert "Berlin" in result.stdout

Run the tests:

uv run pytest -v
======================== test session starts =========================
collected 3 items

tests/test_main.py::test_forecast_displays_table PASSED         [ 33%]
tests/test_main.py::test_forecast_rejects_invalid_days PASSED    [ 66%]
tests/test_main.py::test_cities_command PASSED                   [100%]

========================= 3 passed in 0.42s ==========================

No activation scripts, no source .venv/bin/activate, no python -m pytest. Just uv run pytest and it works.

Step 7: Lint With Ruff

Add a ruff configuration to pyproject.toml:

[tool.ruff]
target-version = "py313"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM"]

Run it:

uv run ruff check src/ tests/
All checks passed!

Format the code too:

uv run ruff format src/ tests/
3 files already formatted

Ruff also comes from Astral, the same team behind uv. They share configuration patterns and work together without friction. If you want to go further with type checking, see our Pyrefly vs mypy vs ty comparison — ty is Astral’s type checker and pairs naturally with ruff and uv.

214ms
Dependency resolve
38ms
Package install
0.42s
Full test suite
1 tool
Replaces 5

Step 8: Build and Publish

Build a distributable wheel:

uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/weather_cli-0.1.0.tar.gz
Successfully built dist/weather_cli-0.1.0-py3-none-any.whl

Both a source distribution and wheel land in dist/. To publish to PyPI:

uv publish --token $PYPI_TOKEN

If you’re publishing to a private index (Artifactory, Nexus, AWS CodeArtifact), pass --index-url:

uv publish --index-url https://your-private-index.example.com/simple/ --token $TOKEN

Step 9: Docker Deployment

uv shines in containers. The global cache means repeated builds are fast, and uv sync with a lockfile guarantees deterministic installs.

Create a Dockerfile:

FROM python:3.13-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY src/ src/
RUN uv sync --frozen --no-dev

ENTRYPOINT ["uv", "run", "weather-cli"]

The two-stage uv sync is intentional: first install dependencies (cached layer), then install the project. Code changes don’t invalidate the dependency layer.

Build and run:

docker build -t weather-cli .
docker run --rm weather-cli forecast 52.52 13.41 --days 3

The image weighs around 120 MB: the python:3.13-slim base plus your dependencies. For an even smaller image, use a multi-stage build with python:3.13-alpine or copy only the .venv into a distroless base.

uv vs pip vs Poetry: Quick Reference

If you’re coming from pip or Poetry, here’s how the commands map:

TaskpipPoetryuv
Create projectmanualpoetry inituv init
Add dependencypip install + manual requirements.txtpoetry adduv add
Install from lockpip install -r requirements.txtpoetry installuv sync
Run a scriptpython script.pypoetry run python script.pyuv run python script.py
Build packagepython -m buildpoetry builduv build
Publishtwine uploadpoetry publishuv publish
Install Pythonpyenv/deadsnakesnot supporteduv python install 3.13
Run one-off toolpipx runnot supporteduvx ruff check .
Resolve speed4–8s (warm)2–5s200–400ms

The speed gap is bigger than it looks. When uv add takes 200ms, you try things. When pip install takes 8 seconds, you batch changes and test less often.

Tips From Four Months of Daily Use

Pin your Python version with uv python pin 3.13. This writes .python-version and uv respects it everywhere. Team members who run uv sync get the same Python automatically — no Slack messages asking “which version are you on?”

Stop activating virtualenvs. uv run ensures the environment matches the lockfile every time, so there’s nothing to activate. I haven’t typed source .venv/bin/activate since February.

Commit uv.lock. Like poetry.lock or package-lock.json, the lockfile is the contract between your local environment and CI. Without it, you’re rolling dice on dependency versions.

For one-off tools, use uvx. Need to run a formatter once? uvx black . downloads it into a temporary environment and runs it. No global installs, no polluting your project dependencies.

CI gets the biggest win. A GitHub Actions workflow with uv sync --frozen typically finishes in 3–5 seconds for the dependency install step. Poetry takes 15–30 seconds for the same dependency set. Over hundreds of CI runs per week, that adds up.

That said, it’s not perfect. Some edge cases to know:

If you depend on Poetry plugins (like poetry-dynamic-versioning), you’ll need to find uv-compatible alternatives or use a build hook instead. The plugin API is different and there’s no translation layer.

uv handles most C extensions fine, but some packages with complex build systems (old versions of numpy with Fortran dependencies, for example) occasionally hit edge cases. These are getting fixed rapidly, and the issue tracker is active.

Workspace support exists (uv init --lib, [tool.uv.workspace]), but it’s younger than Poetry’s or npm’s. For large monorepos with many interdependent packages, test the workspace features against your specific layout before committing.

FAQ

How do I use uv in Python? Install uv with curl -LsSf https://astral.sh/uv/install.sh | sh, then create a project with uv init my-project. Add dependencies with uv add package-name, and run your code with uv run python script.py or uv run my-command. uv handles the virtual environment and lockfile automatically.

What is uv Python? uv is a Python package and project manager built by Astral, the company behind the ruff linter. Written in Rust, it replaces pip, pip-tools, pipx, poetry, pyenv, and virtualenv with a single binary that runs 10–100x faster.

Is uv better than pip? For project management, yes. uv resolves dependencies 10–100x faster, generates lockfiles by default, manages Python versions, and provides a consistent uv run command that keeps your environment synced. pip still works fine for one-off installs in throwaway environments, but for any project you’ll maintain, uv is a strict upgrade.

How do I install packages with uv? Run uv add package-name to add a dependency to your project. This updates pyproject.toml, regenerates the lockfile, and installs the package into your virtual environment in one step. For dev-only dependencies (test frameworks, linters), use uv add --dev package-name.

Can I use uv with existing pip projects? Yes. If you have a requirements.txt, run uv pip install -r requirements.txt as a drop-in replacement. For a full migration, create a pyproject.toml with uv init, then uv add each dependency. The uv migration guide covers the details.

Sources

Bottom Line

uv is what Python dependency management should have been from the start. Fast, deterministic, and opinionated enough to make the right thing easy. The four months since I switched have been the smoothest stretch of Python project maintenance I’ve had in a decade.

If you’re starting a new Python project today, there’s no reason to reach for pip + virtualenv + pyenv. uv init gives you everything in one command, and the rest of this tutorial proves it scales from “quick CLI hack” to “tested, linted, containerized package” without introducing a second tool. For another example of building a CLI from scratch, check out our spec-driven development guide which uses uv as its package manager. And if you’re staying on Python 3.13 for now but curious about the next release, our Python 3.14 free-threading benchmarks cover what’s coming.