Skip to content

Stop Arguing About Code Style — Automating Quality with pre-commit

In the last post, we set up pyproject.toml so that every tool in our project — Black, Ruff, Mypy, pytest — had one shared home. That solved the “where are the settings?” problem.

But it didn’t solve a different problem.

A teammate writes some code. They forget to run Black. They forget to run Ruff. They commit anyway. Now your codebase has one function in single quotes and another in double quotes. One file uses tabs. Another has an unused import sitting there for three weeks because nobody noticed.

You can ask people to “please remember to format before committing.” People forget. Reminders in Slack get ignored. Code review turns into a style debate instead of a discussion about logic.

So instead of asking developers to remember, we make Git remember for them.

The idea is surprisingly simple

Imagine a bouncer standing at the door of a club. Before anyone gets in, the bouncer checks a few things. Are you on the list? Are you following the dress code? Do you have anything dangerous on you?

If something’s wrong, you don’t get in, you fix it first.

That’s exactly what pre-commit does. Before your code is allowed into a commit, a set of checks runs automatically. If a check fails, the commit is blocked until the issue is fixed. Nobody has to remember to run anything manually. Git simply won’t let bad code through the door.

Where does this configuration live?

All of this lives in a file called .pre-commit-config.yaml, sitting in the root of your project, right next to pyproject.toml.

repos:
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black

Each repo entry points to a tool’s own GitHub repository — pre-commit doesn’t reinvent Black or Ruff, it just borrows them and runs them for you at the right moment. The rev pins an exact version, so the same checks run for everyone on the team, not whatever version happens to be installed locally. And hooks lists which specific checks from that tool to run, since some tools offer more than one.

Turning it on

Having a .pre-commit-config.yaml file in your repo doesn’t do anything by itself — it’s just a list of instructions sitting there. To actually activate it, run:

uv sync --extra dev
uv run pre-commit install

The first command installs everything in your dev dependencies — including pre-commit itself — into the project’s virtual environment. The second is the one that matters day-to-day: it writes a small script into .git/hooks/pre-commit, which is what Git looks for and runs automatically every time someone types git commit. Skip this step and the YAML file just sits there unused.

There’s also a manual escape hatch worth knowing. If you want to run every hook against every file in the project right now, without committing anything — useful the first time you set this up, since old files may not be formatted yet — run:

uv run pre-commit run --all-files

Think of install as flipping the switch on, and run --all-files as one big cleanup pass before the switch starts enforcing things going forward.

The core formatting and linting hooks

  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black
  - repo: https://github.com/PyCQA/isort
    rev: 5.13.2
    hooks:
      - id: isort
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.9.0
    hooks:
      - id: mypy
        additional_dependencies: []
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff

This is the same lineup we configured back in pyproject.toml, just wired up to actually run.

Black reformats your code automatically — quotes, spacing, line breaks — so nobody has to argue about style in code review ever again. isort sorts and groups your imports the same way every time. Mypy checks your type hints and catches a category of bugs before the code is even executed. Ruff lints for errors, unused imports, and common mistakes Black and isort don’t touch.

Think of it like a car going through inspection before it’s allowed on the road. Black checks the paint job. isort checks that everything’s in its proper compartment. Mypy checks the engine actually works the way the manual says it should. Ruff checks for anything that looks like it’s about to break down.

One detail worth noticing: the mypy hook has additional_dependencies: []. Pre-commit hooks run in their own isolated environment, separate from your project’s virtual environment. If mypy needs to understand the types of a library you’re importing (say, pandas-stubs), you’d list it here. Right now it’s empty, meaning mypy is only checking your own code without any extra type stubs.

Handling notebooks separately

This is the part that doesn’t show up in a typical Python project, but matters a lot for research and paper-replication work, where Jupyter notebooks are part of the codebase.

  # Strip notebook outputs before committing — keeps diffs clean
  - repo: https://github.com/kynan/nbstripout
    rev: 0.7.1
    hooks:
      - id: nbstripout

Notebooks don’t just store code — they store output too. Run a cell that prints a 10,000-row dataframe or renders a matplotlib chart, and that output gets embedded directly into the .ipynb file as part of its saved state.

Now imagine two people running the same notebook on different machines. Same code, but the embedded outputs differ slightly — different timestamps, different random seeds, different image encodings. Git sees this as a change and shows it in the diff, even though nobody touched a single line of actual logic. Multiply that across a team and your commit history turns into noise.

nbstripout solves this by removing all outputs before the notebook is committed. The code stays. The clutter doesn’t. Think of it like requiring everyone to clean their desk before leaving the office — what’s saved is the work itself, not yesterday’s coffee stains.

  # Run black, isort, mypy inside notebooks
  - repo: https://github.com/nbQA-dev/nbQA
    rev: 1.8.5
    hooks:
      - id: nbqa-black
        additional_dependencies: [black==24.4.2]
      - id: nbqa-isort
        additional_dependencies: [isort==5.13.2]
      - id: nbqa-mypy
        additional_dependencies: [mypy==1.9.0]

Here’s a subtlety: Black, isort, and Mypy are built to understand .py files. A .ipynb file isn’t a plain Python file — it’s a JSON structure containing cells, metadata, and outputs all bundled together. Pointed at a notebook directly, these tools wouldn’t know what they’re looking at.

nbQA is the translator in the middle. It extracts the actual code from each notebook cell, hands it to Black, isort, or Mypy as if it were ordinary Python, then writes the result back into the right cells. That’s why each nbqa hook pins its own version of the underlying tool with additional_dependencies — nbqa-black needs to know it’s specifically running Black 24.4.2, matching the version used everywhere else in the project, so formatting stays consistent whether you’re editing a .py file or a notebook cell.

What happens when you commit

Once this file exists and pre-commit is installed, the flow looks like this:

You write code → you run git commit → pre-commit intercepts the commit → each hook runs in order → if everything passes, the commit goes through → if something fails, the commit is blocked and the tool tells you exactly what to fix.

Some hooks, like Black and isort, will actually fix the problem automatically and just ask you to re-stage the file. Others, like Mypy, will only report the problem and leave the fixing to you.

Why this matters more than it seems

Without pre-commit, code style becomes a conversation that happens in code review, over and over, for the same handful of issues. With pre-commit, that conversation never has to happen — the bouncer already turned away anything that didn’t belong, before it ever reached a human reviewer.

What’s next?

At this point, your project has a clear configuration in pyproject.toml, and Git automatically enforces it on every commit. But there’s still one piece missing: how does a new contributor — or future you, six months from now — actually understand how the project works?

That’s where documentation comes in. In the next post, we’ll take all of this structure and turn it into a real, browsable developer site using MkDocs.

Leave a Reply

Your email address will not be published. Required fields are marked *