Pre-Commit Hooks
The best time to catch a linting error is before it reaches CI. The second best time is never, because it should have been caught locally. Pre-commit hooks run checks automatically when you commit, turning your local git workflow into the first line of defense against trivial mistakes. Set them up once, never think about them again.
The value is not just catching errors earlier. It is eliminating an entire category of CI failures -- the ones where you push, wait 4 minutes, discover you forgot to run the formatter, fix it, push again, and wait another 4 minutes. That 8-minute round trip becomes a 2-second local check.
What to Run in Pre-Commit
Not everything belongs in a pre-commit hook. The constraint is speed. If a hook takes more than 5 seconds, developers will bypass it with --no-verify and you have accomplished nothing. Only run checks that are fast and high-signal.
Good pre-commit checks (fast, high-signal):
- Code formatting (Prettier, Black, gofmt)
- Linting (ESLint, Ruff, golangci-lint)
- Type checking on changed files (tsc --noEmit, mypy)
- Commit message format validation
- Secret detection (detect-secrets, gitleaks)
- Trailing whitespace, merge conflict markers
Bad pre-commit checks (slow, better for CI):
- Full test suite
- Integration tests
- Docker builds
- Security vulnerability scans
- Full-project type checking on large codebases
The principle: pre-commit hooks should catch formatting and syntax issues. They should not try to verify correctness. That is what CI is for.
Setting Up with Husky & lint-staged
For JavaScript and TypeScript projects, Husky and lint-staged are the standard. Husky manages git hooks, lint-staged runs linters only on staged files.
# Install
npm install --save-dev husky lint-staged
# Initialize Husky
npx husky init
# .husky/pre-commit
npx lint-staged
# package.json
{
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml}": [
"prettier --write"
],
"*.css": [
"stylelint --fix"
]
}
}
The key detail is that lint-staged only processes files that are staged for commit, not the entire project. This keeps the hook fast even in large codebases. If you have 500 TypeScript files but only changed 3, only those 3 get linted and formatted.
Setting Up with pre-commit (Python Ecosystem)
The pre-commit framework by Anthony Sottile works with any language, but is especially popular in Python projects. It manages hooks as versioned, cached dependencies.
# Install
pip install pre-commit
# .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-merge-conflict
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=500']
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
# Install the hooks
pre-commit install
The pre-commit framework caches hook environments, so after the first run, subsequent runs are fast. It also supports running hooks on CI as a verification step, catching anyone who bypassed hooks locally.
Commit Message Validation
Inconsistent commit messages make git log useless. Enforce a format in a commit-msg hook. The most common standard is Conventional Commits.
# Conventional Commits format
<type>(<scope>): <description>
# Examples
feat(auth): add OAuth2 login flow
fix(api): handle null response from payment provider
docs(readme): update setup instructions
refactor(db): extract query builder into separate module
With commitlint (JavaScript ecosystem):
# Install
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional']
};
# .husky/commit-msg
npx commitlint --edit $1
With the pre-commit framework:
# .pre-commit-config.yaml (add to repos list)
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
Commit message validation feels pedantic until you need to generate a changelog, bisect a bug, or understand why a change was made six months ago. Consistent messages make all of those tasks dramatically easier.
Secret Detection
Accidentally committing an API key or database password is one of the most expensive mistakes a developer can make. A pre-commit hook that scans for secrets is cheap insurance.
# Using gitleaks
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
# Using detect-secrets (Yelp)
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
The detect-secrets approach uses a baseline file that records known false positives. When it flags something that is not actually a secret (like a test fixture or a hash constant), you add it to the baseline and it will not flag it again. This avoids the "too many false positives so we disabled it" failure mode.
Making It Mandatory
Pre-commit hooks are local. A developer can skip them with --no-verify, uninstall them, or never install them in the first place. You need a backstop.
Run the same checks in CI. If someone bypasses the hooks, CI catches it. The hooks are not a replacement for CI linting -- they are a fast local shortcut that prevents most CI lint failures.
# CI step that verifies pre-commit hooks would pass
# .github/workflows/lint.yml
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: pre-commit/action@v3.0.1
For Husky, ensure the hook is installed automatically. Add a prepare script to package.json:
{
"scripts": {
"prepare": "husky"
}
}
This runs after npm install, so any developer who clones the repo and installs dependencies gets hooks automatically. No manual step, no forgetting.
Handling Slow Hooks
If a hook is too slow for pre-commit, move it to pre-push. Pre-push hooks run less frequently (only when pushing, not on every commit) and can tolerate longer runtimes.
# .husky/pre-push
npm run typecheck
npm run test:unit -- --changed
This is a good place for type checking the full project (not just changed files) and running a quick subset of tests. The developer has already committed and is about to push, so a 15-second check is acceptable. A 15-second check on every commit is not.
Another option is to run slow checks in the background and notify the developer if they fail, rather than blocking the commit. Tools like lefthook support this:
# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
run: eslint --fix {staged_files}
format:
run: prettier --write {staged_files}
pre-push:
commands:
typecheck:
run: tsc --noEmit
test:
run: jest --onlyChanged
Common Pitfalls
- Hooks that modify files but do not re-stage them. If your formatter changes a file, the formatted version is not what gets committed unless the tool or hook re-stages it. lint-staged handles this correctly. DIY hooks often do not.
- Running hooks on all files instead of staged files. A full-project lint on every commit is slow and noisy. Only check what is being committed.
- Not pinning hook versions. If hooks auto-update, a new version could start failing on existing code and block all commits. Pin versions and update deliberately.
- Forgetting Windows developers. Husky works on Windows, but shell scripts in
.husky/might not. Usenpxor cross-platform tools instead of raw shell commands. - Too many hooks. If pre-commit takes 10 seconds, developers will bypass it. Be ruthless about what goes in pre-commit vs. pre-push vs. CI.
Key Takeaways
- Pre-commit hooks eliminate the round trip of pushing, waiting for CI, discovering a formatting error, fixing, and pushing again.
- Only run fast, high-signal checks in pre-commit: formatting, linting, secret detection, commit message validation.
- Use lint-staged or the
pre-commitframework to run checks only on staged files. - Always verify hooks in CI as a backstop. Hooks are a convenience, not a guarantee.
- Move slow checks (type checking, tests) to pre-push hooks where a 15-second wait is acceptable.
- Auto-install hooks via npm
preparescripts or documented setup commands so nobody forgets.