# Ruff and Black

> Python code quality at speed: Black formats with no options to argue about, and Ruff lints and now formats astonishingly fast, replacing a stack of older tools.


---

# Ruff and Black

You have spent real minutes of your life arguing about where a comma goes, or watching a pull request collect nits about blank lines while the actual logic went unreviewed. Style debates are a tax on attention, and your team keeps paying it. Black ends the debate by formatting your code one fixed way, and Ruff catches the real bugs and bad habits faster than you can blink. Together they turn "is this code clean?" into a question a machine answers in milliseconds.

## How to read this

Read these in order. Phase 1 builds the mental model: why a formatter and a linter are two different jobs, and why handing them to tools beats handing them to humans. Phase 2 is the everyday workflow: the commands you run, what autofix does, and how the editor does it for you. Phase 3 is production reality: pre-commit, CI, configuration that won't fight you, and the gotchas that bite teams.

If you have never set up a Python project before, skim [/guides/python-from-zero](/guides/python-from-zero) first so the project structure here feels familiar.

## The phases

1. [What a formatter and a linter actually do](01-formatter-vs-linter.md)
2. [The everyday workflow](02-the-everyday-workflow.md)
3. [Pre-commit, CI, and the gotchas](03-pre-commit-ci-and-gotchas.md)


---

# What a formatter and a linter actually do

You open a teammate's file and the indentation is four spaces here, two there. Strings are single-quoted on one line and double-quoted on the next. There's an import at the top nobody uses anymore. None of this is *wrong* exactly, but reading it costs you energy, and reviewing it drags you into nitpicks instead of logic. That friction is the problem these two tools exist to remove, and they remove it in two genuinely different ways.

## Two jobs that get confused for one

People lump "code quality tools" together, but a formatter and a linter answer different questions.

A **formatter** answers *how should this code look?* It rewrites whitespace, line breaks, and quote style so the layout is consistent. It does not care whether your code is correct. It only cares that it looks the same as everyone else's.

A **linter** answers *is something wrong or risky here?* It reads your code and flags an unused import, a variable you assigned but never used, a comparison that's always false, a bare `except` that swallows every error. It's a careful reviewer who never gets tired.

```text
formatter  ->  "this LOOKS consistent"   (style, layout)
linter     ->  "this might be WRONG"     (bugs, smells, dead code)
```

*What just happened:* we drew the line that the rest of this guide rests on. Black is the formatter. Ruff is the linter (and, more recently, also a formatter). Keeping the two jobs straight is the whole mental model.

## Black: the end of the style argument

Black's defining choice is that it has almost no options. You don't configure how it formats; you accept how it formats. The only knob most teams ever touch is line length.

This sounds limiting until you've lived it. When there are no options, there is nothing to argue about. Every file in every project formatted by Black looks the same, so your eyes stop tripping on layout and your reviews stop drowning in style comments. Black's own slogan captures the trade: any color you like, as long as it's black.

```python
# before Black
d = {'a':1,'b':2,
  'c':3}

# after Black
d = {"a": 1, "b": 2, "c": 3}
```

*What just happened:* Black normalized the quotes to double, added the spaces after the colons, and collapsed the dict onto one line because it fit. You didn't decide any of that. That's the point.

> The value of Black is not that its style is the best possible style. It's that it's *one* style, applied without you thinking about it. Consistency beats taste here.

## Ruff: a linter so fast you forget it's running

Before Ruff, a typical Python project ran a small zoo of tools: `flake8` for style and basic errors, `isort` to sort imports, `pyupgrade` to modernize old syntax, plus a handful of flake8 plugins. Each was a separate dependency, a separate config, a separate pass over your files.

Ruff is written in Rust and reimplements the rules from that whole zoo in a single tool. It runs so fast that on most projects it finishes before you notice it started, often a sub-second pass where the old stack took many seconds. Because it's one tool, it's one install and one config section instead of five.

```console
$ ruff check .
app/models.py:3:1: F401 [*] `os` imported but unused
app/views.py:42:5: F841 [*] Local variable `result` is assigned to but never used
Found 2 errors.
[*] 2 fixable with the `--fix` option.
```

*What just happened:* Ruff scanned the project, found an unused import (`F401`) and an unused variable (`F841`), and told you both are auto-fixable. The `F` codes come straight from Pyflakes, one of the tools Ruff absorbed, so if you knew the old codes, you already know Ruff's.

