A typical Python project from a few years ago needed pyenv to get the interpreter, venv or virtualenv to isolate it, pip and pip-tools (or Poetry) to manage dependencies, pipx for global CLIs, and Black, isort and flake8 (with a handful of plugins) to keep the code tidy. Each had its own config file, its own speed, and its own way of breaking in CI.

In 2025 most of that collapses into two tools from Astral, both written in Rust: uv for Python versions, environments, dependencies, lockfiles, tools and scripts, and Ruff for linting and formatting. Around them, pyproject.toml has become the single place for project metadata and tool configuration, backed by standards (PEP 621, PEP 723, PEP 735) rather than by one tool's conventions.

This cheatsheet covers the commands and config I actually use, as of uv 0.6.10 and Ruff 0.11.2 (both released in late March 2025). Both projects ship releases almost weekly and are still pre-1.0, so pin versions in CI and skim the changelog when you upgrade.

1. uv in one table

uv describes itself as a single tool to replace pip, pip-tools, pipx, Poetry, pyenv, twine and virtualenv, and benchmarks it at 10-100x faster than pip.

Old tool uv equivalent
pyenv uv python install 3.13, uv python pin 3.12
python -m venv / virtualenv uv venv (or implicit: uv sync creates .venv)
pip install uv add (projects) or uv pip install (drop-in interface)
pip-tools (pip-compile, pip-sync) uv lock / uv sync, or uv pip compile / uv pip sync
Poetry uv init, uv add, uv lock, uv sync, uv build, uv publish
pipx uv tool install, uvx
twine uv publish

Install it once (standalone installer, Homebrew, or pipx install uv), then:

curl -LsSf https://astral.sh/uv/install.sh | sh   # macOS / Linux
uv self update                                    # upgrade a standalone install
uv --version

2. Projects: init, add, lock, sync, run

uv init myapp                 # application: pyproject.toml, main.py, .python-version, git repo
uv init --lib mylib           # library: src/ layout + build system
uv init --package mycli       # application that is installable (src/ layout + build system)
uv init --build-backend hatch mylib   # choose backend: hatch, flit, pdm, setuptools, ...

cd myapp
uv add httpx 'pydantic>=2.10'         # runtime deps -> [project.dependencies]
uv add --dev pytest ruff              # -> [dependency-groups] dev
uv add --group docs mkdocs-material   # -> [dependency-groups] docs
uv add --optional cli typer           # -> [project.optional-dependencies] cli (an extra)
uv remove httpx

uv lock                               # resolve and write uv.lock (cross-platform)
uv lock --upgrade-package pydantic    # bump one package
uv lock --upgrade                     # bump everything allowed by constraints
uv lock --check                       # CI: fail if uv.lock is out of date

uv sync                               # create/update .venv to match uv.lock exactly
uv sync --locked                      # error if uv.lock needs changes (CI)
uv sync --frozen                      # use uv.lock as is, do not check it
uv sync --no-dev                      # production install without the dev group
uv sync --all-groups                  # every dependency group
uv sync --only-group docs             # just one group

uv run pytest -q                      # run inside the project env (syncs first)
uv run --with rich python demo.py     # add a package for this run only
uv run --python 3.12 pytest           # run against another interpreter

uv tree                               # dependency tree
uv tree --outdated                    # what could be upgraded
uv export --no-hashes -o requirements.txt   # for tools that still need requirements.txt
uv build && uv publish                # sdist + wheel to dist/, then upload

The mental model:

File Owned by Commit it?
pyproject.toml You (uv edits it on add/remove) Yes
uv.lock uv. Never edit by hand Yes, for applications. Optional for libraries
.python-version uv python pin Yes
.venv/ uv. Disposable, recreate any time No

Two details that save time:

  • uv run keeps you in sync. It checks the lockfile and environment before every run, so "forgot to reinstall after pulling" bugs go away. You rarely need to activate .venv.
  • --locked vs --frozen. --locked asserts the lockfile matches pyproject.toml and fails otherwise: use it in CI. --frozen skips the check entirely: use it in Docker layers where you copy uv.lock before the source.

3. Python versions

uv python list                 # installed and available interpreters
uv python install 3.12 3.13    # managed builds (python-build-standalone)
uv python pin 3.12             # writes .python-version for this project
uv python find 3.13            # path to a matching interpreter
uv python uninstall 3.11

uv also downloads a missing interpreter automatically when a command needs one. requires-python in pyproject.toml defines which versions the lockfile must support; .python-version decides which one your local .venv uses.

4. Tools and uvx (the pipx replacement)

uvx ruff check .                  # run a CLI in a temporary, cached env
uvx --from httpie http GET example.com   # package name differs from command
uvx [email protected] --version         # a specific version
uv tool install pre-commit        # install permanently on PATH
uv tool list
uv tool upgrade --all
uv tool uninstall pre-commit

Rule of thumb: tools the project depends on (pytest, Ruff, mypy) go in a dependency group, so every developer and CI run uses the same version from uv.lock. Tools you use everywhere (pre-commit, httpie, cookiecutter) go in uv tool install.

