Last Updated: June 01, 2026

Intermediate

Code review comments about formatting, trailing whitespace, and import ordering are a waste of everyone’s time. If a linter can catch it automatically, a human reviewer shouldn’t have to. The problem is that getting developers to manually run Black, Ruff, and mypy before every commit requires discipline that erodes under deadline pressure — and one lapse is all it takes for formatting drift to creep into the codebase.

pre-commit solves this by turning your linters and formatters into Git hooks that run automatically when you type git commit. If any hook fails, the commit is blocked until the issue is fixed. Black even fixes formatting automatically and re-stages the corrected files. The whole setup is declared in a single .pre-commit-config.yaml file that every developer installs once with a single command. Install the tool with pip install pre-commit.

In this tutorial you’ll install pre-commit, configure hooks for Black, Ruff, mypy, and trailing whitespace checks, understand how hooks run and fail, add custom scripts as hooks, and set up the same checks in GitHub Actions so they run in CI. By the end your project will automatically enforce code quality standards on every commit without any manual effort.

Pubs - Python How To Program
Written by Pubs

Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.

View all tutorials by Pubs →

Python pre-commit: Quick Example

Part of the Python Testing & Quality Hub. See the full hub for related Python tutorials.

Here is a minimal working setup. These are terminal and configuration commands, not Python scripts.

# Terminal

# Install pre-commit
pip install pre-commit

# In your project root, create .pre-commit-config.yaml
cat > .pre-commit-config.yaml << 'EOF'
repos:
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black

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

# Install the hooks into .git/hooks/
pre-commit install

# Now try committing a file -- the hooks run automatically
echo "x=1+2; print(x)" > test_code.py
git add test_code.py
git commit -m "test commit"

Output:

black....................................................................Failed
- hook id: black
- files were modified by this hook

reformatted test_code.py

ruff.....................................................................Passed

Black reformatted the file in place (x=1+2 becomes x = 1 + 2) and staged the change, but the commit was blocked. Run git add test_code.py again and re-commit — this time both hooks pass and the commit goes through. The pattern of “fail, fix, re-add, re-commit” takes getting used to, but it becomes muscle memory quickly.

What is pre-commit and How Does It Work?

pre-commit is a framework for managing Git hooks. A Git hook is a script that Git runs automatically at specific points — before a commit (pre-commit), before a push (pre-push), or after a merge (post-merge). The pre-commit framework makes it easy to install and configure hooks from a catalog of community-maintained hooks, without writing shell scripts manually.

Hook stageWhen it runsCommon use
pre-commitBefore creating a commitLinting, formatting, secret detection
pre-pushBefore pushing to remoteRunning tests, type checking
commit-msgAfter writing commit messageEnforcing commit message format
post-checkoutAfter git checkoutInstalling dependencies

Each hook runs in an isolated virtual environment that pre-commit creates and manages automatically. This means the hook’s tools (e.g., Black) are installed at a specific pinned version independent of your project’s virtual environment. Even if your project uses Black 23.x, the pre-commit hook uses whatever version you pinned in .pre-commit-config.yaml — no conflicts.

Tutorial image
pre-commit: the last line of defense before the pull request of shame.

A Full pre-commit Configuration

Here is a production-ready .pre-commit-config.yaml that covers formatting, linting, type checking, and common file issues. Customize by removing hooks you don’t need.

# .pre-commit-config.yaml
# Run 'pre-commit autoupdate' periodically to update pinned versions

default_language_version:
  python: python3.11

repos:
  # General file checks (no extra dependencies required)
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace      # remove trailing spaces
      - id: end-of-file-fixer        # ensure files end with a newline
      - id: check-yaml               # validate YAML syntax
      - id: check-toml               # validate TOML syntax
      - id: check-json               # validate JSON syntax
      - id: check-merge-conflict     # block accidental merge conflict markers
      - id: check-added-large-files  # prevent committing huge files
        args: ['--maxkb=500']
      - id: debug-statements         # catch leftover pdb/breakpoint() calls
      - id: no-commit-to-branch      # prevent direct commits to main/master
        args: ['--branch', 'main', '--branch', 'master']

  # Black -- opinionated code formatter
  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black
        language_version: python3.11

  # Ruff -- fast linter (replaces flake8, isort, and more)
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: [--fix, --exit-non-zero-on-fix]

  # mypy -- optional static type checker (remove if not using type hints)
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests, types-PyYAML]
        args: [--ignore-missing-imports]

