Ruff 0.6 Replaced Black and isort in Our Monorepo Last Week
The switch took one afternoon and one follow-up PR. A 180-package Python monorepo that had been running black, isort, and flake8 on every pre-commit hook now runs a single ruff check and ruff format pair, and the pre-commit wall time dropped from the 20-30 second range down to under two seconds on a warm cache. If you have been waiting for a reason to pull the trigger on ruff replace black isort monorepo, Ruff 0.6 is that reason: the formatter is stable, the Black parity work is finished for the cases real code actually hits, and the isort implementation has been default-on for long enough that the edge cases are well-documented.
This is a walkthrough of how we moved from a three-tool stack to one, what we changed in pyproject.toml, which Ruff rules we turned on that we did not have before, and the two places where Ruff’s formatter diverges from Black in ways you should know about before you run it across hundreds of files.
Why we stopped running Black, isort, and flake8 separately
The old pre-commit chain looked like this: isort sorted imports, black reformatted, then flake8 with a dozen plugins checked for lint violations. Each tool parsed every changed file independently. On a fresh clone with no pre-commit cache, a full-repo run took well over a minute on our CI runners, and developers complained that pre-commit run --all-files on their laptops was slow enough that they skipped it.
Ruff is written in Rust and parses each file once for both linting and formatting. The Ruff formatter documentation states that the formatter is “over 30x faster than Black” and is a drop-in replacement for the vast majority of Black-formatted code. That 30x figure matches what we saw in practice — not because our files are special, but because the Rust parser avoids Python startup cost on every invocation, which dominates the runtime of small incremental runs.

The screenshot shows the official docs.astral.sh/ruff/formatter page with the Black-compatibility section visible — specifically the paragraph noting that Ruff’s formatter intentionally matches Black’s output for all but a handful of deliberately-documented cases. That page is the canonical reference to send a skeptical reviewer when they ask whether the diff they are seeing is a bug or a known divergence.
The actual pyproject.toml change
Here is the section of pyproject.toml we ended up with. It replaces three separate config blocks ([tool.black], [tool.isort], and [tool.flake8], the last of which lived in setup.cfg) with one:
[tool.ruff]
line-length = 100
target-version = "py311"
extend-exclude = ["vendor", "generated"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
"RUF", # Ruff-specific rules
]
ignore = ["E501"] # handled by the formatter
[tool.ruff.lint.isort]
known-first-party = ["acme", "acme_internal"]
combine-as-imports = true
split-on-trailing-comma = true
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
Two things matter here. First, I in the select list enables the isort rules, and the [tool.ruff.lint.isort] table lets you configure first-party packages the same way the standalone isort tool did. Our old .isort.cfg had a known_first_party list of 14 internal namespaces; we ported them one-to-one and the resulting import order on a ruff check --fix pass matched the previous state for all but three files, which had been hand-edited in ways the old isort tolerated.
Second, E501 (line too long) is in ignore because the formatter handles it. If you leave E501 on with the formatter also running, you will get noisy warnings on lines that Ruff has deliberately decided not to wrap — usually long string literals or URLs — and it becomes a game of noqa whack-a-mole.
Running it across 180 packages without breaking history
The formatting sweep was a single commit, authored from a branch that did nothing else. The command was:
ruff check --fix --unsafe-fixes .
ruff format .
git add -A
git commit -m "chore: migrate to Ruff 0.6 for lint + format"
The --unsafe-fixes flag applies rule fixes Ruff classifies as potentially behavior-changing (for example, some SIM rewrites that collapse conditional expressions). On a monorepo I would normally avoid that flag, but because we had just enabled those rule categories for the first time, we wanted the one-shot cleanup. Every fix goes through code review anyway.
Add the commit SHA to .git-blame-ignore-revs so git blame skips the mechanical reformat:
echo "a1b2c3d4e5f6 # Ruff 0.6 migration" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
GitHub honors this file automatically in the web UI, so anyone looking at blame in a PR review will see the real author of a line rather than the migration commit. This is the same pattern the Ruff docs recommend for formatter rollouts, and it is the single most useful thing you can do to avoid pissing off teammates whose history suddenly vanishes.
Pre-commit, CI, and editor integration
The .pre-commit-config.yaml shrank considerably. The previous file had three separate hooks; the new one is:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
The ruff-pre-commit repository is the official mirror Astral maintains; pinning it by tag rather than by SHA is fine here because the tag is immutable. The --exit-non-zero-on-fix flag is the one I always forget — without it, pre-commit will silently apply fixes and let the commit succeed, which is confusing when CI then fails on the unpushed delta.
For CI we just run the same two commands in a GitHub Actions job. Because Ruff has no Python dependency chain of its own (Astral ships it as a standalone binary via pip install ruff, but the Rust binary does not import anything from the target project), the CI step needs no virtualenv and no dependency install. On our runners this cut the lint job from roughly 45 seconds to about 4.