## How they fit together

The real question is: if Ruff also formats now, why mention Black at all? Because Ruff's formatter was deliberately built to match Black's style. They produce nearly identical output, so a team can run Black today and switch to Ruff's formatter later (or the reverse) without a giant reformatting diff. You'll meet both names in real codebases for years, and they play the same tune.

A common setup is Black for formatting plus Ruff for linting. An increasingly common setup is Ruff for both, dropping Black entirely. Either is fine. What matters is that *something* formats and *something* lints, automatically, so humans stop doing it by hand.

## In the wild

Most large open-source Python projects you'll clone already have one of these wired in. You'll see a `[tool.black]` or `[tool.ruff]` section in `pyproject.toml`, and a pre-commit hook that runs them before any commit lands. When you contribute, the project's automation formats and lints your change for you, which is why your pull request gets reviewed on its ideas instead of its commas.

```quiz
[
  {
    "q": "What is the core difference between a formatter and a linter?",
    "choices": [
      "A formatter checks for bugs; a linter fixes whitespace",
      "A formatter changes how code looks; a linter flags whether something is wrong",
      "They do the same job with different speeds",
      "A linter only works on imports; a formatter works on everything"
    ],
    "answer": 1,
    "explain": "A formatter governs layout and style; a linter detects likely errors, dead code, and risky patterns."
  },
  {
    "q": "Why does Black have almost no configuration options?",
    "choices": [
      "It is unfinished and options are coming later",
      "So there is nothing to argue about and every project looks the same",
      "Because configuration would slow it down",
      "Because it only supports one Python version"
    ],
    "answer": 1,
    "explain": "Black's value is one consistent style applied without debate, not a style tuned to your taste."
  },
  {
    "q": "What older tools does Ruff replace in a single fast pass?",
    "choices": [
      "pytest and tox",
      "pip and virtualenv",
      "flake8, isort, pyupgrade, and similar linters",
      "Black and mypy only"
    ],
    "answer": 2,
    "explain": "Ruff reimplements the rules of flake8 (and plugins), isort, pyupgrade, and more in one Rust tool."
  }
]
```


---

# The everyday workflow

Now the keyboard. You have a Python project and you want clean, consistent code without thinking about it. There are really only a few commands you'll ever run by hand, and after that the editor does it for you on every save. Let's walk the loop you'll actually live in.

## Installing them

Both tools install with `pip`. Put them in your dev dependencies, not your runtime ones, since your shipped code doesn't need them.

```bash
pip install black ruff
```

*What just happened:* you now have two command-line programs, `black` and `ruff`, on your path. Nothing changed in your code yet; these tools never run unless you ask them to.

## Formatting with Black

Point Black at a file or a directory and it rewrites them in place.

```console
$ black .
reformatted app/views.py
reformatted app/models.py
All done! ✨ 🍰 ✨
2 files reformatted, 14 files left unchanged.
```

*What just happened:* Black walked the current directory, reformatted the two files that didn't match its style, and left the rest alone. Run it again right now and it'll report zero changes, because Black is idempotent: formatting already-formatted code does nothing.

When you only want to *know* whether code is formatted, without touching it, use `--check`. This is what you run in automation.

```console
$ black --check .
would reformat app/views.py
Oh no! 💥 💔 💥
1 file would reformat.
```

*What just happened:* `--check` made no edits. It exited with a non-zero status and told you one file is out of shape. A passing exit code means "everything is already formatted," which is the gate your CI will use.

## Linting with Ruff, and the autofix that saves your day

`ruff check` reports problems. Add `--fix` and it repairs the ones it safely can.

```console
$ ruff check .
app/models.py:3:1: F401 [*] `os` imported but unused
app/utils.py:1:1: I001 [*] Import block is un-sorted or un-formatted
Found 2 errors.
[*] 2 fixable with the `--fix` option.

$ ruff check --fix .
Found 2 errors (2 fixed, 0 remaining).
```

*What just happened:* the first run found an unused import and an out-of-order import block (`I001` is the import-sorting rule Ruff inherited from isort). The second run, with `--fix`, deleted the dead import and sorted the imports for you. Only rules marked `[*]` get fixed; the rest are left for you to decide.

> Not every lint warning is safe to auto-fix. Ruff fixes the ones with an obvious, behavior-preserving correction (sorting imports, deleting an unused import) and leaves judgment calls (an unused variable that might be a real oversight) for you to read.

