Python Ruff and Black: Linting and Formatting Setup

Ruff and Black are the modern Python code quality stack. Ruff — written in Rust — replaces flake8, isort, pyupgrade, and dozens of plugins with a single blazing-fast tool that lints an entire codebase in milliseconds. Black enforces a consistent, uncompromising code style. Together they eliminate style debates, catch bugs early, and keep codebases readable without slowing down the development loop.

Ruff: The All-in-One Linter

Ruff implements over 800 lint rules from flake8, pylint, isort, pyupgrade, pydocstyle, and more. It runs 10–100x faster than the tools it replaces because it is written in Rust. Most projects can eliminate their entire .flake8, setup.cfg, and .isort.cfg files and replace them with a [tool.ruff] section in pyproject.toml.

pip install ruff

# Lint the current directory
ruff check .

# Lint and auto-fix what can be fixed automatically
ruff check --fix .

# Check a single file
ruff check my_api/main.py

# Show all available rules
ruff rule --all | head -50

# Watch mode — re-lint on file change
ruff check --watch .
# Before Ruff
import os
import sys
import os  # duplicate import

from typing import List, Dict  # use list, dict in Python 3.9+

def process(items:List[Dict]) -> None:
    for i in range(len(items)):  # use enumerate
        print(items[i])

x=1  # missing whitespace
y = x+1

# After ruff check --fix
import os
import sys

def process(items: list[dict]) -> None:
    for i, item in enumerate(items):
        print(item)

x = 1
y = x + 1

Black: The Uncompromising Formatter

Black reformats Python code with no configuration options (on purpose). It imposes one style and eliminates all formatting debates. The only option is line length. Once Black is adopted, code review stops discussing style and focuses on logic.

pip install black

# Format all Python files
black .

# Check without modifying (exit 1 if formatting would change)
black --check .

# Format a specific file
black my_api/main.py

# Show diff of what Black would change without applying
black --diff my_api/main.py
# Before Black
def long_function_name(var_one,var_two,var_three,var_four):
    print(var_one,var_two,var_three,var_four)

result = some_function_that_returns_a_value(argument_one, argument_two, argument_three, argument_four, argument_five)

x = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3', 'key4': 'value4'}

# After Black (line length 88)
def long_function_name(var_one, var_two, var_three, var_four):
    print(var_one, var_two, var_three, var_four)

result = some_function_that_returns_a_value(
    argument_one,
    argument_two,
    argument_three,
    argument_four,
    argument_five,
)

x = {
    "key1": "value1",
    "key2": "value2",
    "key3": "value3",
    "key4": "value4",
}

pyproject.toml Configuration

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # pyflakes
    "I",   # isort
    "B",   # flake8-bugbear
    "C4",  # flake8-comprehensions
    "UP",  # pyupgrade
    "N",   # pep8-naming
    "ANN", # flake8-annotations (type hints)
    "S",   # flake8-bandit (security)
    "RUF", # ruff-specific rules
]
ignore = [
    "E501",   # line too long — handled by Black
    "ANN101", # missing type annotation for self
    "ANN102", # missing type annotation for cls
    "S101",   # use of assert (common in tests)
]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "ANN"]  # allow assert in tests, relax annotations
"migrations/**/*.py" = ["ALL"]     # don't lint generated migration files

[tool.ruff.lint.isort]
known-first-party = ["my_api"]
force-single-line = false

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"]

[tool.black]
line-length = 88
target-version = ["py312"]
exclude = '''
/(
    \.git
  | \.venv
  | migrations
  | __pycache__
)/
'''

Pre-commit Hooks

Pre-commit hooks run Ruff and Black automatically before every commit, preventing unformatted or lint-failing code from entering the repository.

pip install pre-commit
pre-commit install  # installs the git hook
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.7
    hooks:
      - id: ruff
        args: [--fix, --exit-non-zero-on-fix]
      - id: ruff-format   # use Ruff's formatter instead of Black

  # OR: use Black + Ruff separately
  # - repo: https://github.com/psf/black
  #   rev: 24.4.2
  #   hooks:
  #     - id: black

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-merge-conflict
      - id: debug-statements    # catches leftover breakpoint() calls

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [pydantic, fastapi]
# Run all hooks against all files (useful on first install)
pre-commit run --all-files

# Update hook versions to latest
pre-commit autoupdate

VS Code Integration

// .vscode/settings.json
{
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.fixAll.ruff": "explicit",
      "source.organizeImports.ruff": "explicit"
    }
  },
  "ruff.lint.args": ["--select=E,W,F,I,B,C4,UP,N,S,RUF"],
  "ruff.format.args": [],
  "python.analysis.typeCheckingMode": "basic"
}

Install the Ruff extension (publisher: charliermarsh) from the VS Code marketplace. It provides real-time linting and format-on-save without needing Black as a separate extension.

CI/CD Enforcement

# .github/workflows/lint.yml
name: Lint

on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install Ruff and Black
        run: pip install ruff black

      - name: Ruff lint
        run: ruff check --output-format=github .

      - name: Black format check
        run: black --check .

      # Type checking with mypy
      - name: mypy
        run: |
          pip install mypy pydantic fastapi
          mypy my_api/ --ignore-missing-imports

Ruff as a Black Replacement

Since Ruff 0.1.0, Ruff ships its own formatter (ruff format) that is Black-compatible but runs even faster. For new projects, you can use only Ruff — skipping Black entirely.

# Format with Ruff (Black-compatible)
ruff format .

# Check without modifying
ruff format --check .

# Diff only
ruff format --diff .
# pyproject.toml — Ruff-only setup (no Black needed)
[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "RUF"]

[tool.ruff.format]
quote-style = "double"           # match Black's default
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
# .pre-commit-config.yaml — Ruff-only (replaces Black)
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.7
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

Frequently Asked Questions

Should I use Black + Ruff or just Ruff?
For new projects in 2026, use Ruff for both linting (ruff check) and formatting (ruff format). The formatter is Black-compatible and removes a dependency. For existing projects already using Black, keep Black to avoid unnecessary diff noise during migration.
Ruff vs flake8 — do I need to migrate?
Ruff is a drop-in superset of flake8. It runs 100x faster, requires no plugins, and is configured in pyproject.toml. Migration usually takes 30 minutes: install Ruff, run ruff check --select=E,W,F . to verify rule parity, then delete .flake8.
Does Black conflict with Ruff's formatting rules?
Some Ruff rules (E501, W291) overlap with Black's formatting decisions. Disable them in [tool.ruff.lint] ignore = ["E501"] since Black already handles line length. The official Ruff docs list the recommended ignore list for Black compatibility.