# pre-commit Hooks

> Catch problems before they're committed: the pre-commit framework runs formatters, linters, and secret scanners automatically on every git commit.


---

# pre-commit Hooks

You know the feeling: you push, the CI goes red, and the failure is something tiny - a stray trailing space, an unformatted file, a debug `print` you forgot, an AWS key you pasted into a config to test. Five minutes of waiting to learn you broke a rule a machine could have caught in half a second. pre-commit hooks move that check to the moment you commit, on your machine, before the mistake ever leaves your laptop.

This guide is about stopping the bad commit at the door - automatically, the same way for everyone on the team, with one small config file checked into the repo.

## How to read this

Go in order. Phase 1 builds the mental model: what a git hook actually is, and why a *framework* sits on top of it. Phase 2 is the everyday loop: writing `.pre-commit-config.yaml`, installing, and what happens on each commit. Phase 3 is the reality: bypassing, CI enforcement, and the gotchas that bite teams. If you only have ten minutes, read Phase 1 - the rest will make sense once the model clicks.

## The phases

1. [Phase 1: What a Hook Actually Is](01-what-a-hook-is.md) - the mental model: git hooks, and the framework that tames them.
2. [Phase 2: The Config and the Commit Loop](02-the-config-and-loop.md) - `.pre-commit-config.yaml`, installing, and running on staged files.
3. [Phase 3: Bypassing, CI, and the Gotchas](03-bypassing-ci-and-gotchas.md) - fixing vs failing, enforcement, and what breaks in real teams.


---

# What a Hook Actually Is

Here's the reality you're starting from. You make a change, you commit, you push, and *then* a linter somewhere decides it doesn't like your code. The feedback loop is long and it happens far from where the mistake was made. What you want is for the check to run at the exact moment the mistake exists - when you type `git commit` - so you can fix it while the code is still fresh in your head and still on your machine.

That moment-of-commit check is what a *git hook* is. Understanding the plain git mechanism first makes the framework on top of it obvious, so let's start there.

## Git hooks: scripts git runs for you

Git has always been able to run your own scripts at certain points in its lifecycle. These are called hooks, and they live in a folder inside every repo.

```bash
ls .git/hooks/
```

```text
applypatch-msg.sample      pre-commit.sample
commit-msg.sample          pre-push.sample
post-update.sample         prepare-commit-msg.sample
pre-rebase.sample          update.sample
```

*What just happened:* every repo ships with example hooks, all ending in `.sample` so git ignores them. The names *are* the trigger points. The one we care about is `pre-commit`: git runs it right before a commit is finalized. If that script exits with a non-zero status, git aborts the commit.

That last sentence is the whole mental model. A pre-commit hook is a gate. Exit `0`, the commit goes through. Exit non-zero, the commit is blocked and nothing is recorded. So if you put your linter in that script and it finds a problem, the bad commit never happens.

You could write that script by hand. Rename `pre-commit.sample` to `pre-commit`, make it run your linter, done. So why does a whole framework exist?

## Why hand-rolled hooks fall apart

Try to run a real team on raw `.git/hooks/` scripts and you hit three walls fast.

The first is that **`.git/hooks/` is not part of the repo.** Everything inside `.git/` is local to your clone and is never committed or pushed. Write a perfect hook, and your teammate who clones the repo gets... nothing. There's no way to share the hook through git itself, which is the one tool everyone already has.

The second is that **a hook is one script, but you want many checks** - a Python formatter, a YAML validator, a secret scanner, a "did you leave a merge conflict marker in" check. Cramming all of those into one bash script, each with its own install steps and versions, turns into a maintenance pit.

The third is **language and version drift.** Your formatter needs a specific version to behave consistently. If it's installed differently on every machine, "it formats fine on mine" becomes a daily argument. You want the *same* tool at the *same* version for everyone, isolated from whatever else is on the machine.

```text
Raw hook                          The problem it can't solve
------------------------------    ---------------------------------
.git/hooks/pre-commit  (local) →  teammates never get it
one bash script        (rigid) →  many checks, tangled together
"works on my machine"  (drift) →  different tool versions everywhere
```

*What just happened:* each weakness of the raw mechanism maps to exactly one thing the framework provides - sharing, composition, and pinned isolated tools.

## The framework: hooks as managed, shared config

The pre-commit framework (the tool is literally named `pre-commit`) is a thin manager that solves all three. Instead of writing a script, you write a small config file - `.pre-commit-config.yaml` - that *lists* the checks you want. You commit that file. Now it travels with the repo like any other source.

When someone runs one setup command, the framework writes the actual `.git/hooks/pre-commit` script for them, pointed back at the shared config. Each check (called a "hook") is pulled from a repo at a pinned version and installed into its own isolated environment, so everyone runs the identical tool.

