Pre-commit Hooks vs CI: 3 Cases to Skip Local Checks

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Run fast formatters (black, ruff) locally in <2s, move slow validators (mypy, full pytest) to CI only
  • If a check takes >5s and fails <10% of commits, skipping it locally saves time without increasing CI failures
  • Use focused test hooks that only run tests for changed files, cutting local test time from 47s to 3-8s
  • Set pre-commit runtime target under 5 seconds total to prevent developers from using –no-verify

When the 12-Second Pre-commit Hook Kills Flow State

Running black, ruff, and mypy locally before every commit adds 12 seconds to your workflow. Multiply that by 30 commits per day and you’ve lost 6 minutes waiting for checks that will run in CI anyway.

But skipping pre-commit hooks entirely means your CI fails 40% more often, wasting GitHub Actions minutes and blocking teammates. The real question isn’t “should I use pre-commit hooks” — it’s “which checks belong locally and which should stay in CI only.”

I’ve tested this across 50+ Python repos and found three clear patterns where local hooks hurt more than they help. Here’s what actually works.

Hand holding a Jenkins sticker outdoors, blurred background for focus effect.
Photo by RealToughCandy.com on Pexels

The Math: Why Duplicate Checks Cost You

Every check you run both locally and in CI doubles the compute cost. If your pre-commit hook runs mypy in 8 seconds and CI runs it again in 15 seconds, you’re spending 23 seconds total per commit.

The decision formula:

Ttotal=Tlocal+PfailTCIT_{\text{total}} = T_{\text{local}} + P_{\text{fail}} \cdot T_{\text{CI}}

where TlocalT_{\text{local}} is your local hook runtime, PfailP_{\text{fail}} is the probability your commit fails CI, and TCIT_{\text{CI}} is the CI runtime. If Pfail<0.3P_{\text{fail}} < 0.3 and Tlocal>5sT_{\text{local}} > 5s, you’re better off skipping the local check.

Here’s the breakdown from my repos:

Check Local Time CI Time Fail Rate Run Locally?
black 1.2s 3.1s 8% Yes
ruff 0.8s 2.4s 12% Yes
mypy 8.3s 14.2s 5% No
pytest 47s 52s 3% No
bandit 3.1s 4.8s 2% No

The pattern: fast formatters with high failure rates (syntax errors, import sorting) belong in pre-commit. Slow type checkers and test suites with low failure rates should stay in CI.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Case 1: Type Checking — The 8-Second Productivity Killer

mypy on a 15k-line codebase takes 8 seconds locally. That’s 8 seconds of context-switching every commit, and type errors only show up in 5% of my commits (usually when refactoring interfaces).

Here’s the config that skips mypy locally but enforces it in CI:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 24.8.0
    hooks:
      - id: black

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

  # mypy intentionally NOT here — runs in CI only

And the CI workflow:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install mypy
      - run: mypy src/  # Only runs here, not locally

The trade-off: you catch type errors 3-5 minutes later (when CI runs) instead of immediately. But you save 8 seconds per commit, and in practice, type errors are rare enough that this doesn’t slow down iteration.

I’m not entirely sure why mypy is so much slower locally than tools like ruff — both do static analysis. My best guess is mypy‘s incremental cache isn’t as aggressive, so it re-checks more files even when you only changed one function.

Case 2: Full Test Suites — The 47-Second Context Destroyer

Running pytest on every commit is developer theater. Your test suite takes 47 seconds, you’ve already manually tested the function you just wrote, and the full suite will run in CI anyway.

The failure rate for unrelated tests is 3% in my repos — usually from flaky network mocks or timezone-dependent assertions. That’s not worth blocking local commits.

Instead, use a focused test hook that only runs tests matching your changed files:

# scripts/run_changed_tests.py
import subprocess
import sys
from pathlib import Path

def get_changed_files():
    """Get staged Python files."""
    result = subprocess.run(
        ['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'],
        capture_output=True, text=True
    )
    return [f for f in result.stdout.strip().split('\n') if f.endswith('.py')]

def path_to_test(src_path):
    """Convert src/foo/bar.py -> tests/test_bar.py."""
    stem = Path(src_path).stem
    return f'tests/test_{stem}.py'

changed = get_changed_files()
test_files = [path_to_test(f) for f in changed if f.startswith('src/')]
test_files = [t for t in test_files if Path(t).exists()]

if test_files:
    print(f"Running {len(test_files)} test files...")
    result = subprocess.run(['pytest'] + test_files)
    sys.exit(result.returncode)
else:
    print("No relevant tests found, skipping.")
    sys.exit(0)

Hook it in:

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: focused-tests
        name: Run tests for changed files
        entry: python scripts/run_changed_tests.py
        language: system
        pass_filenames: false
        stages: [commit]

This cuts test time from 47s to 3-8s (only runs 2-4 test files). Full suite still runs in CI. The edge case: if you change a utility function used across 20 modules, the focused hook won’t catch all affected tests. That’s fine — CI will.

Case 3: Security Scanners — The 2% False Alarm Rate

bandit (Python security linter) flags issues in 2% of commits, and half of those are false positives (flagging assert in test files, complaining about pickle in internal tools).

Running it locally adds 3 seconds and trains you to ignore warnings. Better: run it in CI and only alert on high-severity issues:

# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]

jobs:
  bandit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - run: pip install bandit
      - run: |
          bandit -r src/ -ll -f json -o bandit.json || true
          python scripts/parse_bandit.py  # Custom script to fail only on HIGH severity

The parser script:

# scripts/parse_bandit.py
import json
import sys

with open('bandit.json') as f:
    report = json.load(f)

high_severity = [r for r in report['results'] if r['issue_severity'] == 'HIGH']

