Ruff is the Rust-based linter that replaced flake8, isort, pyupgrade, pylint partial, and most plugins in one binary that runs 100x faster than the original tools. Ruff also has a formatter — and the question of “Black or Ruff format?” is now a real decision. This article wires both for the case where you keep Black as the formatter and Ruff as the linter (the conservative split), and shows how to move to Ruff-only formatting if you’re starting fresh.
How to verify
Check what’s installed, what’s configured, and what fails:
ruff --version && black --version
ruff check --select ALL . | head -40
black --check --diff . | head -40
ls .pre-commit-config.yaml pyproject.toml ruff.toml 2>/dev/null
grep -E "^\[tool\.(black|ruff)" pyproject.toml 2>/dev/null
If ruff check --select ALL returns 5000 findings on day one, that’s normal — Ruff enables every rule by default with ALL. Pick a subset (see below).
What’s happening
Black is a Python formatter with one knob: line length. It rewrites your code to a canonical form — quote style, parentheses, line breaks — and refuses configuration beyond that. The result is that PRs never include “I prefer single quotes” debates.
Ruff is a Rust-rewrite of flake8 plus most of its plugin ecosystem (isort, pyupgrade, bugbear, eradicate, pydocstyle, mccabe) plus a formatter compatible with Black’s output. Where flake8 took seconds to a minute on a large repo, Ruff takes milliseconds. That speed lets you run it on every save in the editor, not just in CI.
The two split cleanly. Black formats: it physically rewrites the source. Ruff lints: it reports issues, and with --fix it can auto-correct a subset (unused imports, mutable default arguments, isort-style import sorting). Running both gives you canonical formatting plus a broad lint sweep. Ruff’s formatter is Black-compatible — if you want one tool, drop Black and use ruff format instead. Both produce identical output for the cases where they overlap.
The procedure
-
Install both into the project venv (or as
devdependencies):/opt/venvs/app/bin/pip install black ruff pre-commit -
Configure both in
pyproject.toml. Match line length:[tool.black] line-length = 100 target-version = ["py312"] exclude = '/(\.venv|migrations)/' [tool.ruff] line-length = 100 target-version = "py312" src = ["src", "tests"] [tool.ruff.lint] select = [ "E", "W", # pycodestyle errors and warnings "F", # pyflakes "I", # isort "B", # flake8-bugbear "UP", # pyupgrade "C4", # flake8-comprehensions "SIM", # flake8-simplify "RUF", # Ruff-specific ] ignore = [ "E501", # line length — Black handles this ] per-file-ignores = { "tests/*" = ["S101"] } # asserts ok in tests [tool.ruff.lint.isort] known-first-party = ["app"]The
select-then-ignorepattern is more maintainable than enablingALLand adding 50 ignores. -
Add a pre-commit config so commits can’t include unformatted code:
# .pre-commit-config.yaml repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.5.0 hooks: - id: ruff args: [--fix] - id: ruff-format # remove this if keeping Black - repo: https://github.com/psf/black-pre-commit-mirror rev: 24.4.2 hooks: - id: blackInstall once per repo:
pre-commit install. -
Wire to CI as a non-skippable gate:
# .github/workflows/lint.yml - name: Ruff run: | pip install ruff==0.5.0 ruff check --output-format=github . ruff format --check . - name: Black run: | pip install black==24.4.2 black --check --diff .Pin both tools — formatter version drift causes diffs that look real but aren’t.
-
To move from Black to Ruff-only formatting (one tool instead of two):
ruff format . # apply Ruff format pip uninstall black # remove [tool.black] from pyproject.toml # remove black hook from .pre-commit-config.yaml git diff --stat # check the visual delta is emptyRuff’s formatter matches Black’s output for the vast majority of code. The few edge cases (chained method calls, magic trailing commas) differ slightly. Format the whole repo, eyeball the diff, then commit.
-
For incremental adoption on a legacy codebase, lint only changed files:
git diff --name-only origin/main...HEAD | grep '\.py$' | xargs ruff checkOr use
ruff check --add-noqato insert# noqaon every existing finding, then enforce “no new noqa” in PRs.
Common pitfalls
- Configuring Black for one line length and Ruff for another — every
ruff formatundoes Black’s work. Matchline-lengthexactly. - Enabling
select = ["ALL"]withoutignore— Ruff has hundreds of rules; the result is unactionable. Pick categories that match your style. - Running
ruff check --fixin CI — the CI shouldn’t modify the tree. Useruff check(no fix) for the gate,ruff check --fixfor pre-commit. - Skipping
pre-commit installon fresh clones — hooks don’t run unless installed in each clone. Add a check in CONTRIBUTING.md and inmake install. - Ignoring
RUFrules — the Ruff-specific category catches real bugs (mutable default args, unused exception variables) that flake8 missed.
For the type-checking layer that pairs with lint and format, see Running mypy in strict mode in production. Stack Harbor’s managed operations practice runs Black + Ruff on new client projects and migrates existing flake8/isort/pyupgrade stacks to Ruff incrementally — the CI speedup typically saves 30-90 seconds per push, which compounds across a team.