```text
.pre-commit-config.yaml   ← you write & COMMIT this (shared)
        │
        │  pre-commit install   (each dev runs once)
        ▼
.git/hooks/pre-commit      ← framework generates this (local)
        │
        │  on every `git commit`
        ▼
runs each hook from a pinned repo, in its own env
```

*What just happened:* you maintain one committed file; the framework turns it into the per-clone machinery. The thing you share is config, not a script, and that's the entire shift in thinking.

> The word "hook" now means two things, and that's worth holding clearly. There's git's `pre-commit` *event* (the moment), and there are the individual *hooks* you list in the config (each formatter or linter). The framework is the bridge: it registers itself on git's event, then runs your list of hooks when the event fires.

## What this buys you

The payoff is a class of mistakes that can't reach the shared history anymore: unformatted code, broken YAML, leftover `print` debugging, a leaked credential. They get caught on the laptop, in the second before the commit, by the same checks for every single person on the team. Nobody waits on CI to learn they left a trailing space.

For builders: this is the local half of a quality strategy. The same checks can run again in CI as a backstop - if [continuous integration](/guides/what-cicd-does) is the net at the end, pre-commit is the catch at the source. Phase 3 covers running both from one config.

```quiz
[
  {
    "q": "What does git do when a pre-commit hook script exits with a non-zero status?",
    "choices": ["Commits anyway and logs a warning", "Aborts the commit", "Retries the hook three times", "Pushes the commit but marks it failed"],
    "answer": 1,
    "explain": "A non-zero exit is the gate slamming shut: git aborts the commit and records nothing."
  },
  {
    "q": "Why can't you simply share a script in .git/hooks/ with your team through git?",
    "choices": ["Hooks must be written in bash", "The .git/ directory is local and never committed or pushed", "GitHub strips hook files on push", "Hooks require admin permissions"],
    "answer": 1,
    "explain": "Everything under .git/ is local to your clone, so a hand-placed hook never travels with the repo."
  },
  {
    "q": "What is the one file you write and commit so the whole team shares the same checks?",
    "choices": [".git/hooks/pre-commit", "pre-commit.toml", ".pre-commit-config.yaml", "hooks.json"],
    "answer": 2,
    "explain": ".pre-commit-config.yaml lists your hooks and is committed, so it travels with the repo."
  }
]
```


---

# The Config and the Commit Loop

You've got the model: a committed config file, turned into a real git hook by the framework. Now you'll actually wire it up and feel the loop. By the end of this phase you'll write `.pre-commit-config.yaml`, install it, and watch a commit get caught and fixed.

## Step one: install the tool, install the hook

There are two different installs here, and conflating them is the most common early stumble. First you install the `pre-commit` *program* (once per machine). Then, inside a repo, you run `pre-commit install` to write the git hook (once per clone).

```bash
pip install pre-commit      # the program, once per machine
cd my-project
pre-commit install          # the git hook, once per clone
```

```text
pre-commit installed at .git/hooks/pre-commit
```

*What just happened:* the framework generated `.git/hooks/pre-commit` for this clone. From now on, every `git commit` in this repo triggers the framework. Note what's still missing - you haven't told it *what to run* yet. That's the config file.

## Step two: write the config

The config is a list of repos, and from each repo you pick the hooks you want. Here's a realistic starter for a Python project.

```yaml
# .pre-commit-config.yaml
repos:
  - 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-added-large-files
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff           # the linter
      - id: ruff-format    # the formatter
```

*What just happened:* you declared two source repos. `rev` pins each to an exact version - this is the "everyone runs the identical tool" guarantee from Phase 1. Under each, `hooks:` lists the specific checks by their `id`. The framework knows how to fetch and install each one; you don't manage their dependencies yourself.

A few of these earn their place in almost any repo, so it's worth knowing what they do:

```text
trailing-whitespace      strips trailing spaces from lines
end-of-file-fixer        ensures files end in exactly one newline
check-yaml               parses YAML files, fails on syntax errors
check-added-large-files  blocks accidentally committed big binaries
```

*What just happened:* these are cheap, language-agnostic safety nets. They catch the dull mistakes - a giant file you `git add`-ed by accident, a YAML you broke with a stray indent - that would otherwise surface much later.

## Step three: the commit loop

Now the payoff. You stage a change and commit. The framework runs your hooks **on the staged files only** - not your whole repo, only what this commit touches. That's what keeps it fast.

```console
$ git add app.py
$ git commit -m "add user lookup"
trim trailing whitespace.................................................Failed
- hook id: trailing-whitespace
- files were modified by this hook
Fixing app.py
ruff.....................................................................Passed
ruff-format..............................................................Passed
```