5. Single-file scripts with inline metadata (PEP 723)

PEP 723 lets a script declare its own dependencies in a comment block. uv reads it, builds a cached environment and runs the script. This is the best answer I know to "how do I share a 100-line script that needs two packages?".

uv init --script fetch.py --python 3.12   # create a script with a metadata block
uv add --script fetch.py httpx rich       # add deps to the block
uv run fetch.py                           # run it; no venv, no requirements.txt
uv lock --script fetch.py                 # optional: write fetch.py.lock

The resulting file:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "httpx",
#     "rich",
# ]
# ///
import httpx
from rich import print

resp = httpx.get("https://pypi.org/pypi/uv/json", timeout=10)
print(f"Latest uv on PyPI: [bold]{resp.json()['info']['version']}[/bold]")

For reproducibility without a lockfile, add exclude-newer (an RFC 3339 timestamp) under [tool.uv] in the same block: uv ignores any package released after that date.

6. Workspaces (monorepos)

A workspace is several packages sharing one uv.lock and one .venv, in the style of Cargo workspaces.

# pyproject.toml at the repo root
[project]
name = "platform"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["core", "api"]

[tool.uv.workspace]
members = ["packages/*"]

[tool.uv.sources]
core = { workspace = true }
api = { workspace = true }
uv sync --all-packages              # install every member
uv run --package api pytest         # run in the context of one member
uv add --package api fastapi        # add a dep to one member
uv lock                             # one lockfile for the whole repo

Use a workspace when packages are developed together and can share one set of dependency versions. If they need conflicting versions, keep them as separate projects with path dependencies.

7. Ruff: lint and format

Ruff is one binary that replaces flake8 (plus many of its plugins), isort, pyupgrade, pydocstyle and Black-style formatting. It ships over 800 lint rules, and on large Black-formatted projects such as Django and Zulip its formatter produces identical output for more than 99.9% of lines.

ruff check .                  # lint
ruff check --fix .            # apply safe fixes
ruff check --unsafe-fixes --fix .   # also apply fixes that may change behavior; review the diff
ruff check --select I --fix . # sort imports only (isort replacement)
ruff check --statistics .     # count violations per rule (great before adopting a rule)
ruff check --add-noqa .       # add noqa comments to existing violations (adopt now, fix later)
ruff check --output-format=github .  # annotations in GitHub Actions
ruff rule B006                # explain a rule
ruff format .                 # format (Black-compatible)
ruff format --check --diff .  # CI: fail and show what would change

Run it through uv so everyone uses the locked version: uv run ruff check ., or without a project: uvx ruff check ..

Rule selection

Ruff's default is only ["E4", "E7", "E9", "F"]: pyflakes plus the pycodestyle errors that do not overlap with the formatter. That is a safe floor, not a recommendation. A practical starting set:

Prefix Source Catches
E, W pycodestyle Style errors and warnings (most are moot with the formatter)
F Pyflakes Unused imports and variables, undefined names
I isort Import order
UP pyupgrade Old syntax for your target Python (Optional[X] to X \| None)
B flake8-bugbear Likely bugs: mutable default args, except misuse
SIM flake8-simplify Needlessly complex code
C4 flake8-comprehensions Unnecessary list/dict calls
RUF Ruff-specific Ambiguous characters, unused noqa
S flake8-bandit Security smells (subprocess with shell=True)

Avoid select = ["ALL"] in an existing codebase: it enables conflicting and very opinionated rules, and new Ruff versions add rules to ALL, so upgrades break CI. Add prefixes one at a time, using --statistics to see the cost.

8. A pyproject.toml layout

One file for metadata, dependencies, build and tools. This is what uv init --lib plus a few additions looks like:

[project]                                  # PEP 621: standard metadata
name = "mylib"
version = "0.1.0"
description = "Does one thing well"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Your Name", email = "[email protected]" }]
dependencies = [                           # runtime, published with the package
    "httpx>=0.28",
    "pydantic>=2.10",
]

[project.optional-dependencies]            # extras: pip install mylib[cli]
cli = ["typer>=0.15"]

[project.scripts]                          # console entry points
mylib = "mylib.cli:app"

[dependency-groups]                        # PEP 735: dev-only, never published
dev = ["pytest>=8.3", "ruff>=0.11", "mypy>=1.15"]
docs = ["mkdocs-material>=9.6"]
lint = ["ruff>=0.11"]
ci = [{ include-group = "dev" }, { include-group = "docs" }]

[build-system]                             # how to build the wheel
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
default-groups = ["dev"]                   # groups that `uv sync` installs by default

[tool.ruff]
line-length = 100
target-version = "py311"                   # inferred from requires-python if omitted
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "W", "F", "I", "UP", "B", "SIM", "C4", "RUF"]
ignore = ["E501"]                          # line length: let the formatter handle it

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]                      # asserts are fine in tests

[tool.ruff.lint.isort]
known-first-party = ["mylib"]

[tool.ruff.format]
docstring-code-format = true               # format code examples in docstrings

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"

