- Pre-commit hooks can pass locally but fail in CI due to line ending differences, path mismatches, environment variables, or version skew between local and CI tool installations.
- The most common issue is Windows CRLF vs Linux LF line endings — fix it permanently with .gitattributes forcing eol=lf for all text files.
- To debug hook vs CI mismatches, reproduce the exact CI command locally, compare tool versions, check working directories, and verify git state before running checks.
- Auto-fix in hooks (e.g., black .) but validate in CI (black –check .) to catch cases where hooks didn't run, and use git diff –exit-code to detect any uncommitted changes after running formatters.
- Let pre-commit manage tool versions instead of installing them separately in CI — this eliminates version skew and keeps configs in sync across environments.
The Hook Runs Fine Locally, CI Fails Anyway
Your pre-commit hook passes locally. You push. CI fails with the exact same check.
This happened to me on three different projects last month. The hook would run black --check . locally, pass, and then CI would fail with “Files would be reformatted.” Same version, same config, different result.
The problem isn’t the tools. It’s the environment mismatch between where hooks run and where CI runs. After debugging this across 50+ repos (client work + open source audits), I’ve seen five patterns that cause 90% of these failures.

After spending an afternoon reproducing CI failures locally and tracking down environment mismatches across 50+ repos, you deserve a Japanese snack box to reward your debugging persistence.
Pattern 1: Line Ending Hell on Windows
The most common culprit. Your hook runs prettier or black on Windows with CRLF line endings. It passes. CI runs on Linux with LF endings and reformats everything.
Here’s what actually happens:
# pre-commit config
repos:
- repo: https://github.com/psf/black
rev: 23.12.1
hooks:
- id: black
args: [--check]
Locally (Windows):
$ git config core.autocrlf
true
$ black --check .
All done! ✨ 🍰 ✨
5 files would be left unchanged.
CI (Linux):
Oh no! 💥 💔 💥
5 files would be reformatted.
The files are byte-identical except for rn vs n. Black sees that as a difference.
Fix: Force LF in .gitattributes for all text files:
* text=auto eol=lf
*.py text eol=lf
*.js text eol=lf
*.md text eol=lf
Then refresh your working copy:
git rm --cached -r .
git reset --hard
This bit me on a React + Python monorepo where half the team was on Windows. We had 47 files flip-flopping between CRLF and LF on every commit until we added .gitattributes.
When you’re spending hours debugging CI pipelines and tweaking pre-commit configs, a mechanical keyboard makes those repetitive config edits feel less like punishment.
Pattern 2: The Hook Modifies Files CI Only Checks
Your local hook auto-fixes issues. CI only validates. When the hook misses something (or runs in a subtly different mode), CI catches it.
Example from a Django project:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: [--profile, black]
Locally, isort reformats imports. In CI:
# .github/workflows/ci.yml
- name: Check imports
run: isort --check-only --profile black .
The hook runs isort (fix mode). CI runs isort --check-only (validation mode). If the hook skips a file (wrong extension, excluded path), CI catches it.
I’ve seen this happen when:
– The hook uses types: [python] but CI runs on all .py files including generated code
– The hook excludes migrations/ but CI doesn’t
– The hook runs on staged files only, CI runs on the full tree
Fix: Make CI run the exact same fix command, then check for dirty git state:
- name: Check formatting
run: |
isort --profile black .
black .
git diff --exit-code
If any file changed, git diff --exit-code returns non-zero. This catches tools that “fixed” something the hook missed.
Pattern 3: Path Differences Break Exclusions
Pre-commit runs from repo root. Your CI job might run from a subdirectory, or use absolute paths in ways that break exclude patterns.
Real case from a microservices monorepo:
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
exclude: ^(migrations|generated)/
Locally, this excludes app/migrations/ correctly. In CI:
- name: Lint
working-directory: ./services/api
run: pre-commit run --all-files
The working-directory change breaks the regex. Pre-commit still thinks it’s at repo root, but the paths are now relative to services/api/. The exclude pattern ^migrations/ doesn’t match ../../migrations/ or however the path resolves.
Fix: Use absolute exclude patterns or avoid changing working directories in CI:
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
exclude: (^|/)migrations/
The (^|/) prefix matches both migrations/ at root and foo/bar/migrations/ nested.
Alternatively, always run pre-commit from repo root in CI:
- name: Lint
run: pre-commit run --all-files
# No working-directory directive
Pattern 4: Environment Variables Change Tool Behavior
Some tools read environment variables that differ between your shell and CI. I’ve seen this with mypy, eslint, and ruff.
Example:
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
args: [--strict]
Locally, I had MYPYPATH=./stubs in my .bashrc to point to custom type stubs. CI didn’t have this. Result: mypy passed locally (found custom stubs), failed in CI (stub files missing, type errors on third-party imports).
Another variant: NODE_ENV. ESLint plugins behave differently in development vs production. If your hook runs in development mode but CI runs in production, rule sets can diverge.
Fix: Explicitly set required environment variables in the hook config:
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
args: [--strict]
language: system
entry: env MYPYPATH=./stubs mypy
Or better, don’t rely on environment variables. Put config in mypy.ini:
[mypy]
mypy_path = ./stubs
strict = True
Then both local and CI read the same file.
Pattern 5: The Hook Uses a Different Tool Version
Pre-commit installs tools into isolated virtualenvs. Your CI might install them via requirements-dev.txt or a global package manager. Version skew causes different behavior.
Classic case:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.9
hooks:
- id: ruff
args: [--fix]
CI (using uv):
- name: Install tools
run: uv pip install ruff
- name: Lint
run: ruff check --fix .
The hook pins ruff to v0.1.9. CI installs whatever’s latest (say, v0.2.1). Rule implementations change between versions. What passed in 0.1.9 might fail in 0.2.1.
I saw this exact issue when Ruff added the E721 rule in 0.2.0. Old code using type(x) == SomeClass passed pre-commit (0.1.9) but failed CI (0.2.0) with “use isinstance() instead.”
Fix: Pin the same version everywhere. In CI, install from the pre-commit config:
- name: Install pre-commit
run: pip install pre-commit
- name: Run hooks
run: pre-commit run --all-files
This forces CI to use the exact versions from .pre-commit-config.yaml. No skew.
Alternatively, if you must install tools separately, extract versions into a shared file:
# pyproject.toml
[tool.ruff]
required-version = "0.1.9"
Then reference it:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.9 # Must match pyproject.toml
hooks:
- id: ruff
And in CI:
- name: Install ruff
run: pip install ruff==0.1.9
But honestly, just let pre-commit manage it. One source of truth.

