Pre-Commit Hooks
Set up pre-commit hooks to automate code quality checks before every commit. Learn configuration, hook types, and workflow integration
Pre-Commit Hooks
Pre-commit hooks are automated checks that run before you commit code. They catch issues early — before they reach the repository — saving code review time and preventing bad code from being merged.
What Are Git Hooks?
Git hooks are scripts that Git executes at specific points in the workflow:
| Hook Type | When It Runs | Common Uses |
|---|---|---|
pre-commit | Before commit message editor | Lint, format, test, check secrets |
pre-push | Before pushing to remote | Run full test suite, security scan |
commit-msg | After commit message is written | Validate commit message format |
prepare-commit-msg | Before commit message editor | Auto-generate commit messages |
post-commit | After commit is created | Notify CI, update dashboards |
Introducing the pre-commit Framework
The pre-commit framework makes hooks easy to install, configure, and share:
# Install the pre-commit framework
pip install pre-commit
# Verify installation
pre-commit --version
# Install hooks into your git repo
pre-commit install
# Run hooks on all files (useful for first setup)
pre-commit run --all-files
# Run a specific hook
pre-commit run flake8 --all-files
# Update hooks to latest versions
pre-commit autoupdate
# Uninstall hooks
pre-commit uninstallBasic .pre-commit-config.yaml
# .pre-commit-config.yaml
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
args: ['--maxkb=500']
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: detect-private-key
- id: mixed-line-ending
args: ['--fix=lf']
- 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
args: ['--profile', 'black']
- repo: https://github.com/pycqa/flake8
rev: 7.1.0
hooks:
- id: flake8
args: ['--max-line-length=88', '--extend-ignore=E203,W503']How pre-commit Works
pre-commit runs hooks against staged files only. This keeps the feedback loop fast. Use pre-commit run --all-files to check the entire repository.
Common Hook Categories
1. Formatting Hooks
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
language_version: python3.12
args: ['--line-length=100']
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: ['--profile', 'black']
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
types_or: [javascript, typescript, css, json, yaml, markdown]2. Linting Hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.8
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/pycqa/flake8
rev: 7.1.0
hooks:
- id: flake8
additional_dependencies:
- flake8-docstrings
- flake8-bugbear
- repo: https://github.com/pycqa/pylint
rev: v3.2.2
hooks:
- id: pylint
args: ['--max-line-length=100']3. Security Hooks
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
hooks:
- id: bandit
args: ['-x', 'tests/', '-ll']
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: detect-private-key4. Type Checking Hooks
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
args: ['--strict']
additional_dependencies: [types-requests, types-PyYAML]
- repo: https://github.com/pre-commit/pyright
rev: v1.1.366
hooks:
- id: pyright5. Testing Hooks
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest
language: system
types: [python]
pass_filenames: false
always_run: true
args: ['-x', '--ff', '--cov=src', 'tests/']
- id: pytest-cov-threshold
name: Check coverage threshold
entry: pytest
language: system
pass_filenames: false
always_run: true
args: ['--cov=src', '--cov-fail-under=85']Running the full test suite as a pre-commit hook can slow down commits significantly. Consider using pre-push for the full suite and keeping only fast unit tests in pre-commit.
Advanced Configuration
Conditional Hooks with Files/Exclude
repos:
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
# Only run on Python files
types: [python]
# Exclude generated files
exclude: |
(?x)^(
migrations/|
.*_pb2\.py|
build/
)
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: check-added-large-files
# Only check certain directories
files: ^src/
args: ['--maxkb=200']
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.8
hooks:
- id: ruff
# Never run on test files
exclude: ^tests/
args: [--fix]Using Hook Arguments
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.1.0
hooks:
- id: flake8
args:
- --max-line-length=100
- --extend-ignore=E203,W503
- --per-file-ignores=__init__.py:F401
additional_dependencies:
- flake8-docstrings==1.7.0
- flake8-bugbear==24.4.26
- repo: https://github.com/pycqa/pylint
rev: v3.2.2
hooks:
- id: pylint
args:
- --disable=C0111
- --max-line-length=100
- --score=n
files: ^src/
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
args: ['--line-length=100', '--target-version=py312']Local Hooks: Running System Commands
repos:
- repo: local
hooks:
- id: pytest
name: Run unit tests
entry: pytest
language: system
pass_filenames: false
always_run: true
args: ['-x', 'tests/unit/']
- id: check-requirements
name: Check requirements files
entry: python
language: system
files: ^requirements.*\.txt$
args: ['-c', 'scripts/check_requirements.py']
- id: shellcheck
name: Shell script lint
entry: shellcheck
language: system
types: [shell]
- id: make
name: Run make check
entry: make
language: system
pass_filenames: false
always_run: true
args: ['check']The Pre-Commit Hooks Ecosystem
| Category | Hook | Purpose |
|---|---|---|
| Basic | trailing-whitespace | Remove trailing whitespace |
| Basic | end-of-file-fixer | Ensure files end with newline |
| Basic | check-yaml | Validate YAML files |
| Basic | check-json | Validate JSON files |
| Basic | check-added-large-files | Prevent large file commits |
| Lint | ruff | Fast Python linter |
| Lint | flake8 | PEP 8 compliance checker |
| Lint | pylint | In-depth Python analysis |
| Format | black | Auto-formatter |
| Format | isort | Import sorter |
| Format | prettier | Multi-language formatter |
| Type | mypy | Static type checking |
| Type | pyright | Fast type checking |
| Security | detect-secrets | Find secrets in code |
| Security | bandit | Security vulnerability scanner |
| Security | detect-private-key | Find private keys |
| Test | pytest | Run test suite |
| Coverage | pytest-cov | Check coverage thresholds |
| Doc | interrogate | Check docstring coverage |
Creating Custom Hooks
You can write your own hooks for project-specific checks:
# .pre-commit-hooks/check_migration_names.py
"""
Custom hook to validate migration file naming convention.
"""
import re
import sys
from pathlib import Path
MIGRATION_PATTERN = re.compile(r'^\d{4}_[a-z0-9_]+\.py$')
def main():
errors = []
for file in Path('migrations').glob('*.py'):
if not MIGRATION_PATTERN.match(file.name):
errors.append(f"Invalid migration name: {file.name}")
if errors:
for error in errors:
print(error)
sys.exit(1)
sys.exit(0)
if __name__ == '__main__':
main()# .pre-commit-config.yaml (custom hook)
repos:
- repo: local
hooks:
- id: migration-naming
name: Validate migration names
entry: python
language: system
files: ^migrations/
args: ['.pre-commit-hooks/check_migration_names.py']
- id: commit-message-length
name: Check commit message length
entry: python
language: system
stages: [commit-msg]
args: ['.pre-commit-hooks/check_commit_msg.py']CI Integration
pre-commit can also run in CI pipelines using the ci stage:
# .github/workflows/pre-commit.yml
name: Pre-commit Checks
on:
pull_request:
push:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install pre-commit
run: pip install pre-commit
- name: Run all pre-commit hooks
run: pre-commit run --all-files
- name: Run only security hooks
run: pre-commit run detect-secrets --all-filesPre-Commit CI (pre-commit.ci)
Enable automatic CI for your hooks:
# .pre-commit-config.yaml (with CI settings)
ci:
autofix_commit_msg: |
[pre-commit.ci] auto fixes from pre-commit hooks
autofix_prs: true
autoupdate_branch: main
autoupdate_commit_msg: '[pre-commit.ci] autoupdate hooks'
autoupdate_schedule: monthly
submodules: false
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.8
hooks:
- id: ruffSkipping Hooks (Temporarily)
Sometimes you need to bypass hooks:
# Skip all hooks (use sparingly!)
git commit -m "WIP: emergency fix" --no-verify
# Skip hooks via environment variable
SKIP=black,flake8 git commit -m "skip formatting"
# Skip using Git config
git -c core.hooksPath=/dev/null commit -m "bypass all"
# Skip specific hook with pre-commit
PRE_COMMIT_ALLOW_NO_CONFIG=1 git commit -m "emergency"Skipping hooks defeats their purpose. Only bypass them for legitimate emergencies, and always come back to fix the issues.
Troubleshooting Pre-Commit
# Clear pre-commit cache
pre-commit clean
# Reinstall hooks
pre-commit install --overwrite
# Run hooks on specific files
pre-commit run --files src/main.py tests/test_main.py
# See what hooks would run (dry run)
pre-commit run --all-files --verbose
# Debug hook execution
pre-commit run --all-files --hook-stage manual
# Check version mismatches
pre-commit validate-config
# Force re-install of hooks
pre-commit install --install-hooksCommon Issues
| Issue | Cause | Solution |
|---|---|---|
| Hook fails on unrelated changes | Hook checks un-staged files | git stash before committing |
conda: command not found | Environment not activated | Use language: system |
| Hooks too slow | Full test suite in pre-commit | Move slow hooks to pre-push |
ModuleNotFoundError | Missing language dependencies | Add additional_dependencies |
| Hooks modifying files | Auto-formatters | Re-stage after hook runs |
rev not found | Tag doesn't exist | Check the repo for correct tag |
Practice Exercises
-
Install and Initialize: Install the pre-commit framework and initialize it in a Git repository. Run
pre-commit run --all-filesto check the current state. -
Basic Configuration: Create a
.pre-commit-config.yamlwith trailing-whitespace, end-of-file-fixer, check-yaml, and black hooks. Verify each hook triggers correctly. -
Advanced Configuration: Add flake8 with flake8-docstrings and ruff to your hooks configuration. Configure them to exclude test files and migration directories.
-
Custom Hook: Write a custom hook that validates that all TODO comments include a JIRA ticket number (e.g.,
TODO: PROJ-123: fix this). Add it as a local hook. -
Speed Optimization: Profile your hooks. Which ones are slow? Create a configuration that runs fast hooks (< 1 second) on pre-commit and slow hooks on pre-push.
-
Security Hooks: Add
detect-secretsandbanditto your hooks. Create a.secrets.baselinefile for any pre-existing secrets. -
CI Integration: Create a GitHub Actions workflow that runs pre-commit in CI. Ensure it fails the build if any hooks fail.
-
Team Standardization: Your team has 5 repositories. Create a shared pre-commit configuration that can be reused across all of them. Use
default_language_versionanddefault_stagesfor consistency.
Summary
- Pre-commit hooks catch issues before they reach the repository
- The pre-commit framework makes hooks easy to install, configure, and share
- Hooks run on staged files only — fast feedback, incremental checks
- Common hooks: Linting (ruff, flake8), formatting (black, isort), security (bandit, detect-secrets), type checking (mypy)
- Local hooks: Run system commands, custom scripts, or test suites
- CI integration: pre-commit works locally and in CI pipelines
- Skip sparingly: Only bypass hooks in genuine emergencies
- Team consistency: Shared configuration ensures everyone runs the same checks
Pre-commit hooks automate quality enforcement. They transform "remember to lint" from a manual chore into an automated guarantee. Every commit becomes a quality checkpoint.