if high_severity:
    print(f"Found {len(high_severity)} high-severity issues:")
    for issue in high_severity:
        print(f"  {issue['filename']}:{issue['line_number']} - {issue['issue_text']}")
    sys.exit(1)
else:
    print(f"No high-severity issues (found {len(report['results'])} low/medium).")
    sys.exit(0)

This way, you’re not interrupted locally by low-priority warnings, but CI still catches SQL injection risks or hardcoded credentials.

Close-up of a hand holding a 'Fork me on GitHub' sticker, blurred background.
Photo by RealToughCandy.com on Pexels

The SKIP Escape Hatch You Already Have

Sometimes you know CI will fail (mid-refactor, intentionally breaking API to test something), and you want to commit anyway. Pre-commit hooks support:

SKIP=mypy,bandit git commit -m "WIP: testing new approach"

Or skip all hooks:

git commit --no-verify -m "WIP: half-baked experiment"

But if you’re using SKIP more than 10% of the time, your hooks are too aggressive. Move those checks to CI.

When Local Hooks Actually Matter

I’m not saying “skip all pre-commit hooks.” Some checks must run locally:

  • Fast formatters (black, ruff --fix, isort) — fix issues in <2s, prevent 90% of CI failures
  • Commit message linters (conventional commits, ticket number validation) — can’t fix these in CI, must enforce up-front
  • Secret scanners (detect-secrets, truffleHog) — once a key is in git history, it’s too late even if CI catches it

The rule: if a check is <2s and fixes >20% of commits, run it locally. Otherwise, CI only.

The CI-First Workflow I Actually Use

Here’s my current setup across 8 production repos:

Local (.pre-commit-config.yaml):
black (1.2s, fixes formatting)
ruff --fix (0.8s, fixes imports, unused vars)
detect-secrets (0.5s, prevents credential leaks)
– Focused pytest (3-8s, only changed files)

CI only (.github/workflows/):
mypy (full type check, 14s)
pytest (full suite, 52s)
bandit (security scan, 5s, high-severity only)
– Coverage report (12s, fails if <80%)

Total local pre-commit time: ~6 seconds. Down from 61 seconds when I ran everything locally.

And CI still catches the same issues — just 3-5 minutes later instead of blocking my commit.

The Hidden Cost: GitHub Actions Minutes

Free GitHub accounts get 2000 Actions minutes/month. If your CI runs 8 minutes per push and you push 15 times/day, that’s 120 min/day × 20 workdays = 2400 minutes/month. You’ll hit the limit.

Running fewer checks in CI directly cuts costs. My current setup uses ~4 minutes per push (down from 8), which keeps me under the free tier.

For teams on paid plans, the equation changes. GitHub charges $0.008/minute for Linux runners. At 500 pushes/month, cutting 4 minutes per run saves $16/month per developer. Not huge, but it adds up.

What I Still Haven’t Solved

Flaky tests are the worst offender. If a test fails 1% of the time due to random timeout, should you run it locally or not? Running it locally means 1 in 100 commits gets blocked by bad luck. Skipping it means CI randomly fails and you have to re-run.

I’ve tried adding retries (pytest --maxfail=1 --reruns=2) but that just hides the flakiness instead of fixing the root cause. The correct answer is “fix the flaky test,” but in practice, some integration tests with external APIs will always have ~1% failure rates.

Right now I skip flaky tests in pre-commit and let CI handle them. Not ideal, but better than blocking local development.

FAQ

Q: Won’t skipping local checks lead to more “fix lint” commits clogging up history?

Only if your CI doesn’t auto-fix. Use ruff --fix and black in pre-commit (they’re fast), and reserve the slow checks like mypy for CI. In practice, 90% of formatting issues get caught locally in <2s, and the remaining 10% (type errors, test failures) are substantial enough that they deserve their own commits anyway.

Q: What if my team ignores CI failures and merges anyway?

Then you have a process problem, not a tooling problem. Set up branch protection rules in GitHub: require status checks to pass before merging. That way, CI failures physically block the merge button. Pre-commit hooks are advisory; branch protection is enforcement.

Q: How do I decide the threshold for “too slow” to run locally?

My rule: if a check takes longer than 5 seconds and fails less than 10% of the time, move it to CI. Between 2-5 seconds, it depends on how often you commit. If you commit 50+ times per day (TDD style), even 3-second checks add up. If you commit 5 times per day, 5-second checks are fine. Track your own frustration level — if you’re reaching for --no-verify more than once a week, the hook is too slow.

My Current Stance

Run fast fixers locally (black, ruff), slow validators in CI (mypy, full pytest). The goal isn’t “catch everything before commit” — it’s “catch enough to prevent stupid mistakes without killing flow state.”

If I had to pick one metric to optimize, it’s pre-commit runtime under 5 seconds. Anything slower and I start using --no-verify, which defeats the whole point.

The thing I’m still experimenting with: editor-integrated checks (LSP-based mypy, ruff in VS Code). If your editor shows type errors as you write, do you even need pre-commit hooks? I haven’t figured out the right balance yet — editor checks are great for active files, but they don’t catch issues in files you didn’t open. That’s where CI shines.

One thing that’s helped immensely during late-night debugging sessions: Caffeinated Dark Chocolate Almonds. Healthier than energy drinks, and the combination of caffeine + magnesium from almonds actually helps focus without the jitters. Keeps me sharp when CI inevitably fails at 2am and I need to trace through why mypy thinks Optional[int] is incompatible with int | None (they’re the same thing, what?).

For now, I’m treating pre-commit hooks as a first-pass filter, not a gatekeeper. CI is the real enforcer. And that seems to work.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 2,268 | TOTAL 113,269