Linting, formatting, and pre-commit
One-file ruff config replacing flake8/isort/black, plus a pre-commit setup the whole team will keep.
Why this matters
Style debates eat hours and rot code reviews. Linters catch bugs, formatters end debates, and pre-commit enforces both on every commit so bad code never reaches the repo. In 2026 the answer is ruff for almost everything (replaces black + flake8 + isort + pylint + pyupgrade) and pre-commit to run it automatically.
Learning objectives
- Configure
rufffor lint + format. - Use rule sets deliberately.
- Configure
pre-commitwith multiple hooks. - Wire ruff/mypy/pytest into CI.
- Use editor integrations.
1. ruff โ one tool, rest in peace
uv add --dev ruffLint:
uv run ruff check .
uv run ruff check . --fix
uv run ruff check . --fix --unsafe-fixes # apply more aggressive auto-fixesFormat (replaces black):
uv run ruff format .
uv run ruff format --check . # CI: fails if files would be changedBoth commands are blazingly fast โ Rust under the hood, parallel, incremental.
2. Rule sets
ruff ships ~800 rules grouped by source linter. Pick the sets you want:
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes (undefined names, unused imports)
"I", # isort (import order)
"B", # flake8-bugbear (likely bugs)
"C4", # flake8-comprehensions
"UP", # pyupgrade (modernise syntax)
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"PTH", # use pathlib over os.path
"RUF", # ruff-specific
"PL", # pylint subset
"N", # pep8-naming
"ARG", # unused arguments
"RET", # return statements
"ERA", # eradicate commented-out code
"S", # bandit (security)
"T20", # flake8-print (no print() in libs)
]
ignore = [
"E501", # line too long (handled by formatter)
"PLR0913", # too many arguments
"S101", # assert (fine in tests)
"T201", # print (fine in scripts/__main__)
]
unfixable = ["F841"] # don't auto-remove unused variables (might be intentional)
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "ARG"] # asserts and unused args fine in tests
"scripts/**/*.py" = ["T201"] # prints fine in scripts
"__init__.py" = ["F401"] # unused imports often intentional in __init__
[tool.ruff.lint.isort]
known-first-party = ["my_project"]For most projects, start with select = ["E","W","F","I","B","UP","C4","SIM","PTH","RUF"] and add more later.
Formatter
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
skip-magic-trailing-comma = falseDefaults are sensible. Same opinions as black.
3. Other linters you might add
bandit(security): subset already inruffviaSrules.vulture: find dead code.radon: cyclomatic complexity.xenon: enforce complexity thresholds in CI.pylint: deeper analysis; slower; sometimes useful for "smell" checks. Most teams now skip it because ruff covers 80%.
The "all-in-one" 2026 stack: ruff (lint + format) + mypy/pyright (types).
4. pre-commit โ run them all on git commit
uv add --dev pre-commit
uv run pre-commit install.pre-commit-config.yaml:
default_language_version:
python: python3.12
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies: [pydantic, sqlalchemy, types-requests]
args: [--strict]
# Limit to source dir to avoid double-checking tests
files: ^src/
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/asottile/blacken-docs
rev: 1.19.1
hooks:
- id: blacken-docs # format code in docstrings/markdownAfter install, every git commit runs the hooks. Auto-fixed files are re-staged; failures block the commit.
Run on all files (e.g., after adding a new rule):
uv run pre-commit run --all-filesUpdate hook versions:
uv run pre-commit autoupdate5. CI workflow
.github/workflows/ci.yml:
name: ci
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src/
- run: uv run pytest --cov=src --cov-report=term-missingFor matrix builds (Python 3.11, 3.12, 3.13):
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: astral-sh/setup-uv@v3
- run: uv sync --frozen
- run: uv run pytest6. Editor integration
VS Code / Cursor
Install Ruff and Pylance extensions. Settings (.vscode/settings.json):
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"python.analysis.typeCheckingMode": "strict"
}Saving a file:
ruff formatrewrites the file.ruff check --fixauto-fixes lint issues.- Imports are sorted.
Type errors show inline (pyright via Pylance).
PyCharm
Settings โ Tools โ Actions on Save โ enable "Reformat code" and "Optimize imports." Install the Ruff plugin.
Neovim / Helix / Vim
Use ruff-lsp (or built-in ruff server since 0.5.0) + pyright + pylsp via your LSP client (coc.nvim, nvim-lspconfig, etc.).
7. Useful ruff rule highlights
| Rule | What |
|---|---|
B006 | Mutable default argument |
B007 | Loop control variable not used inside loop |
B008 | Function call in default argument |
B904 | raise ... from e (preserve cause) |
C401 | Generator expr in sorted etc. |
SIM108 | Ternary instead of if/else block |
SIM118 | key in dict instead of key in dict.keys() |
PTH100 | os.path.abspath โ Path.resolve() |
UP006 | List[int] โ list[int] |
UP007 | Optional[X] โ X | None |
RUF013 | Implicit Optional without None default |
S301 | pickle.load on untrusted data |
S608 | SQL injection (f-string SQL) |
T201 | print (no print in libs) |
Browse: https://docs.astral.sh/ruff/rules/.
8. Patterns
Generate ignore comments selectively
ruff check . --statistics shows top offenders so you can pick which rules to enable next.
Per-file overrides
[tool.ruff.lint.per-file-ignores]
"src/legacy/*.py" = ["F401", "E501"] # leave legacy alone for now
"tests/*.py" = ["S101"] # `assert` is fineAuto-modernise old code
uv run ruff check . --select UP --fixThen commit. pyupgrade rules convert List[int] โ list[int], Optional[X] โ X | None, set([...]) โ {...}, f"{}" over %, etc.
Stop someone from committing secrets
Add a hook:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.0
hooks:
- id: gitleaks9. Worked example: from chaos to clean
A typical day-0 ruff run on a legacy repo:
$ uv run ruff check .
src/a.py:12:1: F401 [*] `os` imported but unused
src/a.py:34:5: B006 Do not use mutable data structures for argument defaults
src/b.py:18:8: SIM108 Use ternary operator `x = 1 if cond else 2`
src/c.py:9:1: PTH100 `os.path.abspath()` should be replaced by `Path.resolve()`
src/d.py:45:9: B904 Within an `except` clause, raise exceptions with `raise ... from err`
Found 47 errors.Run --fix:
$ uv run ruff check . --fix
Fixed 31 errors.
Remaining: 16Manually fix the remaining (often B006, B904 require small thought).
Add to CI. From now on, the repo stays clean.
Hands-on lab (1 hour)
- Add ruff + mypy + pre-commit to an existing project.
- Run
ruff check . --statistics; pick the top 3 rules to enable next. - Add
pre-committo.git/hooksand trigger a commit; observe auto-fix. - Add
gitleakshook; attempt to commit a fake API key; verify block. - Add a GitHub Actions workflow running ruff + mypy + pytest on push.
- Configure VS Code / Cursor for format-on-save with ruff.
- Bonus: write a custom ruff rule? (Advanced โ only if curious. Custom rules require a Rust plugin since 0.6.)
Common pitfalls
- Mixing
black+ruffformat โ use one. Ruff format is identical to black 23.x. - Disabling rules wholesale instead of fixing or
per-file-ignores. - Forgetting to commit
pyproject.tomlconfig so teammates get different lints. - CI runs
pytestbut skips lint/type checks โ bugs slip through. - Pre-commit hooks not run because someone disabled them locally โ make CI re-run them.
- Auto-fixing destructive things without review (
--unsafe-fixes).
Self-check
- What does
ruffreplace? - Difference between
ruff checkandruff format? - What does pre-commit do?
- How do you exempt one file from a rule?
- Why run lint/type-check in CI even if pre-commit runs?
References
- ruff docs: https://docs.astral.sh/ruff/.
- pre-commit docs: https://pre-commit.com/.
- black docs: https://black.readthedocs.io/ (for the philosophy; ruff-format matches).
- "The 2026 Python tooling stack" โ Hynek Schlawack blog.
- GitHub Actions for Python: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python.
Sign in to save your progress and earn badges.