Key distinctions:

Section Standard Published to PyPI? Use for
[project] dependencies PEP 621 Yes What the package needs to run
[project.optional-dependencies] PEP 621 Yes (extras) Optional features users opt into
[dependency-groups] PEP 735 (accepted October 2024) No Dev, test, lint, docs. Supports include-group
[build-system] PEP 517/518 n/a Which backend builds the wheel
[tool.*] Tool-specific No Ruff, uv, pytest, mypy settings

Build backends: hatchling (uv's default for --lib/--package), setuptools (most compatible, needed for C extensions with legacy setups), flit-core (minimal, pure Python), pdm-backend, and maturin or scikit-build-core for Rust and C/C++ extensions. Applications that are never built into a wheel do not need a [build-system] at all; that is what plain uv init produces.

9. Migration cheatsheet

From To How
requirements.txt + venv uv project uv init --bare, then uv add -r requirements.txt and uv add --dev -r requirements-dev.txt. Delete the old files once CI is green
Keep requirements.txt workflow uv pip Prefix your commands: uv venv, uv pip install -r requirements.txt, uv pip compile requirements.in -o requirements.txt. Zero project changes
pip-tools uv uv pip compile / uv pip sync as drop-ins, or move to uv lock / uv sync
Poetry uv uvx migrate-to-uv (third-party tool, also handles Pipenv) converts [tool.poetry] to [project] and dependency groups. Then uv lock and review the diff. Check version constraints: Poetry's ^1.2 becomes >=1.2,<2
pyenv uv uv python install and uv python pin. Keep .python-version, uv reads it
pipx uv uv tool install <pkg> for each tool from pipx list
Black ruff format Remove [tool.black], set line-length in [tool.ruff], run once and commit the diff as a single "format" commit (add it to .git-blame-ignore-revs)
isort Ruff I rules Add "I" to select, move known_first_party to [tool.ruff.lint.isort]
flake8 + plugins ruff check Map each plugin to its prefix (bugbear to B, comprehensions to C4, ...). Port .flake8 ignores to ignore and per-file-ignores

Migrate in separate commits: uv first, then formatting, then lint rules one prefix at a time.

10. CI: GitHub Actions with setup-uv

name: ci
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5
        with:
          version: "0.6.10"                 # pin; bump deliberately
          enable-cache: true                # caches keyed on uv.lock
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: uv sync --locked --all-groups

      - name: Lint
        run: |
          uv run ruff check --output-format=github .
          uv run ruff format --check .

      - name: Test
        run: uv run pytest

Notes:

  • --locked makes CI fail if someone edited pyproject.toml without updating uv.lock.
  • python-version in setup-uv sets UV_PYTHON, so uv uses (and downloads if needed) that interpreter. No separate setup-python step is required.
  • The cache is keyed on uv.lock by default, so it only refreshes when dependencies change.

For Docker, the same idea: copy pyproject.toml and uv.lock first, run uv sync --frozen --no-dev --no-install-project, then copy the source and run uv sync --frozen --no-dev. Dependencies stay in a cached layer.

11. pre-commit

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.11.2
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/astral-sh/uv-pre-commit
    rev: 0.6.10
    hooks:
      - id: uv-lock          # keep uv.lock in sync with pyproject.toml

Install with uv tool install pre-commit and pre-commit install. Keep the ruff hook (with --fix) before ruff-format, as recommended by Ruff, so fixes are formatted in the same run. Keep the hook rev and the Ruff version in your dev group aligned, or editors and CI will disagree.

12. Daily commands

I want to... Command
Start a project uv init name
Add / remove a dependency uv add pkg / uv remove pkg
Add a dev tool uv add --dev pkg
Install everything after git pull uv sync (or just uv run ...)
Run tests uv run pytest
Upgrade one package uv lock --upgrade-package pkg && uv sync
Try a package without installing uv run --with pkg python
Lint, fix and format uv run ruff check --fix . && uv run ruff format .
Clear the cache uv cache clean

Why this matters

  • Fewer tools, fewer failure modes. Two binaries and one config file replace roughly ten tools and five config files.
  • Speed changes habits. When installs take a second and lint takes milliseconds, people run them constantly, and CI gets cheaper.
  • Standards over lock-in. PEP 621 metadata, PEP 735 groups and PEP 723 scripts are not uv-specific. If you leave uv, the pyproject.toml still means the same thing.
  • Reproducibility by default. A committed uv.lock, uv sync --locked in CI and pinned tool versions make "works on my machine" rare.

Final Thoughts

The Python packaging story used to be the part of the language that needed the longest explanation. With uv and Ruff it fits in a cheatsheet: uv init, uv add, uv run, ruff check --fix, ruff format, and a lockfile in git.

Both tools are young and move fast, so treat versions as something you pin and upgrade on purpose. But the shape of the workflow, standards-based pyproject.toml, a lockfile and a single fast toolchain, is where Python tooling has clearly settled. If you are starting a project this week, start there. If you are maintaining an old one, start with uv pip and ruff format and migrate the rest one commit at a time.

Sources