While manually running linters and formatters ensures code quality at a given moment, it is a fragile system that relies on developer discipline. Code style inconsistencies inevitably creep in between runs, especially in collaborative environments. Pre-commit hooks solve this problem by automating the enforcement process, shifting quality assurance “left” in the development lifecycle to occur before a commit is even created. This transforms code style from a periodic chore into an automatic, non-negotiable gate.

A pre-commit hook is a script triggered by the version control system, Git, at specific points in its workflow. The pre-commit hook, in particular, is executed before a commit message is finalized. If this script exits with a non-zero status, Git aborts the commit, preventing any code that violates the defined rules from entering the repository history. This mechanism is ideal for running fast, non-destructive checks like linters and formatters.

Configuring the pre-commit Framework

Managing native Git hooks directly is cumbersome; they are stored in .git/hooks, which is not versioned and doesn’t easily share across a team. The pre-commit framework provides a professional, declarative solution. It is installed via Pip and configured using a version-controlled .pre-commit-config.yaml file at the root of the repository. This file defines a list of “repositories” (sources for hooks) and the specific hooks to run from them.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace  # Trims trailing whitespace
      - id: end-of-file-fixer    # Ensures a newline at end of file
      - id: check-yaml           # Lints YAML files
      - id: check-added-large-files # Blocks large files

  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black
        # Language version must be consistent with your project
        args: [--target-version, py311]

  - repo: https://github.com/charliermarsh/ruff-pre-commit
    rev: v0.1.11
    hooks:
      - id: ruff
        # Run the linter
        args: [--fix, --exit-non-zero-on-fix]
      - id: ruff-format
        # Or, run the formatter

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.8.0
    hooks:
      - id: mypy
        args: [--ignore-missing-imports]
        # Additional args for your project's config

Installation and Initial Setup

Once the config file is created, developers must install the framework into their local Git repository. This is a one-time per-clone setup command.

# Install the pre-commit package manager
pip install pre-commit

# Navigate to your project root and install the hooks into git
pre-commit install

After installation, the pre-commit hook is active. The first git commit command after this will trigger the framework to install the environments for each hook defined in the config (e.g., installing Ruff and Black in isolated environments) and then run them.

The Workflow: Commit, Fix, Repeat

The standard workflow is intentionally designed to be seamless. When a developer runs git commit, the pre-commit hooks execute against the staged files.

  1. The Hook Runs: Pre-commit passes the staged files to each hook in the order defined in the config.
  2. Two Possible Outcomes:
    • Success (All hooks pass): The commit proceeds as normal.
    • Failure (A hook fails): The commit is aborted. The terminal output will clearly show which hook failed and on which file.

Crucially, many modern tools like ruff and black can fix violations automatically. If ruff finds a linting error it can auto-fix, it will correct the file and then fail the hook (because it modified the code). This is the desired behavior. The developer must then re-stage the automatically fixed files (git add .) and run git commit again. This second commit will now pass. This “fail, fix, re-stage” loop is a core best practice.

$ git commit -m "Add new feature"
ruff...............................................Failed
- hook id: ruff
- files were modified by this hook

Fixing path/to/file.py

$ git status
# You'll see the modified file is not staged
$ git add .  # Add the fixes that pre-commit made
$ git commit -m "Add new feature" # This will now succeed

Best Practices and Common Pitfalls

  • Exclude Files Judiciously: Use the exclude or files properties in your hook config to avoid running linters on generated files, migrations, or large data files. This saves significant time.
  • CI is the Ultimate Enforcer: Pre-commit hooks are a developer convenience, but they can be skipped with git commit --no-verify. Therefore, you must run the same checks (e.g., pre-commit run --all-files) in your Continuous Integration (CI) pipeline. CI is the final, un-skippable gatekeeper.
  • Version Pinning is Critical: Always pin the rev of a hook repository to an explicit tag (e.g., v23.12.1). Using main can introduce breaking changes unexpectedly, destroying team consistency.
  • Manage Dependencies: If your hooks require specific system libraries (e.g., libpython3-dev), document this clearly in your setup guide, as the isolated hook environments will not have them.
  • Run on All Files: Use pre-commit run --all-files to check your entire codebase after first setting up the hooks or adding a new one. This ensures existing code is also brought up to standard.