*What just happened:* `trailing-whitespace` found a problem **and fixed it** - see "files were modified by this hook." The overall run Failed, so the commit was *aborted*. This is the key behavior of fixing hooks: they edit the file, but the fix is now an *unstaged* change, so git won't include it silently. You're being told to look.

This trips up everyone once. A formatter that "passes by fixing" still fails the commit, on purpose, so a machine never rewrites your code into a commit without you seeing it. The fix is to re-stage and commit again.

```console
$ git add app.py          # stage the fix the hook made
$ git commit -m "add user lookup"
trim trailing whitespace.................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
[main 3f1c9ab] add user lookup
```

*What just happened:* with the auto-fix staged, every hook passes and the commit lands. The loop is: commit → hook fixes or complains → you stage the fix → commit again. After a day of this it's muscle memory.

## Fixing hooks vs failing hooks

Hooks come in two flavors, and the difference shapes your day.

```text
FIXING hook   (formatter)   edits the file, fails the commit so you
                            re-stage. The fix is handed to you.
FAILING hook  (linter)      reports a problem it can't fix for you,
                            fails the commit. You edit, then re-stage.
```

*What just happened:* a formatter like `ruff-format` rewrites the file for you; a linter like `ruff` (without `--fix`) can only point at the line. Both block the commit; only one does the work for you. Knowing which is which tells you whether to expect an auto-edit or a to-do.

## Running it on demand

You don't have to commit to run the checks. This is essential the first time you adopt pre-commit on an existing repo, and for debugging.

```bash
pre-commit run --all-files
```

```text
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
check yaml...............................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
```

*What just happened:* `--all-files` ignores staging and checks the entire repo. Run this right after adding the config so you fix the whole codebase in one pass, instead of being ambushed file-by-file over the next week of commits.

> First-run note: the very first commit (or first `run`) after adding a hook is slow - the framework is downloading and building each tool's isolated environment. It caches them, so every run after is fast. Don't panic at the initial pause.

For builders: keep the config small at first. Three or four cheap hooks that everyone tolerates beat twenty strict ones that make people reach for `--no-verify` (Phase 3) on day one. You can always add more once the team trusts the loop.

```quiz
[
  {
    "q": "By default, which files does pre-commit run your hooks against during a commit?",
    "choices": ["Every file in the repository", "Only the staged files in that commit", "Only files changed since the last push", "Files listed in .gitignore"],
    "answer": 1,
    "explain": "It runs on the staged files only, which is what keeps each commit's checks fast."
  },
  {
    "q": "A formatter hook reports 'files were modified by this hook' and the commit fails. What do you do?",
    "choices": ["Run git commit --amend", "Delete the hook from the config", "Re-stage the fixed file with git add, then commit again", "Nothing - the commit already went through"],
    "answer": 2,
    "explain": "The auto-fix is left unstaged on purpose; stage it with git add and commit again."
  },
  {
    "q": "Why run `pre-commit run --all-files` right after first adding the config?",
    "choices": ["It's required before installing", "To fix the whole existing codebase in one pass instead of file-by-file", "It uninstalls old hooks", "It pushes the config to teammates"],
    "answer": 1,
    "explain": "--all-files checks the entire repo at once, so you clean up everything up front."
  }
]
```


---

# Bypassing, CI, and the Gotchas

The loop from Phase 2 works on your machine, for you. Phase 3 is about the rest of reality: the moment you need to bypass a hook, the fact that a local hook is a suggestion and not a wall, and how teams turn it into actual enforcement. This is where pre-commit goes from "nice for me" to "trusted by the team."

## The escape hatch - and why it's a trap door

A local hook can always be skipped. Git itself provides the flag.

```bash
git commit --no-verify -m "wip: debugging prod, fix lint later"
```

```text
[hotfix 9b2e1aa] wip: debugging prod, fix lint later
```

*What just happened:* `--no-verify` (short flag `-n`) told git to skip every hook entirely. The commit landed with zero checks. There are real reasons for this - a 2am hotfix where the linter is the last thing you care about - and it's fine that the escape hatch exists.

The trap is the lesson it teaches: **a local pre-commit hook is advisory, not enforcement.** Anyone can bypass it, on purpose or by forgetting to run `pre-commit install` after cloning. If your team's quality bar lives *only* in local hooks, it isn't really a bar - it's a polite request. That single fact is why the next section exists.

You can also skip *one* hook instead of all of them, which is the sensible middle ground:

```bash
SKIP=ruff git commit -m "intentional pattern ruff flags here"
```

