Skip to content

Elevate Your Git Workflow: A Guide to Using pre-commit

Every developer has pushed a commit they immediately regretted: a trailing whitespace violation that failed the linter, a file left with Windows line endings, a secret accidentally included in a configuration file, or a Go source file that was never formatted with gofmt. These are the kinds of issues that are trivial to catch but easy to forget under deadline pressure. Pre-commit hooks are the last line of defense between your editor and your repository, and pre-commit is the framework that makes managing them across multiple languages and projects practical.

This post covers what pre-commit is, why you should consider it in your development workflow, how to get it running on your machine, how to run it in CI, and how to use your own hook repository.

What Is pre-commit?

pre-commit is a framework for managing and running Git hooks. A Git hook is a script that Git executes at specific points in the workflow. The pre-commit hook runs just before a commit is finalized, which makes it the ideal place to catch formatting violations, run linters, validate configurations, and check for secrets. If any hook fails, the commit is rejected and the developer sees exactly what needs to be fixed.

Without a framework, managing hooks directly means writing shell scripts, copying them from project to project, and keeping them in sync across a team. pre-commit replaces all of that with a single declarative configuration file, a plugin system that downloads hooks from Git repositories, and a consistent interface that works for any language or tool.

The framework runs hooks against only the files staged for the current commit by default. This keeps hook execution fast, even in large repositories. You can also run hooks explicitly against all files, which is useful during initial setup or in CI.

The pre-commit Framework and the pre-commit Hook

The term "pre-commit" is overloaded. The pre-commit hook is a Git concept: a script that runs before every commit. The pre-commit framework, from pre-commit.com, is the tool that manages those scripts. Throughout this post, "pre-commit" refers to the framework unless stated otherwise.

Why Use It?

Catch Issues Before They Reach CI

CI pipelines, like GitHub Actions, are invaluable, but they are a slow feedback loop for static analysis. A linting failure that could have been caught early on your laptop instead burns several minutes of CI time and forces a context switch back to a problem you thought was done. Pre-commit hooks close that loop at the source.

Consistent Standards Across the Team

When hooks are version-controlled in a .pre-commit-config.yaml file, every contributor who runs pre-commit install gets the same checks at commit time. Standards stop being a verbal agreement and start being an enforced constraint. New contributors onboard to the same quality bar automatically.

Multi-Language, One Tool

A single pre-commit configuration can run gofmt on Go files, terraform fmt on Terraform files, shellcheck on shell scripts, and markdown-link-check on Markdown documentation, all from the same framework without requiring separate tooling for each language.

Runs Only on Changed Files

By default, pre-commit runs each hook only against the files staged in the current commit, not the entire repository. This means hooks stay fast even as the codebase grows.

Installation

pre-commit is a Python-based tool distributed via pip. It also has native packages for popular package managers.

Install with Homebrew:

brew install pre-commit

Verify the installation:

pre-commit --version

Install with pip (Python 3.8 or later required):

pip install pre-commit

On Debian and Ubuntu, you can also install from the system package manager:

sudo apt install pre-commit

Verify the installation:

pre-commit --version

Install with pip (Python 3.8 or later required):

pip install pre-commit

Or with Scoop:

scoop install pre-commit

Verify the installation:

pre-commit --version

Getting Started

Create the Configuration File

At the root of your repository, create a file named .pre-commit-config.yaml. This file declares which hook repositories to pull from and which hooks within each repository to run.

Here is a minimal example that covers a few common use cases:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files

Each repo entry specifies:

  • repo: the URL of a Git repository that contains hook definitions
  • rev: the tag or commit hash to pin. A tag like v6.0.0 works, but a full commit hash is even better because tags are mutable and can be moved. The same reasoning applies here as with pinning GitHub Actions to commit hashes. If you use a branch name or any other moving reference, pre-commit will warn you and the reference will never be updated automatically:

    [WARNING] The 'rev' field of repo 'https://github.com/pre-commit/pre-commit-hooks'
    appears to be a mutable reference (moving tag / branch).  Mutable references are
    never updated after first install and are not supported.  See
    https://pre-commit.com/#using-the-latest-version-for-a-repository for more details.
    Hint: `pre-commit autoupdate` often fixes this.
    

    Run pre-commit autoupdate to let the tool resolve the latest tag for each configured repository and rewrite rev in your config file.

  • hooks: the list of hook IDs from that repository to enable

Install the Git Hook

After creating the configuration file, run the following command from the root of your repository:

pre-commit install
pre-commit installed at .git/hooks/pre-commit

This installs a .git/hooks/pre-commit script that invokes the pre-commit framework every time you run git commit. The first time hooks run, pre-commit downloads and caches each hook repository. You will see an info message for each:

[INFO] Initializing environment for https://github.com/pre-commit/pre-commit-hooks.

