The first version of this post compared Black, isort, autopep8 and Ruff side by side and recommended a combination of them. Before rewriting it I checked what my Python repos actually run. The answer is simpler than the post was: every one of them uses Ruff for formatting and linting, none of them has Black or isort installed, and nobody has missed either.
That covers voice agent workers, a telephony simulator, a scraping API, OCR tooling and a few smaller services. This post is the config they share, why each line is there, and the places where I have not been consistent.
The hub post covers the general rules: one formatter per language, pinned versions, and enforcement in the editor, on commit and in CI. This is the Python specifics.
Why one tool replaced three
Black formatted, isort sorted imports, and Flake8 linted. Three tools meant three configs that each had to agree on line length, three versions to pin, and a known fight between Black and isort over how an import block wraps, solved by setting isort's profile = "black" and hoping nobody forgot.
Ruff does all three jobs. ruff format is designed as a drop-in replacement for Black, and on code Black already formatted it produces nearly identical output, so the migration is usually a small diff rather than a rewrite. Import sorting is Ruff's I lint rules. Most of Flake8's rules, and a large set of plugins, are reimplemented as lint rules too.
The case for switching was never speed for me, although Ruff is fast. It was that line length, target Python version and exclusions live in one [tool.ruff] table, so there is nothing left to keep in sync.
The config
This is the shape I copy into every new repo's pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
[tool.ruff.lint.per-file-ignores]
# Generated models: long descriptions and wire-format field names
# belong to the contract, not to our style.
"src/contracts/*" = ["E501", "N815"]
Line by line:
line-length = 100. The same number as printWidth in my Prettier configs, on purpose. A repo with a Python service and a TypeScript front end should not have two opinions about how long a line is. Ruff's default is 88, inherited from Black.
target-version. Matches the lowest Python version in requires-python. It tells the formatter and linter which syntax is safe, so it never suggests something your oldest supported interpreter cannot run. If requires-python is set, Ruff infers it, but I write it down anyway because it is the kind of thing people grep for.
select. Ruff's default rule set is small: E4, E7, E9 and F (the lint.select setting), which is roughly "things that are probably bugs". I add the rest of pycodestyle errors and warnings (E, W), import sorting (I) and pep8-naming (N). One service also adds B (bugbear) and UP (pyupgrade), and I would add those everywhere if I were starting again, because both catch real mistakes rather than style.
per-file-ignores. The only exceptions I allow are for generated code. The contract models in my voice agent worker are generated from a schema, and their field names are camelCase because that is the wire format. Renaming them to satisfy a naming rule would break the contract. Scoping the ignore to the generated directory keeps the rule on for everything a human writes.
The two places Ruff can disagree with itself
Line length. The formatter makes a best-effort attempt to wrap lines at line-length. It will not split a long string literal or a URL in a comment, so a formatted file can still trip E501. My repos are inconsistent here and I will own it: one ignores E501 globally and lets the formatter decide, another keeps it on and ignores it only for generated files. I now prefer keeping it on. An overlong line the formatter could not fix is usually a string that should be built differently, and I would rather hear about it.
Rules that fight the formatter. Ruff's formatter documentation lists lint rules that conflict with it, such as W191 (tab indentation) and COM812 (missing trailing comma). None of them are in my select, which is part of why the list stays short. If you enable a broad set like ALL, read that list and ignore the conflicting rules, or the two halves of the same tool will rewrite each other's output.
Import sorting is a lint rule, not formatting
This trips people up: ruff format does not sort imports. Sorting is the I rule set, applied with --fix. So the command pair, in this order, is:
ruff check --fix .
ruff format .
Lint fixes first, because sorting can change how a block wraps, then the formatter has the last word on whitespace. That is the same ordering I use for ESLint and Prettier in the JavaScript guides. If you only want import sorting and not the rest of the lint fixes, ruff check --select I --fix . does exactly that.
Pin it, and I have not everywhere
Ruff lives in the dev dependency group, next to pytest:
[dependency-groups]
dev = [
"pytest>=9.1.1,<10",
"ruff>=0.16.6,<0.17",
]
Ruff is still on 0.x, and its versioning policy allows the formatter's stable style to change in a minor release. So the upper bound on the minor version is what keeps two developers from producing different output from the same file. My newest repos pin like this, or pin an exact version. A couple of older ones still say ruff>=0.4, which is exactly the setup I argue against in the hub post. They get fixed the next time I touch them, and the upgrade lands as its own commit.
Wiring
Editor. The Ruff extension for VS Code runs the same binary as the command line, so format on save and "organize imports" produce what CI expects.
CI. Check, never fix:
ruff check .
ruff format --check .
Both exit non-zero if anything would change. With uv, prefix each with uv run so CI uses the pinned version from the lockfile rather than whatever is on the runner.
Commit hook. If a repo has one, the official hooks come from astral-sh/ruff-pre-commit, with the lint hook listed before the format hook for the same ordering reason as above:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
Keep that rev in step with the version in pyproject.toml, or the hook and CI can disagree about the same file.
Migrating from Black and isort
- Copy your Black line length into
[tool.ruff], and addItoselect. - Delete
[tool.black],[tool.isort]and the Flake8 config, and remove the three packages. - Run
ruff check --fix .andruff format .and commit the result on its own, with nothing else in the commit. - Add that commit's hash to
.git-blame-ignore-revsso blame skips it.
Expect a small diff, mostly import order and a handful of lines where the two formatters differ. If the diff is large, check that the line length actually carried over. That is the usual cause.
For the enforcement model behind all of this, see the hub post. For the JavaScript side, the front-end and back-end guides use the same principles with Prettier.