*What just happened:* the `SKIP` environment variable names a hook (by its `id`) to skip while every other hook still runs. Better than `--no-verify`, because you're surgically opting out of one check rather than turning off the whole gate.

## Enforcement lives in CI, not on the laptop

Because local hooks are skippable, the real wall is a server that re-runs the same checks and won't merge until they pass. The beauty of pre-commit is that you don't write a second config for this - CI runs the *exact same* `.pre-commit-config.yaml`.

```yaml
# .github/workflows/lint.yml
name: lint
on: [pull_request]
jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - run: pip install pre-commit
      - run: pre-commit run --all-files
```

*What just happened:* CI installs pre-commit and runs `--all-files` on the pull request. If anyone committed with `--no-verify`, this step fails and blocks the merge. The local hook is now a *convenience* (fast feedback while you work) and CI is the *enforcement* (the thing nobody can skip). Same checks, two places.

```text
LOCAL hook   fast, on your machine, skippable    → convenience
CI run       slower, on a server, unskippable    → enforcement
```

*What just happened:* this split is the mental model to keep. Don't try to make the local hook unbypassable - you can't, and you'd only frustrate people. Make CI the source of truth and let the local hook be the head start. This pairs naturally with the rest of your [CI/CD pipeline](/guides/what-cicd-does).

## The high-value hook: catching secrets

If you add one thing beyond formatters, make it a secret scanner. A leaked API key or password in git history is a genuine emergency - git remembers it forever, even after you delete the line.

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

```console
$ git commit -m "add config"
gitleaks.................................................................Failed
- hook id: gitleaks
- exit code: 1

Finding:     aws_secret = "AKIA...REDACTED..."
File:        config.py
Secret:      AKIA...REDACTED...
RuleID:      aws-access-token
```

*What just happened:* the scanner found something shaped like an AWS key and aborted the commit before the secret ever entered history. This is the single highest-value check on the list - it turns a credential-rotation fire drill into a five-second "oh, right, move that to an env var." Pair it with a strong [.gitignore](/guides/gitignore-lfs-submodules) so secret-bearing files like `.env` never get staged in the first place.

## Gotchas that actually bite

A handful of real-world snags, each with the fix.

```text
1. Teammate skips the checks entirely
   → They forgot `pre-commit install`. Hooks only fire after that.
     CI is your backstop for exactly this.

2. The pinned versions go stale over months
   → Run `pre-commit autoupdate` to bump every `rev:` to the latest
     release. Review the diff, then commit it.

3. A hook is mysteriously slow on every commit
   → It may be re-checking files it shouldn't. Hooks support `files:`
     and `exclude:` regex to scope what they run on.

4. CI passes but local fails (or vice versa)
   → Different tool versions. The whole point of `rev:` pinning is to
     stop this; make sure CI isn't pip-installing the tool separately.
```

*What just happened:* every one of these traces back to a Phase 1 idea - hooks are local (so they can be missed), and pinning exists to keep environments identical. The fixes are about respecting those facts, not fighting them.

> One mindset note: hooks that are too strict or too slow get bypassed, and a bypassed hook protects nothing. A fast, well-scoped, mostly-auto-fixing config that people actually keep enabled beats a perfect config they route around with `--no-verify` every day. Tune for "people leave it on."

For builders: a healthy setup is three layers - `.gitignore` keeps junk and secrets out of staging, local pre-commit hooks give instant feedback as you work, and CI re-runs the identical config as the unskippable gate. No single layer is trusted alone; together they stop the bad commit at the door and keep it stopped.

```quiz
[
  {
    "q": "What does `git commit --no-verify` do, and what does it reveal about local hooks?",
    "choices": ["Runs hooks twice for safety; hooks are mandatory", "Skips all hooks; local hooks are advisory and can be bypassed", "Verifies the commit signature only", "Forces every hook to auto-fix"],
    "answer": 1,
    "explain": "--no-verify skips every hook, proving local hooks are a convenience, not enforcement."
  },
  {
    "q": "How do you make pre-commit checks genuinely unskippable for a team?",
    "choices": ["Delete the --no-verify flag from git", "Run the same .pre-commit-config.yaml in CI and block merges on failure", "Require admin to commit", "Make the hook exit 0 always"],
    "answer": 1,
    "explain": "CI re-runs the same config on a server nobody can bypass, turning it into real enforcement."
  },
  {
    "q": "Why is a secret-scanning hook considered especially high-value?",
    "choices": ["It makes commits faster", "It auto-formats your code", "It stops a leaked credential before it enters git history, which remembers forever", "It replaces the need for .gitignore"],
    "answer": 2,
    "explain": "Once a secret is in history it stays there even after deletion; catching it pre-commit avoids a rotation emergency."
  }
]
```