The rev field pins each hook to a specific tagged version. Running pre-commit autoupdate updates all these pins to the latest released versions — it’s good practice to run this every month or as part of your dependency update process. The no-commit-to-branch hook is particularly valuable on shared projects: it prevents developers from accidentally pushing directly to the main branch instead of creating a pull request.

Running Hooks Manually and Skipping Them

You don’t have to commit to run the hooks. Several commands let you run hooks on demand or against the entire codebase.

# Terminal -- running hooks manually

# Run all hooks on all staged files (same as a commit would)
pre-commit run

# Run all hooks on ALL files in the repo (not just staged)
pre-commit run --all-files

# Run a single specific hook
pre-commit run black --all-files
pre-commit run ruff --all-files

# Skip hooks entirely for one commit (use sparingly)
git commit -m "WIP: quick fix" --no-verify

# Update all hooks to latest versions
pre-commit autoupdate

# Clear cached hook environments (useful when a hook behaves strangely)
pre-commit clean

# Uninstall hooks (removes from .git/hooks/)
pre-commit uninstall

Output of pre-commit run –all-files (example):

Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
Debug Statements (Python)................................................Passed
black....................................................................Passed
ruff.....................................................................Failed
- hook id: ruff
- exit code: 1

app/models.py:12:1: F401 `os` imported but unused
Found 1 error.

The --no-verify flag completely bypasses all hooks for one commit. Use it only when you have a genuine reason to skip checks (e.g., committing auto-generated code that doesn’t meet style standards, or doing an emergency hotfix). Overusing --no-verify defeats the purpose of having hooks. A better approach for long-term exceptions is to add # noqa: F401 inline comments for specific Ruff suppressions, or configure exclusions in pyproject.toml.

Tutorial image
All hooks passed. The code review will be mercifully short.

Running pre-commit in GitHub Actions

pre-commit hooks run locally on each developer’s machine, but you also want them to run in CI so that PRs from contributors who didn’t install hooks are still checked. GitHub Actions makes this easy.

# .github/workflows/quality.yml

name: Code Quality

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

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      # Cache pre-commit environments to speed up runs
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
          restore-keys: pre-commit-

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

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

The cache step stores the hook environments so they don’t need to be recreated on every CI run. The cache key is based on the hash of .pre-commit-config.yaml — when you change hook versions or add new hooks, the cache is automatically invalidated. A typical pre-commit CI run takes 30-60 seconds with a warm cache, versus 2-3 minutes cold on the first run.

Real-Life Example: Setting Up a New Python Project with Full Quality Gates

Tutorial image
Enforced at commit, enforced in CI, enforced in code review. Pick your battles.

Here is a complete bootstrap script that sets up a new Python project with pre-commit, Black, Ruff, and a matching pyproject.toml configuration that keeps all tools in sync.

# setup_quality_gates.py
# Run once to set up a new project: python setup_quality_gates.py

import subprocess
import os

def run(cmd):
    print(f"$ {cmd}")
    subprocess.run(cmd, shell=True, check=True)

# 1. Install tools
run("pip install pre-commit black ruff mypy")

# 2. Install hooks into .git
run("pre-commit install")
run("pre-commit install --hook-type commit-msg")  # optional: enforce commit format

# 3. Create pyproject.toml tool config (appends if file exists)
config = """
[tool.black]
line-length = 100
target-version = ["py311"]
include = '\\.pyi?$'
exclude = '''
/(
    \\.git
  | \\.venv
  | __pycache__
  | migrations
)/
'''

[tool.ruff]
line-length = 100
target-version = "py311"
select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # pyflakes
    "I",   # isort
    "B",   # flake8-bugbear
    "UP",  # pyupgrade
]
ignore = ["E501"]  # line too long (handled by black)

[tool.ruff.per-file-ignores]
"tests/*" = ["S101"]  # allow assert in tests

[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
strict = false
"""

