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.
Table of Contents
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, runruff 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.