uv: the Python package manager

MLOps, Session 2: a short reminder

Lambert Fatoux

What is uv?

One fast tool, written in Rust by Astral, that replaces five older ones.

Install uv

macOS and Linux

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

Windows (PowerShell)

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Open a new terminal, then check:

uv --version
  • macOS with Homebrew: brew install uv
  • Windows with WinGet: winget install --id=astral-sh.uv -e
  • Update a standalone install: uv self update

Three files, two commands

uv add and uv run call both commands for you. Commit uv.lock: it is what makes another machine install the same versions.

Daily commands

uv init                            # new project: pyproject.toml, .python-version
uv add pandas                      # add a dependency, update uv.lock, install
uv add --dev pytest                # development-only dependency
uv remove pandas                   # remove it again

uv sync                            # make .venv match uv.lock (after git clone)
uv lock --upgrade-package pandas   # upgrade one package, leave the rest
  • Always uv add, never pip install into the project
  • After git clone, one command rebuilds the environment: uv sync

Dependency groups: an example

[project]                          # what the project needs to RUN
name = "fraud-lab"
requires-python = ">=3.12"
dependencies = [
    "pandas>=3.0.6",
    "scikit-learn>=1.9.1",
]

[dependency-groups]                # what you need to DEVELOP
dev = [
    "ruff>=0.6",
    {include-group = "test"},      # dev also contains test
]
test = ["pytest>=8"]
docs = ["mkdocs>=1.6"]

Reading the TOML

  • [dependency-groups] is a table: one key per group
  • dev = [ ... ] is a list of strings, like "pytest>=8"
  • {include-group = "test"} nests a group: dev also installs test
  • dev is synced by default; test and docs are names you choose
  • Groups stay local; extras are published with the package

Groups in practice

uv add --dev ruff              # write it in the dev group
uv add --group test pytest     # create the test group if needed

uv sync                        # project + dev (the default)
uv sync --group docs           # ... and docs too
uv sync --no-dev               # project only: Docker, production
uv sync --only-group test      # only pytest: a fast CI job

The same flags work with uv run.

uv run: no activation needed

uv run python train.py     # locks and syncs first, then runs
uv run pytest              # a dev dependency, run inside the project

Three flags decide what happens when the lockfile is stale:

  • --locked: error if uv.lock is out of date, never rewrite it
  • --frozen: use uv.lock as it is, without checking
  • --no-sync: run without checking that .venv is up to date

uv lock --check is the same test as --locked, without running anything.

Tools outside the project

  • uvx ruff check .: run a tool once, in a throwaway environment (uvx = uv tool run)
  • uv tool install ruff: keep it on your PATH, like pipx
  • uv add --dev ruff: the project needs it, so everyone gets the same version through uv.lock

Rule of thumb: if the code or the CI depends on the tool, it is a dev dependency, run with uv run. Everything else is uvx.

uv in CI (GitHub Actions)

steps:
  - uses: actions/checkout@v7
  - uses: astral-sh/[email protected]
    with:
      version: "0.13.5"          # pin uv itself
      enable-cache: true         # reuse downloads between runs
  - run: uv sync --locked        # fails if uv.lock is out of date
  - run: uv run pytest

With --locked, a pull request that changes pyproject.toml without updating uv.lock turns the build red.

uv in Docker

FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /bin/
ENV UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1
WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project --no-dev   # dependencies layer

COPY . .
RUN uv sync --locked --no-dev                        # then the project
CMD ["uv", "run", "--no-sync", "python", "predict.py"]
  • Dependencies before code: the first layer is cached until uv.lock changes
  • Add .venv to .dockerignore: a laptop environment does not run in Linux

Best practices

  • Commit pyproject.toml and uv.lock; never .venv/
  • Add packages with uv add, not pip install
  • Use --locked in CI and Docker: a stale lock fails loudly
  • Pin the uv version, never latest
  • Upgrade on purpose: uv lock --upgrade-package <name>
  • Dev tools in the dev group, one-off tools with uvx