Ruff 0.8 Replaced Black, isort, and Flake8 in Our Monorepo
12 mins read

Ruff 0.8 Replaced Black, isort, and Flake8 in Our Monorepo

A single ruff check --fix and ruff format pair now covers what used to be three separate pre-commit hooks: Black for formatting, isort for import ordering, and Flake8 (plus half a dozen plugins) for linting. The decision to ruff replace black isort flake8 across a Python monorepo is not controversial anymore — Ruff 0.8 ships a stable formatter, import sorter, and more than 800 lint rules out of a single Rust binary, and the speed gap versus the older toolchain is wide enough that the old stack is hard to justify.

This guide walks through the concrete migration: the pyproject.toml sections you actually need, the rules that map one-to-one from Flake8 plugins, the handful of behavioural differences that will bite you in CI, and the pre-commit configuration that replaces three hooks with one. The code below is what you’d paste into a real repo, not pseudocode.

Why one binary beats three

Black, isort, and Flake8 are each good at one thing. The cost is that every file gets parsed three times, every virtualenv pins three tools, every pre-commit run pays three startup costs, and every CI job installs three packages. Ruff, written in Rust by Astral, parses each file once and runs the linter and formatter against a shared AST. The Ruff formatter documentation explicitly targets Black-compatible output: same line length defaults, same string quote handling, same trailing comma behaviour, with a small list of intentional divergences documented on that page.

The practical effect on a mid-sized monorepo is that the pre-commit stage drops from tens of seconds to well under a second for incremental runs, and full-repo format checks that used to be measured in minutes finish in single-digit seconds. You are not chasing a micro-optimisation — you are removing an entire class of “waiting for lint” interruptions from the inner loop.

Official documentation for ruff replace black isort flake8
Official documentation — the primary source for this topic.

The screenshot above is the canonical landing page at docs.astral.sh/ruff, showing the top-level navigation split into Linter, Formatter, Rules, Settings, and Integrations. That layout matters because it maps directly onto how you configure Ruff in pyproject.toml: the [tool.ruff.lint] table controls what Flake8 used to do, [tool.ruff.format] controls what Black used to do, and [tool.ruff.lint.isort] controls the import-sorting behaviour that isort owned. Once you know which page goes with which table, the migration stops being guesswork.

The pyproject.toml that replaces three configs

Here is a minimal configuration that gives you Black-equivalent formatting, isort-equivalent import ordering, and a Flake8-equivalent rule set including pyflakes, pycodestyle, pep8-naming, flake8-bugbear, flake8-comprehensions, and flake8-simplify:

[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["migrations", "vendor"]

[tool.ruff.lint]
select = [
    "E", "W",   # pycodestyle
    "F",        # pyflakes
    "I",        # isort
    "N",        # pep8-naming
    "B",        # flake8-bugbear
    "C4",       # flake8-comprehensions
    "SIM",      # flake8-simplify
    "UP",       # pyupgrade
    "RUF",      # ruff-specific
]
ignore = ["E501"]  # line length handled by the formatter
fixable = ["ALL"]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "D"]
"__init__.py" = ["F401"]

[tool.ruff.lint.isort]
known-first-party = ["myapp", "mylib"]
combine-as-imports = true
force-sort-within-sections = true

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"

A few things are worth pointing out. The E501 ignore is deliberate: the formatter already wraps at line-length, so letting the linter also shout about long lines produces duplicate diagnostics on URLs and strings the formatter cannot break. The I selector enables the isort replacement; you do not install anything extra. And fixable = ["ALL"] means ruff check --fix will rewrite anything it can safely rewrite — unused imports, f-string conversions, sorted imports, modernised syntax — in one pass.

Mapping Flake8 plugins to Ruff rule codes

The migration step that trips people up is realising their Flake8 config is really a list of plugins, each with its own rule prefix. Ruff implements most of them natively, so you do not need a compatibility shim. The Ruff rules reference lists every supported rule code with its upstream origin and a short description; search that page for the plugin name and you will find the corresponding code. A short mapping for the plugins that show up on almost every repo:

  • pyflakesF; pycodestyleE / W; mccabeC90
  • flake8-bugbearB; flake8-comprehensionsC4; flake8-simplifySIM; flake8-banditS; flake8-pytest-stylePT; flake8-printT20; flake8-tidy-importsTID; pep8-namingN

If your old .flake8 file enabled B,C4,SIM,N,PT,T20, you paste those same letters into select and delete the file. The one plugin that does not have a complete port is flake8-docstrings — Ruff implements it as the D prefix with most but not all pydocstyle checks, and the gaps are listed on the rules page. For almost every other plugin, behaviour is identical enough that you can enable the prefix and diff the diagnostics on a sample commit.

The formatter is not Black, exactly

Ruff’s formatter aims at Black compatibility but deliberately differs in a small number of places documented on the Known deviations from Black page. The ones that show up in practice are handling of parenthesised context managers on older Python targets, some edge cases around implicit string concatenation, and docstring formatting, which Ruff formats by default while Black historically did not. If your repo depends on Black’s exact docstring behaviour, set docstring-code-format = false under [tool.ruff.format] and the diff against a Black-formatted baseline shrinks to a handful of lines.