The Debug Process That Actually Works
When you hit this (hook passes, CI fails), here’s the checklist:
-
Reproduce CI locally. Don’t guess. Run the exact CI command on your machine:
bash
# If CI does this:
black --check .
# You do this:
black --check .
If it passes locally but fails in CI, it’s environment. If it fails locally too, your hook isn’t running. -
Check tool versions. Print them in both places:
bash
black --version
pre-commit run black --verbose # shows installed version
Mismatch? That’s your culprit. -
Check working directory. Where is CI running the command?
yaml
- name: Debug paths
run: pwd && ls -la
If it’s not repo root, your exclude patterns are probably wrong. -
Check git state. What files is CI actually seeing?
yaml
- name: Debug git
run: git status && git diff
If there are unstaged changes (from checkout or previous steps), tools might run on the wrong content. -
Run the hook in CI. Instead of running the tool directly, run the hook:
yaml
- name: Pre-commit
run: pre-commit run --all-files
If this passes but your manual tool invocation fails, the hook config differs from your CI command.
I’ve debugged 30+ cases using this exact sequence. It’s boring, but it works.
What About Skip Conditions?
Some hooks use files: or exclude: regex that’s just subtly wrong. The hook skips a file. CI doesn’t.
Example:
repos:
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.56.0
hooks:
- id: eslint
files: .(js|jsx)$
This runs ESLint only on .js and .jsx files. But CI does:
- name: Lint JS
run: eslint .
ESLint’s default config might include .ts, .tsx, .mjs files. The hook skips them. CI lints them. Mismatch.
Fix: Either expand the hook’s file pattern:
files: .(js|jsx|ts|tsx|mjs)$
Or narrow CI to match the hook:
- name: Lint JS
run: eslint "**/*.{js,jsx}"
I prefer the first approach (expand the hook). If you have .ts files, you probably want to lint them.
The Trap of --all-files in CI
Running pre-commit run --all-files in CI sounds safe. It’s not.
If your hook uses stages:, it might only run on commit stage locally but CI forces all stages:
repos:
- repo: local
hooks:
- id: check-secrets
name: Check for secrets
entry: detect-secrets scan
language: system
stages: [commit]
Locally, this runs only on commit (not push, not manual). In CI with --all-files, pre-commit ignores stages: and runs it anyway.
Usually that’s fine. But if the hook has side effects (writes to a file, calls an API), running it in CI might break things.
Fix: Be explicit about stages in CI:
- name: Run commit hooks
run: pre-commit run --all-files --hook-stage commit
Or just don’t use stages: unless you really need them. Most hooks should run everywhere.
Math Checks in Hooks: The Floating Point Trap
If your hook validates scientific code (checksums, numerical tests), floating point precision can differ between architectures.
Example hook that validates a physics simulation:
# scripts/validate_sim.py
import numpy as np
def check_energy_conservation():
result = run_simulation()
expected = 1.0
assert np.isclose(result.energy, expected, atol=1e-9)
Locally (x86_64 with AVX2):
$ python scripts/validate_sim.py
Passed
CI (ARM64 or older x86):
AssertionError: energy 1.0000000012 not close to 1.0
The simulation uses different BLAS libraries or CPU instructions. The result differs at the 10th decimal place. Your tolerance is too strict.
Fix: Relax tolerances for cross-platform checks:
assert np.isclose(result.energy, expected, atol=1e-6)
Or pin BLAS libraries:
- name: Install NumPy with OpenBLAS
run: pip install numpy==1.24.0 # Known deterministic version
But honestly, if your hook is running numerical code, maybe it shouldn’t be a pre-commit hook. That’s what CI is for.
FAQ
Q: Should pre-commit hooks auto-fix or just validate?
Auto-fix locally, validate in CI. Let the hook run black . (fix mode) so developers don’t have to think about it. In CI, run black --check . to catch cases where the hook didn’t run (force push, manual edits). This gives you safety without annoying developers.
Q: How do I debug a hook that only fails in CI?
Run the exact CI command locally. If it still passes, SSH into the CI runner (GitHub Actions supports tmate for debugging) and run commands interactively. Check git status, env, and pwd. 90% of the time it’s line endings, working directory, or environment variables.
Q: Can I skip pre-commit hooks in CI and just run the tools directly?
Yes, but you lose the version pinning and config consistency pre-commit provides. If you do this, you must manually keep tool versions in sync between .pre-commit-config.yaml and CI. I’ve seen teams do it successfully, but it’s more maintenance. I’d only skip pre-commit in CI if you have a very custom setup (Bazel, Nix) that conflicts with pre-commit’s virtualenv isolation.
When to Give Up on Pre-commit Hooks
Some checks just don’t belong in hooks.
If the check:
– Takes >5 seconds (kills developer flow)
– Requires network access (flaky, slow)
– Depends on external services (database, API)
– Has platform-specific behavior you can’t normalize
Move it to CI. Hooks are for fast, deterministic, local-first checks. Type checking, linting, formatting. Not integration tests.
I removed a pytest --doctest hook from a data science repo because doctests were flaky (floating point output, random seeds). Developers would commit, hook would randomly fail, they’d commit again without changes, it would pass. That’s not useful. Moved it to CI where we could control the seed and environment.
The Setup I’d Use Today
If I were starting a new Python project:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
CI:
name: CI
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pre-commit
run: pip install pre-commit
- name: Run hooks
run: pre-commit run --all-files
That’s it. No separate tool installations. No version skew. CI runs the exact same hooks as local.
.gitattributes:
* text=auto eol=lf
No line ending issues.
This setup would have saved me hours across those 50 repos. The complexity comes from trying to run tools in CI separately from pre-commit, or from not pinning versions, or from letting Windows developers use CRLF.
Keep it simple. Let pre-commit manage tools. Force LF. Run the same thing everywhere.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,795 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (763 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (654 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (550 views)