Subsequent runs use the local cache, so they are fast.

Run Hooks Manually

You can run all configured hooks against the currently staged files at any time:

pre-commit run

To run against every file in the repository regardless of staged status:

pre-commit run --all-files

To run a single hook by its ID:

pre-commit run trailing-whitespace --all-files

What Happens on Commit

When you run git commit, pre-commit intercepts the operation and runs every configured hook against the staged files.

flowchart LR
    A([git commit]) --> B[pre-commit\nintercepts]
    B --> C[Run each hook\nagainst staged files]
    C --> D{All hooks pass?}
    D -- Yes --> E([Commit created])
    D -- No --> F[Commit blocked\nReport issues]
    F --> G[Fix and\ngit add]
    G --> A

The output looks like this:

Trim Trailing Whitespace.................................................Passed
Fix End of Lines.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed

If a hook fails, the commit is blocked and you see what went wrong:

Trim Trailing Whitespace.................................................Failed
- hook id: trailing-whitespace
- exit code: 1
- files were modified by this hook

docs/guide.md

Many hooks automatically fix what they find (for example, stripping trailing whitespace or reformatting code). After a hook modifies a file, stage the change and commit again.

Skipping Hooks in Exception Cases

If you need to make a commit that bypasses hooks for an exception, you can pass --no-verify to git commit. Use this sparingly. The value of pre-commit hooks comes from running them consistently, and bypassing them should be the exception, not the workflow.

git commit --no-verify -m "chore: work in progress"

GitHub Desktop

GitHub Desktop runs pre-commit hooks when you commit through the UI, so your hooks work without any additional setup. However, the icon in the commit panel exposes a Bypass Commit Hooks option that has the same effect as --no-verify on the command line. If your team uses GitHub Desktop, remind contributors that this option exists and should be reserved for the same exceptional circumstances as --no-verify. Relying on it regularly defeats the purpose of enforcing hooks.

Running pre-commit in CI

Running pre-commit hooks locally catches most issues before they reach the repository, but enforcing the same checks in CI ensures that no commit slips through without passing all hooks, regardless of whether the contributor installed pre-commit locally.

Why Run Pre-commit in CI?

Running pre-commit in your CI pipeline is not just a backup for developers who forgot to install their hooks. There are several reasons to treat it as a first-class part of your workflow:

  • The --no-verify problem. Developers can bypass local hooks with git commit --no-verify. This is occasionally valid (work in progress, bootstrapping a new machine), but when it becomes habitual it quietly erodes the value of your hooks. CI has no --no-verify flag. If the hooks run in CI, the guarantee holds regardless of what anyone does locally.

  • A single source of truth. Your .pre-commit-config.yaml becomes the centralized, definitive rulebook for formatting, linting, and static analysis across the entire repository. All contributors, all operating systems, all editors: one config, one standard.

  • Guaranteed consistency. CI does not care whether a contributor is on macOS with Homebrew or Windows with pip. It does not care whether their IDE reformats on save. Every pull request is evaluated against the same checks, with the same tool versions, in the same environment.

  • Easy maintenance. Instead of writing separate workflow steps to run gofmt, golangci-lint, shellcheck, and terraform fmt individually, a single pre-commit run --all-files step handles everything declared in your config. Adding a new tool is a one-line change to .pre-commit-config.yaml, not a workflow rewrite.

GitHub Actions

The following workflow runs all pre-commit hooks against every file on every push and pull request to the main branch. It uses Python to install pre-commit directly, which works for any hook that does not require additional system tools.

name: Pre-commit

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  pre-commit:
    name: Pre-commit
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Setup Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: '3.x'

      - name: Install pre-commit
        run: pip install pre-commit

      - name: Run pre-commit
        run: pre-commit run --all-files

Hooks that require external tools

If your hooks depend on tools like golangci-lint, terraform, or shellcheck, you need to install those tools in the workflow before running pre-commit. Add the appropriate setup steps before the Run pre-commit step. For example, to include Go-based hooks:

- name: Setup Go
  uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
  with:
    go-version-file: go.mod

- name: Install goimports
  run: go install golang.org/x/tools/cmd/goimports@latest

Exit Codes

pre-commit run --all-files exits with code 0 when all hooks pass, and with a non-zero code when any hook fails or modifies a file. This maps cleanly to the pass/fail semantics that CI systems expect.

Defining Your Own Hooks

When the community hook index does not have what you need, or when you want checks that are specific to your project or organization, pre-commit gives you two options: local hooks that run tools already on the machine, and hook repositories that package hooks for reuse across projects.

Local Hooks

The quickest way to add a project-specific hook is the local repo type. Local hooks use language: system, which means pre-commit invokes whatever tool is already installed on the machine. The hook definitions live directly in .pre-commit-config.yaml, so there is no separate repository to create or publish.

