Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • black for formatting, ruff for linting
  • Configuration in pyproject.toml
  • Installing pre-commit hooks
  • Fixing a legacy codebase in one commit

Lesson notes

1. Why bother with formatting at all

Code style is not about beauty. It is about three very practical things:

  • Readable diffs. If the formatter is deterministic, a diff shows only what actually changed - not that someone's editor reindented the file.
  • No more style discussions. The tool decides, nobody argues, code review talks about logic instead of blank lines.
  • Bugs found for free. A linter sees the unused import, the variable you shadowed and the except that swallows everything.

2. The two tools

ToolJobTypical command
black (or ruff format) Formatter - rewrites layout, never logic black .
ruff Linter - finds unused imports, bad practices, sorts imports ruff check --fix .

ruff is written in Rust and is fast enough that you stop noticing it. It also ships its own formatter, ruff format, which is a drop-in replacement for black - one tool instead of two. Pick either; just do not run two different formatters over the same file.

pip install ruff black pre-commit
ruff check .          # report problems
ruff check --fix .    # fix what can be fixed automatically
ruff format .         # format (or: black .)

3. One configuration file for the whole project

Both tools read pyproject.toml from the repository root, so the settings travel with the code and every contributor gets the same result.

# pyproject.toml
[tool.ruff]
line-length = 88
target-version = "py312"
exclude = ["migrations", ".venv", "staticfiles"]

[tool.ruff.lint]
select = [
    "E",   # pycodestyle errors
    "F",   # pyflakes
    "I",   # isort - import order
    "UP",  # pyupgrade - modern syntax
    "B",   # flake8-bugbear - likely bugs
    "DJ",  # flake8-django - Django specific rules
]
ignore = ["E501"]  # the formatter already handles line length

[tool.ruff.lint.isort]
known-first-party = ["core", "blog", "accounts", "comments"]

[tool.black]
line-length = 88
extend-exclude = "migrations"

The DJ rules are worth enabling on a Django project: they catch null=True on a CharField, a model without __str__, and exclude in a ModelForm.

4. pre-commit: the tool that remembers for you

Running the commands by hand works exactly until the day you forget. A git hook does not forget. pre-commit installs one for you and runs the checks on the files you are about to commit.

git commit staged files pre-commit hooks ruff, formatter, gitleaks, whitespace pass fail commit created commit aborted files auto-fixed git add and retry
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
      - id: check-merge-conflict
      - id: detect-private-key

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.9
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks
pre-commit install          # writes .git/hooks/pre-commit
pre-commit run --all-files  # first run: fixes the whole repository
pre-commit autoupdate       # bump the hook versions from time to time

5. Fixing a codebase that was never formatted

The first run will touch almost every file. Keep that churn out of your feature branches:

git switch -c chore/formatting
pre-commit run --all-files
git add -A
git commit -m "chore: apply ruff and formatter to the whole project"

Then tell git blame to skip that commit, so it keeps pointing at the person who wrote the logic rather than the person who ran the formatter:

# .git-blame-ignore-revs
# formatting-only commit
a1b2c3d4e5f60718293a4b5c6d7e8f9012345678
git config blame.ignoreRevsFile .git-blame-ignore-revs

6. The hook is not the guarantee

A hook lives on a developer's machine and can be bypassed with git commit --no-verify. The real gate is continuous integration, which we set up in lesson 8.5:

# .github/workflows/ci.yml (preview of module 8)
name: CI
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"
      - run: pip install pre-commit
      - run: pre-commit run --all-files

7. Exercise

  1. Add pyproject.toml with the ruff configuration above and run ruff check .. Read every reported rule before you fix it.
  2. Install pre-commit and run it over the whole repository in a separate formatting commit.
  3. Deliberately commit a file with an unused import and watch the hook stop you.
  4. Enable the DJ rules and see what ruff thinks about the models you wrote during the Django Girls tutorial.

Further reading

After this lesson you will

  • Format and lint the blog project automatically
  • Stop style discussions with a shared configuration