## The two of them in one go

A by-hand cleanup is two commands. Run the formatter first, then the linter, because Ruff's autofix can change line layout and you want Black to have the final word on layout.

```bash
black .
ruff check --fix .
```

*What just happened:* Black settled the layout, then Ruff fixed lint problems and re-sorted imports. Your working tree is now clean by both tools' standards. If you'd rather use Ruff for formatting too, the equivalent is `ruff format .` followed by `ruff check --fix .`.

## Let the editor do it on save

Typing those commands gets old fast. The real workflow is to format and fix automatically every time you save a file, so you never think about it again. In VS Code, that's a few lines of settings using the Ruff and Black extensions.

```json
{
  "editor.formatOnSave": true,
  "[python]": {
    "editor.defaultFormatter": "ms-python.black-formatter",
    "editor.codeActionsOnSave": {
      "source.fixAll.ruff": "explicit",
      "source.organizeImports.ruff": "explicit"
    }
  }
}
```

*What just happened:* `formatOnSave` runs Black on every save, and the two `codeActionsOnSave` entries run Ruff's autofix and import-sorting at the same moment. You write messy code, hit save, and it lands clean. This is where these tools stop being commands and become invisible.

## For builders

The reason the format-then-lint order matters is that both tools touch the same lines. If Ruff removes an unused import, the surrounding blank lines may shift; Black then settles those. Running them in a fixed order, and running each until it's stable, is what keeps your diffs small and predictable instead of one tool undoing the other's work.

```quiz
[
  {
    "q": "What does `black --check .` do?",
    "choices": [
      "Reformats every file in place",
      "Reports whether files would change and exits non-zero if any would, without editing",
      "Sorts imports only",
      "Installs Black if it is missing"
    ],
    "answer": 1,
    "explain": "`--check` makes no edits; it just reports and sets the exit code, which is what CI relies on."
  },
  {
    "q": "Why does Ruff only auto-fix some of the problems it finds?",
    "choices": [
      "It is a paid feature for the rest",
      "It only fixes problems whose correction is safe and behavior-preserving",
      "It can only fix one file at a time",
      "The unfixed ones are not real errors"
    ],
    "answer": 1,
    "explain": "Ruff fixes rules marked `[*]` that have an obvious safe correction, and leaves judgment calls to you."
  },
  {
    "q": "When running both by hand, which order is recommended and why?",
    "choices": [
      "Ruff then Black, because Ruff is faster",
      "Black then Ruff, so the formatter and then the linter's fixes settle the layout cleanly",
      "Order does not matter at all",
      "Only ever run one of them"
    ],
    "answer": 1,
    "explain": "Format first, then lint-fix, because both touch the same lines and a fixed order keeps diffs small and stable."
  }
]
```


---

# Pre-commit, CI, and the gotchas

Your editor formats on save, which is great until a teammate's editor doesn't, or someone commits from a terminal, or a contributor opens a pull request from a setup you've never seen. The save-time fix is for *you*. To keep the whole project clean, you need a gate that runs whether anyone remembered to or not. That's pre-commit and CI, plus the configuration and the few surprises that trip teams up.

## Configure once, in pyproject.toml

Both tools read their settings from `pyproject.toml`, the standard config file for modern Python projects. Keep this small. The most common thing you'll set is line length, and a list of lint rules to enable.

```toml
[tool.black]
line-length = 88

[tool.ruff]
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "I"]   # pycodestyle errors, Pyflakes, import sorting
```

*What just happened:* we set the same line length for both tools (matching them matters, since a mismatch makes them fight over where to wrap), and told Ruff which rule families to run: `E` for style errors, `F` for likely bugs, `I` for import sorting. Black's default line length is 88, so this section is optional; the value is shown to make the match explicit.

> A real footgun: if Black wraps at 88 and Ruff's line-length rule is set to 79, every wrapped line becomes a lint error Black created. Keep the two line lengths identical and this whole class of conflict disappears.

## The gate that doesn't rely on memory: pre-commit

The `pre-commit` framework runs checks automatically before each `git commit`. If a check fails or rewrites a file, the commit stops, so unformatted code physically cannot enter history. You configure it with a `.pre-commit-config.yaml` at your repo root.

```yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/psf/black
    rev: 24.8.0
    hooks:
      - id: black
```

