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 runkeeps 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.--lockedvs--frozen.--lockedasserts the lockfile matchespyproject.tomland fails otherwise: use it in CI.--frozenskips the check entirely: use it in Docker layers where you copyuv.lockbefore 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:
--lockedmakes CI fail if someone editedpyproject.tomlwithout updatinguv.lock.python-versionin setup-uv setsUV_PYTHON, so uv uses (and downloads if needed) that interpreter. No separate setup-python step is required.- The cache is keyed on
uv.lockby 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.tomlstill means the same thing. - Reproducibility by default. A committed
uv.lock,uv sync --lockedin 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
- Astral, uv documentation and CLI reference: commands and flags (
init,add,lock,sync,run,python,tool,export,tree, workspaces, scripts). Checked against the uv 0.6.10 tag. - Astral, uv releases on GitHub: uv 0.6.10, released March 26, 2025; README claims (replaces pip, pip-tools, pipx, Poetry, pyenv, twine, virtualenv; 10-100x faster than pip).
- Astral, Ruff documentation: configuration, default rule set
["E4", "E7", "E9", "F"], over 800 rules, and formatter compatibility with Black (over 99.9% identical lines on Django and Zulip). - Astral, Ruff releases on GitHub: Ruff 0.11.2, released March 21, 2025.
- Astral, setup-uv (v5.4.0, March 21, 2025):
version,enable-cache,python-versioninputs. Using uv in GitHub Actions. - Astral, ruff-pre-commit and uv-pre-commit: hook IDs
ruff,ruff-format,uv-lock. - PEP 621: Storing project metadata in pyproject.toml (2020).
- PEP 723: Inline script metadata (accepted January 2024).
- PEP 735: Dependency Groups in pyproject.toml (accepted October 10, 2024):
[dependency-groups]andinclude-group. - PEP 517 and PEP 518: build backends and
[build-system]. - Mathieu Kniewallner, migrate-to-uv (0.7.2, March 25, 2025): migration from Poetry and Pipenv to uv.