Linting, formatting, and pre-commit

One-file ruff config replacing flake8/isort/black, plus a pre-commit setup the whole team will keep.

๐Ÿงช Module 6 8 min read Not started

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

  1. Configure ruff for lint + format.
  2. Use rule sets deliberately.
  3. Configure pre-commit with multiple hooks.
  4. Wire ruff/mypy/pytest into CI.
  5. Use editor integrations.

1. ruff โ€” one tool, rest in peace

powershell
uv add --dev ruff

Lint:

powershell
uv run ruff check .
uv run ruff check . --fix
uv run ruff check . --fix --unsafe-fixes      # apply more aggressive auto-fixes

Format (replaces black):

powershell
uv run ruff format .
uv run ruff format --check .                  # CI: fails if files would be changed

Both 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:

toml
[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

toml
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
skip-magic-trailing-comma = false

Defaults are sensible. Same opinions as black.


3. Other linters you might add

  • bandit (security): subset already in ruff via S rules.
  • 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

powershell
uv add --dev pre-commit
uv run pre-commit install

.pre-commit-config.yaml:

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/markdown

After 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):

powershell
uv run pre-commit run --all-files

Update hook versions:

powershell
uv run pre-commit autoupdate

5. CI workflow

.github/workflows/ci.yml:

yaml
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-missing

For matrix builds (Python 3.11, 3.12, 3.13):

yaml
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 pytest

6. Editor integration

VS Code / Cursor

Install Ruff and Pylance extensions. Settings (.vscode/settings.json):

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:

  1. ruff format rewrites the file.
  2. ruff check --fix auto-fixes lint issues.
  3. 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

RuleWhat
B006Mutable default argument
B007Loop control variable not used inside loop
B008Function call in default argument
B904raise ... from e (preserve cause)
C401Generator expr in sorted etc.
SIM108Ternary instead of if/else block
SIM118key in dict instead of key in dict.keys()
PTH100os.path.abspath โ†’ Path.resolve()
UP006List[int] โ†’ list[int]
UP007Optional[X] โ†’ X | None
RUF013Implicit Optional without None default
S301pickle.load on untrusted data
S608SQL injection (f-string SQL)
T201print (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

toml
[tool.ruff.lint.per-file-ignores]
"src/legacy/*.py" = ["F401", "E501"]    # leave legacy alone for now
"tests/*.py"      = ["S101"]            # `assert` is fine

Auto-modernise old code

powershell
uv run ruff check . --select UP --fix

Then commit. pyupgrade rules convert List[int] โ†’ list[int], Optional[X] โ†’ X | None, set([...]) โ†’ {...}, f"{}" over %, etc.

Stop someone from committing secrets

Add a hook:

yaml
- repo: https://github.com/gitleaks/gitleaks
  rev: v8.21.0
  hooks:
    - id: gitleaks

9. 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: 16

Manually fix the remaining (often B006, B904 require small thought).

Add to CI. From now on, the repo stays clean.


Hands-on lab (1 hour)

  1. Add ruff + mypy + pre-commit to an existing project.
  2. Run ruff check . --statistics; pick the top 3 rules to enable next.
  3. Add pre-commit to .git/hooks and trigger a commit; observe auto-fix.
  4. Add gitleaks hook; attempt to commit a fake API key; verify block.
  5. Add a GitHub Actions workflow running ruff + mypy + pytest on push.
  6. Configure VS Code / Cursor for format-on-save with ruff.
  7. Bonus: write a custom ruff rule? (Advanced โ€” only if curious. Custom rules require a Rust plugin since 0.6.)

Common pitfalls

  1. Mixing black + ruff format โ€” use one. Ruff format is identical to black 23.x.
  2. Disabling rules wholesale instead of fixing or per-file-ignores.
  3. Forgetting to commit pyproject.toml config so teammates get different lints.
  4. CI runs pytest but skips lint/type checks โ†’ bugs slip through.
  5. Pre-commit hooks not run because someone disabled them locally โ€” make CI re-run them.
  6. Auto-fixing destructive things without review (--unsafe-fixes).

Self-check

  1. What does ruff replace?
  2. Difference between ruff check and ruff format?
  3. What does pre-commit do?
  4. How do you exempt one file from a rule?
  5. Why run lint/type-check in CI even if pre-commit runs?

References

Sign in to save your progress and earn badges.