*What just happened:* this wires three hooks. `ruff` lints and autofixes, `ruff-format` formats, and `black` formats. In practice you pick *one* formatter, so a team using Black would drop the `ruff-format` hook, and a team on Ruff's formatter would drop the `black` repo. Pin `rev` to a specific version so everyone runs the exact same rules.

```console
$ git commit -m "add user endpoint"
ruff.....................................................................Failed
- hook id: ruff
- files were modified by this hook
Fixing app/api.py
black....................................................................Passed
```

*What just happened:* the commit was blocked because Ruff fixed a file. The fixes are now in your working tree, unstaged. You `git add` them and commit again, and this time the hooks pass. The annoyance of committing twice is the feature: bad code never lands.

## The backstop: CI

Pre-commit only protects people who installed it. CI protects everyone, because it runs on the server for every push and pull request. Here it runs in *check* mode: it never edits anything, it only passes or fails. A red check means "this branch has unformatted or lint-failing code, fix it before merge."

```yaml
# .github/workflows/quality.yml
name: quality
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - run: pip install black ruff
      - run: black --check .
      - run: ruff check .
```

*What just happened:* on every push and pull request, GitHub installs the tools and runs `black --check` and `ruff check`. Note there's no `--fix` and no plain `black .` here. CI must never rewrite code, only judge it; a failing job tells the author to run the fixers locally and push again.

## The gotchas that actually bite

A few surprises come up again and again. Knowing them ahead of time saves an afternoon.

- **Version drift.** Black and Ruff occasionally change how they format as they improve. If a teammate runs a newer version, your "already formatted" code suddenly reformats. The fix is to pin exact versions in both `pyproject.toml` and `.pre-commit-config.yaml` so everyone formats identically.
- **The two formatters disagree at the edges.** Ruff's formatter matches Black very closely but not byte-for-byte in every rare case. Don't run both formatters on the same project; pick one. Running Black and `ruff format` together can leave them flip-flopping a line forever.
- **The first adoption commit is huge.** Turning these on in an existing codebase reformats hundreds of files at once, which buries real changes in `git blame`. Do the reformat as one isolated commit, then add its hash to a `.git-blame-ignore-revs` file so blame skips past it.
- **Suppressing a single line.** When a lint rule is wrong for one specific line, silence only that line with a `# noqa` comment naming the code, like `# noqa: F401`. Reach for this rarely; a blanket `# noqa` that names no code hides every problem on the line, which is how real bugs sneak through.

```python
from .legacy import old_helper  # noqa: F401  # re-exported for backward compat
```

*What just happened:* this import looks unused to Ruff, but it's intentionally re-exported, so the targeted `# noqa: F401` tells Ruff to skip exactly that one rule on exactly that one line, and nothing else.

## In the wild

When you join a team with all of this set up, your day looks like: write code, save (editor formats and fixes), commit (pre-commit catches anything you missed), push (CI confirms it's clean for everyone). Three layers, each a backstop for the one before, and not one of them needs you to remember a style rule. That's the whole payoff: the machines hold the line so the humans can argue about things that matter.

```quiz
[
  {
    "q": "Why does CI run `black --check` and `ruff check` instead of the fixing commands?",
    "choices": [
      "Check mode is faster to install",
      "CI should judge code, never rewrite it; a failure tells the author to fix it locally",
      "The fixing commands do not work on servers",
      "Check mode also runs the tests"
    ],
    "answer": 1,
    "explain": "CI only passes or fails. Rewriting code on the server would hide problems instead of surfacing them."
  },
  {
    "q": "What is the safest way to ignore one specific lint rule on one line?",
    "choices": [
      "Delete the line",
      "Add a bare `# noqa` with no code",
      "Add `# noqa: <code>` naming the exact rule, like `# noqa: F401`",
      "Disable the rule globally in pyproject.toml"
    ],
    "answer": 2,
    "explain": "A targeted `# noqa: F401` silences only that rule on that line; a bare `# noqa` hides everything and lets bugs through."
  },
  {
    "q": "Why should you pin exact tool versions across pyproject.toml and pre-commit?",
    "choices": [
      "To make installs slower but safer",
      "So everyone formats identically and a newer version does not silently reformat the codebase",
      "Because the tools refuse to run without a version",
      "To enable the autofix feature"
    ],
    "answer": 1,
    "explain": "Formatting can change between versions; pinning keeps every contributor producing byte-identical output."
  }
]
```
