Skip to content

Wiring Black and Ruff into a Python formatting pipeline

Configure Black and Ruff together for a Python project — formatter and linter responsibilities split cleanly, with pre-commit hooks and CI gate.

Ruff is the Rust-based linter that replaced flake8, isort, pyupgrade, pylint partial, and most plugins in one binary that runs 100x faster than the original tools. Ruff also has a formatter — and the question of “Black or Ruff format?” is now a real decision. This article wires both for the case where you keep Black as the formatter and Ruff as the linter (the conservative split), and shows how to move to Ruff-only formatting if you’re starting fresh.

How to verify

Check what’s installed, what’s configured, and what fails:

ruff --version && black --version
ruff check --select ALL . | head -40
black --check --diff . | head -40
ls .pre-commit-config.yaml pyproject.toml ruff.toml 2>/dev/null
grep -E "^\[tool\.(black|ruff)" pyproject.toml 2>/dev/null

If ruff check --select ALL returns 5000 findings on day one, that’s normal — Ruff enables every rule by default with ALL. Pick a subset (see below).

What’s happening

Black is a Python formatter with one knob: line length. It rewrites your code to a canonical form — quote style, parentheses, line breaks — and refuses configuration beyond that. The result is that PRs never include “I prefer single quotes” debates.

Ruff is a Rust-rewrite of flake8 plus most of its plugin ecosystem (isort, pyupgrade, bugbear, eradicate, pydocstyle, mccabe) plus a formatter compatible with Black’s output. Where flake8 took seconds to a minute on a large repo, Ruff takes milliseconds. That speed lets you run it on every save in the editor, not just in CI.

The two split cleanly. Black formats: it physically rewrites the source. Ruff lints: it reports issues, and with --fix it can auto-correct a subset (unused imports, mutable default arguments, isort-style import sorting). Running both gives you canonical formatting plus a broad lint sweep. Ruff’s formatter is Black-compatible — if you want one tool, drop Black and use ruff format instead. Both produce identical output for the cases where they overlap.

The procedure

  1. Install both into the project venv (or as dev dependencies):

    /opt/venvs/app/bin/pip install black ruff pre-commit
  2. Configure both in pyproject.toml. Match line length:

    [tool.black]
    line-length = 100
    target-version = ["py312"]
    exclude = '/(\.venv|migrations)/'
    
    [tool.ruff]
    line-length = 100
    target-version = "py312"
    src = ["src", "tests"]
    
    [tool.ruff.lint]
    select = [
      "E", "W",      # pycodestyle errors and warnings
      "F",            # pyflakes
      "I",            # isort
      "B",            # flake8-bugbear
      "UP",           # pyupgrade
      "C4",           # flake8-comprehensions
      "SIM",          # flake8-simplify
      "RUF",          # Ruff-specific
    ]
    ignore = [
      "E501",        # line length — Black handles this
    ]
    per-file-ignores = { "tests/*" = ["S101"] }   # asserts ok in tests
    
    [tool.ruff.lint.isort]
    known-first-party = ["app"]

    The select-then-ignore pattern is more maintainable than enabling ALL and adding 50 ignores.

  3. Add a pre-commit config so commits can’t include unformatted code:

    # .pre-commit-config.yaml
    repos:
      - repo: https://github.com/astral-sh/ruff-pre-commit
        rev: v0.5.0
        hooks:
          - id: ruff
            args: [--fix]
          - id: ruff-format         # remove this if keeping Black
      - repo: https://github.com/psf/black-pre-commit-mirror
        rev: 24.4.2
        hooks:
          - id: black

    Install once per repo: pre-commit install.

  4. Wire to CI as a non-skippable gate:

    # .github/workflows/lint.yml
    - name: Ruff
      run: |
        pip install ruff==0.5.0
        ruff check --output-format=github .
        ruff format --check .
    
    - name: Black
      run: |
        pip install black==24.4.2
        black --check --diff .

    Pin both tools — formatter version drift causes diffs that look real but aren’t.

  5. To move from Black to Ruff-only formatting (one tool instead of two):

    ruff format .                          # apply Ruff format
    pip uninstall black
    # remove [tool.black] from pyproject.toml
    # remove black hook from .pre-commit-config.yaml
    git diff --stat                         # check the visual delta is empty

    Ruff’s formatter matches Black’s output for the vast majority of code. The few edge cases (chained method calls, magic trailing commas) differ slightly. Format the whole repo, eyeball the diff, then commit.

  6. For incremental adoption on a legacy codebase, lint only changed files:

    git diff --name-only origin/main...HEAD | grep '\.py$' | xargs ruff check

    Or use ruff check --add-noqa to insert # noqa on every existing finding, then enforce “no new noqa” in PRs.

Common pitfalls

  • Configuring Black for one line length and Ruff for another — every ruff format undoes Black’s work. Match line-length exactly.
  • Enabling select = ["ALL"] without ignore — Ruff has hundreds of rules; the result is unactionable. Pick categories that match your style.
  • Running ruff check --fix in CI — the CI shouldn’t modify the tree. Use ruff check (no fix) for the gate, ruff check --fix for pre-commit.
  • Skipping pre-commit install on fresh clones — hooks don’t run unless installed in each clone. Add a check in CONTRIBUTING.md and in make install.
  • Ignoring RUF rules — the Ruff-specific category catches real bugs (mutable default args, unused exception variables) that flake8 missed.

For the type-checking layer that pairs with lint and format, see Running mypy in strict mode in production. Stack Harbor’s managed operations practice runs Black + Ruff on new client projects and migrates existing flake8/isort/pyupgrade stacks to Ruff incrementally — the CI speedup typically saves 30-90 seconds per push, which compounds across a team.