The benchmark chart makes the gap obvious: the black + isort + flake8 bar sits around 28 seconds on the monorepo, while the ruff check + ruff format bar is a thin sliver near 1.5 seconds. That is not a micro-optimization — it is the difference between a pre-commit hook people bypass and one they do not notice.
Editor integration on the VS Code side is the Ruff VS Code extension, which bundles the same binary as the CLI. Set "editor.defaultFormatter": "charliermarsh.ruff" and "editor.formatOnSave": true in the workspace settings and you can uninstall the Black and isort extensions entirely.
The two places Ruff’s formatter is not exactly Black
Ruff documents its deliberate deviations from Black on a dedicated Black compatibility page. The two that actually showed up in our diff:
First, Ruff formats docstrings by default when docstring-code-format is enabled, which Black does not do. If you have doctest-style code inside your docstrings, Ruff will re-indent and reformat the embedded Python. We left this off for the initial migration — you enable it explicitly under [tool.ruff.format] — because we have a few hundred Sphinx doctest blocks and did not want to audit them all on day one.
Second, Ruff’s handling of a single edge case around match statements with parenthesized patterns differs from Black in a way the compatibility page spells out. We had exactly four files this touched, all in one service, and the resulting diff was cosmetic. Worth reviewing, not worth blocking the migration.
What new rules caught on the first run
Because we added B (bugbear), UP (pyupgrade), and SIM (simplify) as part of the switch, the first ruff check found real issues that had been sitting in the codebase for years. The most valuable category was B008, function-call default arguments in function signatures — the classic def f(x=[]): bug — which turned up in three places, all of them latent. UP032 rewrote a couple hundred old .format() calls to f-strings. SIM108 collapsed some if/else assignments into ternaries.
None of this was exciting individually, but none of it was noise either. The general rule: if you are already doing the migration, turn on at least B and UP at the same time. The marginal cost of reviewing the extra fixes is near zero when you are already reviewing a big formatter diff, and you will never have a better opportunity to apply them as a single commit.

The topic diagram shows the tool consolidation as a funnel: three boxes on the left (Black, isort, flake8 with its plugin subtree) collapse into one box on the right labeled ruff, with the inputs (pyproject.toml, .py files) and outputs (formatted files, lint report) unchanged. The diagram is a useful thing to paste into a migration RFC because it makes clear that nothing downstream of the lint step needs to change — CI artifacts, coverage reports, and test runners all see the same file tree they did before.
Things I would do differently next time
Two regrets, both small. The first is that I should have split the formatter sweep and the new-rule-fix sweep into two commits. We did them together because it was convenient, and it meant that the new-rule fixes were harder to review inside the noise of the formatter diff. A separate commit for ruff format . and a separate commit for ruff check --fix --unsafe-fixes . would have been cleaner for history, and .git-blame-ignore-revs handles multiple entries just fine.
The second is that we did not set required-version in [tool.ruff]. That field pins a minimum Ruff version and errors out if a developer runs an older binary. Without it, we had one day where a teammate on a stale pipx-installed ruff 0.4 committed files that the 0.6 CI then rejected, because a rule that 0.4 did not know about had flagged them locally as clean. Add required-version = "0.6.0" and the problem goes away.
If you are running Black and isort today on a codebase of any meaningful size, the ruff replace black isort monorepo migration is effectively a free speed upgrade with one afternoon of work and one review-heavy PR. Pin the version in both pyproject.toml and .pre-commit-config.yaml, add the migration commit to .git-blame-ignore-revs before you merge, and turn on at least the I, B, and UP rule categories on the same pass so you only pay the review cost once.
References
- Ruff formatter documentation — canonical reference for the formatter’s behavior and its Black compatibility claims, cited for the 30x speed and drop-in-replacement positioning.
- Ruff’s Black compatibility page — documents the specific, intentional deviations from Black (docstring code formatting, match statement edge cases) referenced in the “not exactly Black” section.
- Ruff rules index — the authoritative list of rule categories including
I(isort),B(bugbear), andUP(pyupgrade) used in the pyproject.toml example. - astral-sh/ruff-pre-commit — official pre-commit hook repository, cited for the
.pre-commit-config.yamlsnippet and tag pinning guidance. - astral-sh/ruff on GitHub — main repository, source of release notes and the canonical place to check
required-versionsemantics and changelog entries between 0.4 and 0.6. - Ruff configuration reference — covers
[tool.ruff],[tool.ruff.lint], and[tool.ruff.format]table options used throughout the migration config.