mode = "a" if os.path.exists("pyproject.toml") else "w"
with open("pyproject.toml", mode) as f:
    f.write(config)

# 4. Run hooks on all files to establish baseline
print("\\nRunning hooks on all files (initial baseline)...")
result = subprocess.run("pre-commit run --all-files", shell=True)
if result.returncode == 0:
    print("\\nAll hooks passed! Project is clean.")
else:
    print("\\nSome files were reformatted. Review changes with 'git diff', then commit.")

Output:

$ pip install pre-commit black ruff mypy
$ pre-commit install
pre-commit installed at .git/hooks/pre-commit
Running hooks on all files (initial baseline)...
Trim Trailing Whitespace.................................................Passed
black....................................................................Passed
ruff.....................................................................Passed
All hooks passed! Project is clean.

The script uses subprocess.run(..., check=True) for the setup commands so it fails immediately if any step errors — no silent partial setups. The pyproject.toml configuration aligns Black and Ruff to the same line length (100) so they don’t conflict: Black formats to 100 characters and Ruff’s E501 rule (which would also complain about line length) is disabled since Black handles it.

Frequently Asked Questions

My pre-commit hooks are slow — how do I speed them up?

The most common cause is mypy, which is inherently slow because it type-checks the entire project. Move mypy to a pre-push hook (add stages: [pre-push] under the mypy hook entry) so it only runs on push rather than every commit. For other hooks, run pre-commit run --all-files once to warm the cache — subsequent runs are much faster because hooks only process changed files. Also, replace flake8 + isort with Ruff (which handles both) for a significant speed improvement.

Do all team members need to run pre-commit install?

Yes, each developer needs to run pre-commit install in the cloned repo once. This installs the hooks into the local .git/hooks/ directory, which is not tracked by Git. To automate this, add a Makefile target or a setup.sh script that runs pre-commit install, and document it in your README. Alternatively, use the pre-commit GitHub Action to catch issues in CI from developers who skipped local installation.

Can I exclude specific files or lines from a hook?

Yes. To exclude entire files or directories, add an exclude pattern to the hook config in .pre-commit-config.yaml: exclude: ^(migrations/|docs/). To suppress a specific Ruff or flake8 rule on one line, add a trailing comment: import os # noqa: F401. For Black, you can mark a region to skip with # fmt: off / # fmt: on comments — useful for hand-aligned data structures where Black’s formatting destroys readability.

Can I write my own custom hook?

Yes. Add a repo: local entry in your config and define the hook directly. For example, a custom hook that runs your test suite on push:

repos:
  - repo: local
    hooks:
      - id: run-tests
        name: Run pytest
        entry: pytest tests/ -x -q
        language: system
        pass_filenames: false
        stages: [pre-push]

The language: system setting means the hook uses the Python interpreter from your active virtual environment rather than an isolated pre-commit environment.

Conclusion

pre-commit turns code quality enforcement from a manual discipline into an automatic process. You configured hooks for Black (formatting), Ruff (linting and import sorting), mypy (type checking), and common file hygiene checks; ran them manually with pre-commit run --all-files; handled the fail-fix-re-add-recommit workflow; and wired the same checks into GitHub Actions for CI coverage. The complete setup described here takes about 10 minutes to install on a new project and saves hours of formatting-related code review comments over the life of the project.

The next step is exploring the full catalog of community hooks at pre-commit.com/hooks.html, which includes hooks for detecting secrets accidentally committed to Git, validating Dockerfile syntax, enforcing commit message conventions, and many more. Combine pre-commit with a branch protection rule requiring CI to pass before merging and you’ve built a complete, automated quality gate.

Full documentation is at pre-commit.com.

Setting Up pre-commit