In a real migration you want the diff to be zero on the first commit. The sequence that keeps review manageable is: run ruff format as a single commit, run ruff check --fix --select I as a second commit for import ordering only, then enable the rest of the rule set in a third commit where every change is a real finding. Three commits, three reviewable diffs, and git blame survives because the formatter pass is annotated as such.

Benchmark: Lint+Format Time vs Repo Size
Performance comparison — Lint+Format Time vs Repo Size.

The chart above plots lint-plus-format wall time against repository size in Python files. The gap is not linear — the Black/isort/Flake8 bar rises roughly proportionally with file count because each tool re-parses every file, while the Ruff bar stays almost flat up to several thousand files before it begins to climb. On a 5,000-file monorepo the Ruff column is a single-digit seconds bar next to a column that is tens of times taller. The practical consequence is that ruff check becomes cheap enough to run as a file-save hook in the editor, not just at commit time.

Pre-commit: one hook instead of three

The astral-sh/ruff-pre-commit repository publishes a hook definition you can drop into .pre-commit-config.yaml. The replacement for three separate Black, isort, and Flake8 hooks is two entries from the same repo — one for the linter, one for the formatter:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.4
    hooks:
      - id: ruff
        args: [--fix, --exit-non-zero-on-fix]
      - id: ruff-format

Pin rev to an exact tag; pre-commit’s autoupdate will bump it for you when you are ready. The --exit-non-zero-on-fix flag is the one people forget: without it, a developer who runs git commit will see Ruff silently rewrite a file and the commit will proceed with stale staged content. With it, the commit fails, the developer re-stages the fixed file, and the second commit goes through cleanly. That single flag matches the semantics Black’s pre-commit hook had by default and is the piece of the migration most likely to confuse teammates on day one.

Delete the old hooks in the same PR. If you leave Black or isort installed alongside Ruff, you get a slow race condition where one tool reformats what the other just wrote, and the pre-commit output becomes noisy. A clean migration removes black, isort, flake8, and every flake8-* plugin from requirements-dev.txt in the same commit that enables Ruff.

Topic diagram for Ruff 0.8 Replaced Black, isort, and Flake8 in Our Monorepo
Purpose-built diagram for this article — Ruff 0.8 Replaced Black, isort, and Flake8 in Our Monorepo.

The diagram above shows the data flow that changes in the migration. On the left, three boxes labelled Black, isort, and Flake8 each read the same source file and each produce their own diagnostics or rewrites, with arrows converging on a single pre-commit stage. On the right, a single Ruff box reads the file once, branches internally into a linter and formatter path, and emits both a fix set and a formatted output. The reader’s takeaway from the diagram is that the consolidation is not just a speed win — it eliminates the ordering problem where isort and Black could disagree about what a file should look like, because one binary now owns every decision.

Editor integration and the last mile

The VS Code extension charliermarsh.ruff uses the same binary as your CI. Set editor.formatOnSave and editor.codeActionsOnSave with source.fixAll.ruff and source.organizeImports.ruff, and every file save runs the same checks CI will run on the PR. PyCharm ships an official Ruff plugin with equivalent settings. There is nothing left to install separately for Black or isort, and the editor’s “format document” action no longer has to choose between them.

One detail worth flagging: if you used isort‘s profile = "black" setting, the closest Ruff equivalent is already the default — Ruff’s isort implementation behaves as if Black-profile is on. If you used a custom known_third_party list, port it to [tool.ruff.lint.isort] known-third-party = [...]. If you used isort‘s sections feature to create a custom import group, that is supported via section-order and sections keys under the same table, and the syntax is documented on the Ruff settings page.

What I would do differently next time

If I were starting a Ruff migration on a new monorepo today, I would enable the formatter first, commit the diff, and live on that setup for a week before touching the linter. The formatter pass is mechanical and reviewable; the linter pass produces real findings that need judgement calls, and mixing the two in one PR makes the review miserable. The I selector for imports is the one exception — it is safe to fold into the formatter commit because its output is also mechanical.

The other thing worth doing early is to write a ruff check --statistics report against the whole repo with every rule you plan to eventually enable, then commit a ruff.toml that ignores the noisiest codes with a comment pointing at a tracking issue. That gives you a landing zone for rules you want to adopt later without blocking the initial migration on fixing every finding. Once you ruff replace black isort flake8 in CI, delete the old tools from the lockfile in the same PR so nobody accidentally reinstalls them.

References

  • Ruff formatter documentation — describes the Black-compatible formatter used as the drop-in replacement in this guide.
  • Known deviations from Black — lists the specific behaviours where Ruff’s formatter intentionally differs, which is the checklist to run before swapping the tools in CI.
  • Ruff rules reference — enumerates every rule code and its upstream Flake8 plugin origin, supporting the plugin-to-code mapping table in this article.
  • astral-sh/ruff-pre-commit — the official pre-commit hook repository referenced for the .pre-commit-config.yaml snippet.
  • Ruff configuration reference — documents the [tool.ruff], [tool.ruff.lint], [tool.ruff.lint.isort], and [tool.ruff.format] tables used in the sample pyproject.toml.
  • astral-sh/ruff on GitHub — the project repository where release notes and the 0.8 line’s changelog are published.

Leave a Reply

Your email address will not be published. Required fields are marked *