repos:
  - repo: local
    hooks:
      - id: gofmt
        name: gofmt
        language: system
        entry: gofmt -l -w
        types: [go]
      - id: goimports
        name: goimports
        language: system
        entry: goimports -w
        types: [go]

pre-commit passes the list of matched files as arguments to the entry command, so gofmt -l -w receives only the staged Go files, not the entire tree.

Local hooks are a good fit when:

  • The tool is already a project dependency (the Go toolchain, a Node package, etc.)
  • The check is specific to this repository and unlikely to be shared elsewhere
  • You want to get something running in minutes without a separate repository

The tradeoff is that every developer and every CI runner must have the required tools installed and on PATH. If goimports is not installed, the hook fails.

Hook Repositories

If you want to share hooks across multiple projects or teams, or if you want pre-commit to manage the tool installation for you, you can publish a dedicated hook repository. A hook repository needs two things: a .pre-commit-hooks.yaml file at the root that declares the hooks, and the scripts or binaries the hooks invoke.

The .pre-commit-hooks.yaml Format

This file lists one or more hook definitions:

- id: my-linter
  name: My Linter
  description: Runs my-linter on all source files.
  entry: hooks/my-linter.sh
  language: script
  files: \.go$

Each hook definition supports the following fields:

Field Required Description
id Yes Unique identifier used in .pre-commit-config.yaml.
name Yes Human-readable display name shown in hook output.
description No Short description of what the hook does.
entry Yes Path to the script or command to run, relative to the repository root.
language Yes How pre-commit should invoke the hook (script, python, node, etc.).
files No Regex pattern; the hook runs only on files whose paths match.
exclude No Regex pattern; matching files are excluded even if files matches.
require_serial No When true, the hook runs once for all files rather than in parallel.

A Minimal Shell Hook

The simplest hook is a shell script invoked with language: script. Here is a hook that checks whether Go source files are formatted with gofmt:

.pre-commit-hooks.yaml:

- id: gofmt
  name: gofmt
  description: Run 'gofmt' to format Go code.
  entry: hooks/gofmt.sh
  language: script
  files: \.go$
  exclude: vendor\/.*$

hooks/gofmt.sh:

#!/usr/bin/env bash
set -euo pipefail

unformatted=$(gofmt -l "$@")

if [ -n "$unformatted" ]; then
  echo "The following files are not formatted with gofmt:"
  for f in $unformatted; do
    echo "  $f"
  done
  gofmt -w "$@"
  exit 1
fi

Make the script executable:

chmod +x hooks/gofmt.sh

Shell Scripts and Windows

A .sh script with language: script requires a POSIX shell to run, so it works on macOS and Linux but will fail on Windows unless the user has Git Bash, WSL, or a similar environment configured.

If you need the hook to work on Windows without any extra setup, skip the shell script and use language: system with a direct binary entry instead (the same approach used for local hooks):

- id: gofmt
  name: gofmt
  description: Run 'gofmt' to format Go code.
  entry: gofmt -l -w
  language: system
  types: [go]

language: system passes the staged files directly to the binary, which works on any operating system where the binary is on PATH. The tradeoff is that the tool must already be installed; pre-commit cannot manage the environment for you.

Referencing Your Hook Repository

Once your repository is published, anyone can reference it in their .pre-commit-config.yaml:

repos:
  - repo: https://github.com/your-org/your-pre-commit-hooks
    rev: v1.0.0
    hooks:
      - id: gofmt

The first time a contributor runs pre-commit install or pre-commit run, the framework clones the hook repository at the specified rev and caches it locally in ~/.cache/pre-commit. Subsequent runs use the cache and are fast.

Testing Hooks Locally Before Publishing

During development, you can point rev at a branch while iterating. Note that pre-commit will emit a warning about the mutable reference and will not update it automatically. Before publishing, switch to a pinned tag or commit hash.

repos:
  - repo: https://github.com/your-org/your-pre-commit-hooks
    rev: your-feature-branch  # For development only; pin before merging.
    hooks:
      - id: gofmt

Once the branch is tagged and published, run pre-commit autoupdate to resolve the latest tag and rewrite rev in your config automatically.

Local vs. Hook Repository

Criteria Local (repo: local) Hook Repository
Setup effort None: hooks live in your config Requires a separate Git repository
Tool installation Tools must be pre-installed pre-commit can manage environments
Shareable across projects No Yes
Works offline after first use Yes Yes (cached after first clone)
Best for Quick, project-specific checks Reusable tooling shared across repos

References

  • pre-commit.com: Official documentation, hook index, and supported languages.
  • pre-commit/pre-commit-hooks: The standard hook library maintained by the pre-commit project, including trailing-whitespace, end-of-file-fixer, check-yaml, and many others.
  • Supported hooks: A searchable directory of community-maintained hook repositories.