Ruff vs Black: Which Python Formatter Should You Use?
Ruff’s formatter targets Black’s output, and most of the time it hits. Most of the time is not all of the time. Reformatting a fully Black-formatted tree with ruff format changed 117 of 877 files across Flask, Rich, and pip.
Ruff is the better choice for new projects, and for existing ones the only real question is what the switch costs. That cost is the size of the first reformat commit.
One clarification before the comparison. Ruff is a linter and a formatter in one binary, and Black is only a formatter, so half the pages ranking for this query compare the wrong things. The formatter comparison is ruff format against black. The linter comparison is a separate question, covered in Ruff vs flake8.
What actually changes in your diff?
Formatting three codebases with Black 26.5.1, then running ruff format 0.16.3 over the result, isolates the deviations from everything else. Both tools ran on defaults, with each project’s own configuration removed:
| Codebase | Files | Changed by ruff format |
|
|---|---|---|---|
| Flask | 65 | 3 | 4.6% |
| Rich | 167 | 14 | 8.4% |
| pip | 645 | 100 | 15.5% |
| Total | 877 | 117 | 13.3% |
An M4 Mac and a 4-vCPU Debian container produced identical counts. The spread across projects tracks how much long, dense code each one has: pip’s _vendor directory diverges at the same rate as its first-party code (62 of 393 files), so vendored third-party style is not what drives the number.
Four constructs account for the differences readers hit most often.
Long assert statements are the largest cause, appearing in 27 of the 117 changed files. Black splits the condition; Ruff parenthesizes the message:
# Black
assert (
bool(static_host) == host_matching
), "Invalid static_host/host_matching combination"
# ruff format
assert bool(static_host) == host_matching, (
"Invalid static_host/host_matching combination"
)Implicit string concatenation shows up in 15 changed files. Ruff joins adjacent literals when the result fits; Black leaves the split in place:
# Black
msg = "Defaulting to user installation because normal site-packages " "is not writeable"
# ruff format
msg = "Defaulting to user installation because normal site-packages is not writeable"f-strings account for 13 changed files, in two ways. Ruff formats expressions inside replacement fields, turning f"\x1b[{param+1}G" into f"\x1b[{param + 1}G". It also normalizes the outer quote to double and flips the inner quotes to match, which Black declines to do:
# Black
name = f'{getattr(type_, "__module__", "")}.{getattr(type_, "__qualname__", "")}'
# ruff format
name = f"{getattr(type_, '__module__', '')}.{getattr(type_, '__qualname__', '')}"Pragma comments change 7 files. Black counts # noqa, # type: ignore, and # pyright: ignore toward the line-length budget and splits the code to make room; Ruff excludes them from the measurement and lets the comment overflow:
# Black
request.host = get_host(
request.environ, request.trusted_hosts
) # pyright: ignore
# ruff format
request.host = get_host(request.environ, request.trusted_hosts) # pyright: ignoreThe remaining 63 files differ in where each tool breaks a long line, with no single pattern behind them. Astral publishes the full list in Known deviations from Black; the four above are the ones that produce most of the churn on real code.
None of these deviations is a bug in either tool, and none of them is configurable away. Ruff’s formatter is stable, deterministic, and self-consistent. The reformat is a one-time cost, not a recurring one.
Which one is faster?
On pip’s codebase (645 files, about 194,000 lines), running each tool in check mode with its cache disabled:
| Tool | M4 Mac (10 cores) | Debian container (4 vCPUs) |
|---|---|---|
| Black 26.5.1 | 2.61s | 2.99s |
ruff format 0.16.3 |
0.04s | 0.086s |
ruff format (warm cache) |
0.02s | 0.077s |
That is 65x on the Mac and 35x on the container. Black’s own cache narrows the gap on unchanged files, which is why both tools were measured cold.
Speed decides where formatting can run. A three-second check is something you schedule; a forty-millisecond one is something you attach to every file save and every pre-commit hook without noticing it.
Is Black still maintained?
Yes. Black released six versions between January 2025 and May 2026, most recently 26.5.1, and its changelog carries active work on t-strings and PEP 695 type parameter syntax.
Black also keeps a promise Ruff does not make explicitly: a yearly stable style, where code formatted in a given calendar year stays unchanged across every release from that year. Style changes accumulate behind --preview and graduate the following January.
A maintained tool is not automatically the right tool. Black does one job that Ruff does faster while also doing three others.
What does Ruff do that Black does not?
Black formats. Ruff formats, lints, and sorts imports from one binary and one configuration table, which outlasts any speed benchmark as a reason to switch.
A project running Black, isort, and flake8 pins three dependencies and keeps three configurations agreeing about line length. Ruff collapses that into [tool.ruff] in pyproject.toml. The E203-in-extend-ignore dance that exists only to stop flake8 fighting Black disappears, because one tool owns both decisions.
Ruff also fixes what it finds, which Black never does: ruff check --fix removes unused imports, sorts imports with the I rules, and rewrites outdated syntax with UP.
When should you stay on Black?
Three situations justify it:
- A reformat you cannot schedule. Changing 13% of files conflicts with every open branch. On a repository with dozens of long-lived feature branches, that means rebasing all of them, so the answer is to wait for a quiet window rather than to skip the migration.
- A Black unstable feature you depend on. Black’s
--enable-unstable-featureexposes named experiments such asstring_processingandhug_parens_with_braces_and_square_brackets. Ruff’s preview mode has no per-feature equivalent, so a project pinned to one of these has nothing to switch to. - Tooling that speaks to
blackd. Black ships an HTTP formatting daemon. Ruff offers a language server (ruff server) but no HTTP API, so an internal service callingblackdneeds rewriting first.
Everything else is inertia. A repository already formatted with Black is the easiest possible migration, because the reformat diff is small and mechanical by construction.
How do you switch?
Add Ruff, translate [tool.black] into [tool.ruff] and [tool.ruff.format], run ruff format ., and commit the result on its own so .git-blame-ignore-revs can hide it from git blame. Ruff matches Black’s defaults for line length, indentation, and quote style, so most projects carry over two or three keys.
Review that first diff rather than rubber-stamping it. Anything surprising is one of the deviations covered here, and a # fmt: off block will pin the few spots you want left alone.
How to migrate from Black to the Ruff formatter has the full configuration mapping, the pre-commit swap, and the CI changes.
Learn More
- Ruff and Black reference pages
- Ruff: a complete guide covers rule categories and configuration in depth
- How to replace Black, isort, flake8, and pyupgrade with Ruff consolidates the whole lint stack
- How to disable the Ruff formatter for a block of code for the
# fmt: offescape hatch - Set up Ruff for formatting and checking your code (Tutorial)
- Known deviations from Black lists all documented differences
- The Black code style documents Black’s own rules