# pip install pre-commit

# Create .pre-commit-config.yaml in repo root:
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files

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

  - repo: https://github.com/charliermarsh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: [--fix]

# Install the git hooks
pre-commit install

# Now every git commit runs the checks automatically

The pre-commit install command writes a .git/hooks/pre-commit shell script. Every commit triggers the checks; failures block the commit until fixed.

Common Hooks for Python

repos:
  # Formatters
  - repo: https://github.com/psf/black
    rev: 24.1.1
    hooks: [{id: black}]
  
  - repo: https://github.com/pycqa/isort
    rev: 5.13.2
    hooks: [{id: isort, args: ["--profile=black"]}]

  # Linters
  - repo: https://github.com/charliermarsh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: [--fix]

  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks: [{id: flake8}]

  # Type checking
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

  # Security
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks: [{id: detect-secrets}]

Pin every rev to a specific version — auto-updates to hooks can break the repo for everyone simultaneously.

Running on All Files

# Run hooks against ALL files, not just changed ones — useful after adding new hooks
pre-commit run --all-files

# Run a specific hook
pre-commit run black --all-files

# Update hook versions
pre-commit autoupdate

CI Integration

# GitHub Actions
name: pre-commit
on: [pull_request]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - uses: pre-commit/action@v3.0.1

Run pre-commit in CI as well — local hooks can be bypassed with git commit --no-verify, but CI is the gate that can’t be skipped.

Local Hooks

repos:
  - repo: local
    hooks:
      - id: pytest-fast
        name: pytest fast tests
        entry: pytest -m "not slow" --tb=short -q
        language: system
        pass_filenames: false
        always_run: true
      - id: my-custom-check
        name: Custom validation
        entry: python scripts/validate.py
        language: python
        files: "\\.py$"

Skipping Hooks Selectively

# Skip a specific hook for a commit
SKIP=mypy git commit -m "WIP"

# Skip ALL hooks (use sparingly)
git commit --no-verify -m "emergency fix"

# Skip a file from a specific hook in config
# Add a regex to exclude files:
- id: black
  exclude: ^(legacy/|migrations/)

Common Pitfalls

  • Not pinning versions. rev: main means everyone gets a different version. Pin to a tag (rev: v1.2.3) and update via pre-commit autoupdate.
  • Hooks not running for new contributors. Each clone needs pre-commit install. Document it or use a Makefile target.
  • Hooks too slow. Slow hooks (mypy on huge codebases) make contributors skip via –no-verify. Either tune them (run only on changed files) or move to CI-only.
  • Bypass with –no-verify. Easy escape hatch. Run the same hooks in CI to catch bypasses.
  • Hook fixes uncommitted changes. A hook that auto-fixes (black, ruff –fix) modifies your file BUT doesn’t add to the commit. You have to re-add and commit again.

FAQ

Q: pre-commit or husky?
A: pre-commit for Python projects. husky for JS/Node. Both wrap git hooks; pre-commit has a richer ecosystem of Python hooks.

Q: Should I commit .pre-commit-config.yaml?
A: Always. It’s the source of truth for hook versions across the team.

Q: How do I bypass a hook on one file?
A: Either add the file to the hook’s exclude pattern or use a comment-based skip (each tool has its own — # noqa, # type: ignore, etc.).

Q: pre-commit.ci or self-hosted CI?
A: pre-commit.ci is a free service that auto-updates hook versions via PRs. Helpful for OSS; for private repos, your own CI is usually preferred.

Q: How fast should hooks be?
A: Under 5 seconds total for the common case. Beyond that, contributors disable. Move slow checks to CI-only.

Wrapping Up

pre-commit is the lowest-friction way to enforce code quality before commits land. The default Python config (black + ruff + trailing-whitespace + check-yaml) is a 5-minute setup that prevents 80% of style and trivial bug PRs. Run the same hooks in CI to catch bypasses. Pin versions, document the install step, and you have a self-maintaining quality bar that scales with the team.

Continue Learning Python

Tutorials you might also find useful: