The 2025 toolchain: uv for environments and locking, ruff for lint and format, one pyproject.toml
uv collapses pip, venv, pipx and pip-tools into one Rust binary: a fast resolver, a universal lockfile, uv run and uvx. ruff replaces flake8, isort and black with one pass. pyproject.toml holds every config, and migration from legacy setups is incremental, not a rewrite.
The CI pipeline took fourteen minutes, and six of them were pip install. Another ninety seconds went to the lint stage, where flake8, isort, and black ran in sequence — and twice a month they fought: black would rewrap an import block, isort would “fix” it back, and the build failed on a diff no human wrote. Onboarding was worse. The README’s environment setup had eleven steps, two of them wrong since the Python 3.11 bump; new hires lost their first day to pip resolving for forty minutes and then failing on a transitive pin conflict that requirements.txt had no way to express. The migration took one engineer two days: uv sync restored the locked environment in eight seconds on a warm cache, uv run pytest made “works on my machine” mean the lockfile or nothing, and ruff check && ruff format replaced three quarreling tools with one pass that finished before the old stack had imported its plugins. The pipeline now runs in under five minutes; nobody has debugged a venv since.
uv: one binary where five tools used to be
uv (Astral, written in Rust) collapses pip, venv, pip-tools, pipx, and most of pyenv into one binary. The core loop: uv add httpx writes the dependency into pyproject.toml, resolves the full graph, and pins everything in uv.lock; uv sync makes the project’s .venv match the lockfile exactly — removing strays, not just adding; uv run pytest guarantees the command executes in that synced environment, creating it on the fly if a teammate just cloned the repo. uvx ruff runs a tool in an isolated, cached environment — pipx without the second tool. uv python install 3.12 even manages interpreters. Two properties do the heavy lifting. First, speed: the resolver is 10–100× faster than pip with a warm cache (Astral’s published benchmarks; cold installs are nearer 8–10×), which turns “resolve the whole graph from scratch” from a coffee break into a blink — that is what makes always-resolving-from-pyproject practical. Second, the lockfile: uv.lock is universal — resolved once for all platforms and Python versions, with hashes — so the Linux CI box and the macOS laptop install byte-identical graphs. The honest caveats: uv.lock is uv’s own format (a standard, PEP 751’s pylock.toml, is only now emerging), and the tool is young and VC-backed — but because all inputs live in standard pyproject.toml, the exit path back to pip is real.
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx"]
# ///
# PEP 723 inline metadata: `uv run fetch.py` builds this one-off
# environment on demand — no requirements.txt for a single script.
import httpx
print(httpx.get("https://example.com").status_code)A teammate clones the repo and immediately runs uv run pytest with no setup. What happens?
ruff: lint and format in one pass
When lint lives in CI and takes 90 seconds, engineers stop caring about it. When it runs in under a second on every save, it becomes the same kind of instant feedback as a syntax error — and that changes which bugs get caught before review. That is the core argument for ruff.
ruff is the same consolidation applied to code quality: one Rust binary that re-implements flake8 (plus most of its popular plugins), isort, pyupgrade, and a large slice of pylint as ruff check, and a black-compatible formatter as ruff format (Astral measures the formatter as >99.9% identical to black on black’s own test corpus). Rules are opt-in by family in pyproject.toml: E/F (pycodestyle/pyflakes) as the baseline, I for import sorting, B for flake8-bugbear’s likely-bugs, UP for outdated syntax, with --fix auto-applying safe corrections. Speed is the headline number and it is not marketing: ruff lints CPython-scale codebases in well under a second where the flake8 stack took tens of seconds — fast enough to run the entire rule set on every save, which changes behavior: lint stops being a CI-only nag and becomes feedback you see while typing. The consolidation also ends the tri-tool fights from the Hook, because one tool with one config cannot disagree with itself about import wrapping. Tradeoffs to state: a residue of plugin-specific flake8 rules and some pylint checks have no ruff equivalent, and ruff intentionally does not type-check — it complements, never replaces, mypy/pyright.
pyproject.toml as the single config, and the migration path
Everything above reads its configuration from one file:
[project]
name = "billing"
requires-python = ">=3.12"
dependencies = ["httpx>=0.27", "pydantic>=2.7"]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.uv]
dev-dependencies = ["pytest>=8", "mypy>=1.10"]One file replaces requirements.txt, requirements-dev.txt, setup.cfg, .flake8, .isort.cfg, and pyproject’s old black section. Migration from a legacy repo is incremental, and the order matters: (1) adopt ruff first — it is config-only, zero runtime risk; start with E,F, delete flake8/isort/black configs, expand rule families one PR at a time so the diff stays reviewable. (2) Use uv as a faster pip without changing workflow: uv pip install -r requirements.txt is a drop-in that proves the speed claim on your own repo. (3) Move dependencies into pyproject.toml with uv add, commit uv.lock, switch CI to uv sync --frozen so the build fails if the lockfile is stale instead of silently re-resolving. (4) Replace global tool installs with uvx. The anti-pattern is doing it as one big-bang PR that swaps packaging, linting, and formatting simultaneously — when something breaks, you cannot tell which layer did it.
How does committing uv.lock differ from committing a pip freeze > requirements.txt snapshot?
- 01Walk through what uv add, uv sync, uv run, and uvx each do, and what makes uv.lock different from a pip freeze snapshot.
- 02What is the safe migration order from pip+flake8+isort+black, and why is the big-bang swap an anti-pattern?
The 2025 Python toolchain is a consolidation story. uv replaces pip, venv, pip-tools, pipx, and much of pyenv with one Rust binary: uv add resolves and pins into a universal, hash-verified uv.lock; uv sync makes the environment match it exactly; uv run guarantees commands execute inside that environment, building it on demand from a fresh clone; uvx runs tools in isolated caches; uv python install manages interpreters. Resolution runs 10–100× faster than pip on a warm cache, which is what makes lockfile-first workflows feel free, and the lockfile is resolved once for every platform — the end of “resolves differently on the CI box.” ruff applies the same consolidation to quality: ruff check re-implements flake8 and its plugin ecosystem, isort, and pyupgrade; ruff format is black-compatible to >99.9% on black’s own corpus; one pass finishes in well under a second at CPython scale, fast enough to move linting from CI nag to on-save feedback, and one config ends the black-vs-isort wars. Everything reads pyproject.toml — dependencies, rule selection, line length — replacing a half-dozen dotfiles. Migrate in risk order: ruff (config-only), uv as drop-in pip, then pyproject.toml plus committed lockfile with uv sync --frozen in CI, then uvx; never as one big-bang PR, because the first failure becomes unattributable. Caveats stay caveats: uv.lock is tool-specific until PEP 751 matures, ruff does not type-check, and a few flake8/pylint rules have no equivalent yet. Now when you see a CI pipeline that spends six minutes on pip install or a lint stage that fights itself on import ordering, you know the exact four-step migration that fixes it — and the exact reason to start with ruff, not with the lockfile.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.