Last Updated: June 01, 2026
- Python pre-commit: Quick Example
- What is pre-commit and How Does It Work?
- A Full pre-commit Configuration
- Running Hooks Manually and Skipping Them
- Running pre-commit in GitHub Actions
- Real-Life Example: Setting Up a New Python Project with Full Quality Gates
- Frequently Asked Questions
- Conclusion
- Related Articles
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.
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 stage | When it runs | Common use |
|---|---|---|
| pre-commit | Before creating a commit | Linting, formatting, secret detection |
| pre-push | Before pushing to remote | Running tests, type checking |
| commit-msg | After writing commit message | Enforcing commit message format |
| post-checkout | After git checkout | Installing 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.
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.
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
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: mainmeans everyone gets a different version. Pin to a tag (rev: v1.2.3) and update viapre-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.
Related Articles
Continue Learning Python
Tutorials you might also find useful: