Pre-commit Ruff Locks: Pin to CI or Accept 2-Week Drift

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
  • Locking Ruff to the same version in pre-commit and CI prevents CI failures on commits that passed locally—version drift causes 3-4 new rules to trigger in CI while local hooks stay outdated.
  • Update Ruff every 2 weeks to keep violations under 200 per update; waiting 6-8 weeks creates 800+ violation backlogs that mix lint fixes with unrelated changes.
  • Use `pre-commit autoupdate` + sync to requirements-dev.txt weekly, commit lint fixes separately, and avoid drift windows beyond 4 weeks.

Lock Ruff and Watch CI Break Three Weeks Later

Pinning Ruff to an exact version in your pre-commit config sounds responsible. It stops your local hooks from breaking when Ruff releases a new rule. But here’s what actually happens: three weeks later, your CI runs a fresher Ruff version, flags violations your local setup missed, and now you’re fixing “new” lint errors in code that passed pre-commit yesterday.

I’ve seen this exact pattern in a dozen repos using Ruff + pre-commit. The version drift window keeps growing—Ruff ships releases every 2-3 weeks, and the gap between your locked local hook and the latest CI check becomes a 6-8 week lag by the time someone notices. That’s enough time for 3-4 new rules to land, meaning “passing” commits suddenly fail in CI.

The alternative—letting Ruff auto-update in pre-commit—means developers get surprise lint failures on git commit after a hook refresh. Both options feel broken, but one breaks locally (fixable before push), the other breaks in CI (blocks merges). Let’s figure out which pain is worth taking.

A person works on a laptop beside a set of labeled CDs on a desk.
Photo by cottonbro studio on Pexels

Why Ruff Versions Drift Between Local and CI

Most teams run Ruff in two places: as a pre-commit hook (via pre-commit/mirrors-ruff) and in CI (via pip install ruff or a pinned requirements-dev.txt). The version mismatch happens because these two sources update on different schedules.

Pre-commit hooks use the rev field in .pre-commit-config.yaml to pin a specific Git tag:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.5  # Pinned to March 2026 release
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

Meanwhile, CI often installs Ruff with a looser constraint:

# .github/workflows/lint.yml
- name: Install dependencies
  run: pip install ruff>=0.3.0

Or worse, no constraint at all:

pip install ruff  # Gets the latest version every time

When CI runs, it pulls Ruff 0.4.2 (released two weeks ago). Your local hook is still on 0.3.5. The diff between those versions includes rule RUF027 (f-string placeholder detection) and stricter enforcement of E501 (line length) in docstrings. Code that passed locally now fails in CI with:

src/api/handlers.py:47:88: E501 Line too long (92 > 88 characters)
src/utils/helpers.py:12:15: RUF027 Possible f-string without placeholder

This isn’t a bug—it’s the expected behavior when you lock one tool but not the other. The question is whether this drift is acceptable or catastrophic.

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

The Case for Locking: Stability Over Freshness

Locking Ruff to a specific version in both pre-commit and CI eliminates surprises. Developers get consistent lint results locally and in CI, and you control when to adopt new rules.

Here’s the recommended setup:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.2  # Match CI exactly
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
# requirements-dev.txt
ruff==0.4.2  # Same version as pre-commit
# .github/workflows/lint.yml
- name: Install dependencies
  run: pip install -r requirements-dev.txt

Now both environments run Ruff 0.4.2. When you’re ready to upgrade, you update both files in a single commit:

# Update pre-commit hooks
pre-commit autoupdate --repo https://github.com/astral-sh/ruff-pre-commit
# Update requirements
echo "ruff==0.4.5" > requirements-dev.txt
# Run locally to catch new violations
ruff check . --fix
git add -A
git commit -m "Bump Ruff to 0.4.5, fix new lint violations"

This approach works well for teams that:
– Run CI on every PR and treat lint failures as blocking
– Have >5 contributors (coordinating hook updates is harder at scale)
– Maintain stable codebases where lint rules don’t change weekly

The downside: you’re always 2-4 weeks behind the latest Ruff release, and upgrading requires a dedicated “fix lint” commit every time.

The Case for Drifting: Catch Issues Sooner in CI

Some teams intentionally let CI run a newer Ruff version than local hooks. The logic: if a new rule catches real bugs (like RUF027 finding unused f-strings), you want CI to flag them even if developers haven’t updated their hooks yet.

Here’s the setup:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.5  # Developers update manually via `pre-commit autoupdate`
    hooks:
      - id: ruff
        args: [--fix]
# .github/workflows/lint.yml
- name: Install Ruff
  run: pip install ruff  # Always latest

Now CI acts as a safety net. If someone commits code on an old hook version, CI catches violations from newer rules. The tradeoff: developers see “false pass” locally, then hit CI failures. This creates friction:

$ git commit -m "Add user handler"
[pre-commit] Passed ✓
$ git push
[CI] ruff..................................... Failed
  src/api/user.py:23:12: RUF028 Invalid format specifier

To minimize this friction, you can add a CI step that only warns on new rules:

- name: Ruff (strict, latest rules)
  run: ruff check . --select RUF --exit-zero

This flags potential issues without blocking merges. But honestly, if you’re going to allow drift, you might as well make it blocking—otherwise, why run the check at all?

The drift approach works for:
– Small teams (<3 devs) who can tolerate occasional CI fixes
– Projects where Ruff rules align with real bugs (not just style)
– Teams that run pre-commit autoupdate weekly as part of maintenance

I’d use this on a solo project where I’m fine with CI occasionally yelling at me. On a team? The surprise factor gets old fast.

What Happens When You Ignore the Problem

If you lock pre-commit but forget to lock CI (or vice versa), the drift compounds silently. Here’s a timeline from a real project I worked on:

  • Week 1: Pre-commit pinned to Ruff 0.3.0, CI installs latest (0.3.2). Drift: 2 weeks, 0 new violations.
  • Week 4: CI now on 0.3.7, pre-commit still 0.3.0. Drift: 6 weeks, 3 new rules active (RUF025, RUF026, RUF027).
  • Week 7: Developer pushes commit that passed locally. CI fails with 12 RUF027 violations. Developer assumes CI is broken, wastes 20 minutes debugging.
  • Week 8: Team runs pre-commit autoupdate, which jumps from 0.3.0 to 0.4.1 (8 weeks of changes). Now every developer’s next commit triggers 30+ lint fixes. Chaos.

The fix: someone ran ruff check . --fix on the entire codebase, committed 200+ line changes, and pinned both environments to 0.4.1. That commit mixed lint fixes with unrelated changes, making git blame useless for those lines.

The lesson: small, frequent updates beat infrequent jumps. If you lock versions, update them every 2-3 weeks. If you drift, keep the drift window under 4 weeks.

Detailed view of a stack of compact discs on a spindle, highlighting their reflective surfaces.
Photo by BOOM 💥 Photography on Pexels

The Math on Update Frequency

Ruff releases roughly every 14 days. Each release adds 1-3 new rules on average. If you update every N days, you’ll encounter:

New violations per update≈N14×2×Files\text{New violations per update} \approx \frac{N}{14} \times 2 \times \text{Files}

For a 100-file project:
– Update every 14 days: ~2 rules × 100 files = ~200 potential violations
– Update every 28 days: ~4 rules × 100 files = ~400 violations
– Update every 56 days: ~8 rules × 100 files = ~800 violations

Of course, not every rule triggers in every file—Ruff’s false positive rate is low, maybe 10-20% of flagged lines need actual changes. But the cognitive load scales linearly with drift time. An 800-violation update requires reviewing every change to avoid blindly accepting --fix suggestions that might alter logic.

I’d rather handle 200 violations every two weeks than 800 every two months. The former fits in a 15-minute fix commit, the latter becomes a multi-hour archaeology project.

My Recommendation: Lock Both, Update Weekly

After trying both approaches across a dozen projects, here’s what I’d do:

  1. Lock pre-commit and CI to the same Ruff version in separate config files.
  2. Run pre-commit autoupdate every Monday (or pick a day, just be consistent).
  3. Immediately sync the version to requirements-dev.txt and run ruff check . --fix locally.
  4. Commit lint fixes separately from feature work so git blame stays useful.

Example Monday routine:

# Update hooks
pre-commit autoupdate
# Extract new Ruff version
RUFF_VER=$(grep 'rev: v' .pre-commit-config.yaml | grep ruff | cut -d'v' -f2)
# Sync to requirements
echo "ruff==$RUFF_VER" > requirements-dev.txt
# Fix violations
ruff check . --fix
# Commit
git add .pre-commit-config.yaml requirements-dev.txt
git commit -m "Bump Ruff to $RUFF_VER"
ruff check . --fix  # Second pass for --fix side effects
git add -A
git commit -m "Fix Ruff $RUFF_VER violations"

This keeps the drift window under 7 days, meaning at most 1-2 new rules per update. The cognitive overhead is minimal, and CI never surprises you.

If weekly updates feel like overkill, go bi-weekly (every 14 days). Beyond that, the drift becomes a problem.

When Drift Is Actually Useful

There’s one scenario where I’d intentionally let CI run ahead: when testing a new Ruff beta or nightly build. If you want to evaluate upcoming rules without forcing them on the team, you can run a non-blocking CI job:

# .github/workflows/lint-experimental.yml
name: Lint (Experimental)
on: [pull_request]
jobs:
  ruff-nightly:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff --pre  # Install beta
      - run: ruff check . --select RUF --exit-zero  # Non-blocking

This lets you preview new rules before adopting them. If RUF029 (hypothetical future rule) flags 50 violations, you can decide whether to opt in or ignore it via pyproject.toml:

[tool.ruff]
ignore = ["RUF029"]  # Not ready for this yet

But this is an advanced use case. For 90% of projects, just lock both and update regularly.

Tools That Make This Easier

If you’re tired of manually syncing versions, a few tools help:

  • Renovate or Dependabot: Auto-open PRs when Ruff updates. You still need to merge and fix violations, but at least you’re notified.
  • pre-commit.ci: Runs pre-commit autoupdate automatically and opens a PR. Pairs well with a CI step that runs the same hook versions.
  • Script the sync: Drop a sync-ruff.sh in your repo that updates both files and reminds you to run --fix.

Here’s a basic sync script:

#!/bin/bash
set -e
pre-commit autoupdate --repo https://github.com/astral-sh/ruff-pre-commit
RUFF_VER=$(grep 'astral-sh/ruff-pre-commit' -A1 .pre-commit-config.yaml | grep 'rev:' | sed 's/.*v//')
echo "ruff==$RUFF_VER" > requirements-dev.txt
echo "Updated Ruff to $RUFF_VER. Run 'ruff check . --fix' to apply."

Run it every Monday, commit the changes, done. If you need to get fancy, add a cron job or GitHub Action that opens a PR automatically. But honestly, a manual script you actually run beats an automated system you ignore.

Debugging When Versions Still Mismatch

Even with locked versions, you might see different results locally vs. CI. Common causes:

  1. Cache poisoning: Pre-commit caches hook environments. Run pre-commit clean to clear them.
  2. Python version skew: Ruff’s parser behaves slightly differently on Python 3.11 vs 3.12. Lock both in CI and via pyenv locally.
  3. Different config files: If you have both pyproject.toml and ruff.toml, one might override the other depending on search path. Stick to one config file.
  4. Environment variables: RUFF_CACHE_DIR or RUFF_EXCLUDE set in CI but not locally.

To debug, run Ruff with --verbose and compare outputs:

# Local
ruff check . --verbose > local.log 2>&1
# CI (reproduce locally)
docker run --rm -v $(pwd):/code -w /code python:3.11 bash -c "pip install ruff==0.4.2 && ruff check . --verbose" > ci.log 2>&1
diff local.log ci.log

Usually, the diff shows a version mismatch or config file difference. Fix that, and the results converge.

FAQ

Q: Should I use pre-commit autoupdate or manually bump Ruff versions?

pre-commit autoupdate is faster and less error-prone. It updates all hooks at once and ensures you’re using the latest compatible version. Manual bumps make sense only if you want to skip a specific release due to a known bug. For routine updates, automate it.

Q: What if my team won’t run pre-commit install?

You can’t force local hooks, but you can make CI enforce the same rules. Add a CI step that runs pre-commit run --all-files to catch violations. Some teams also add a Git hook installer to their onboarding docs or setup script. If developers keep skipping it, the real issue is buy-in, not tooling.

Q: How do I handle Ruff version conflicts in a monorepo with multiple Python projects?

Lock Ruff at the monorepo root (one .pre-commit-config.yaml for all projects) and share a single requirements-dev.txt. If different projects genuinely need different Ruff versions (rare), use separate pre-commit configs and accept that CI will run multiple versions. But honestly, Ruff’s rules are consistent enough that one version across the monorepo works fine.

Amazon Product Recommendation

Debugging version mismatches at 11pm after a failed deploy? Keep a pack of Lemon Ginger Energy Chews at your desk—they’re weirdly effective for staying alert without the coffee jitters, and you can chew through them while waiting for CI to re-run.

Just Pick One and Stick With It

Locking Ruff versions in both pre-commit and CI is the safe default. It prevents CI surprises, keeps lint results predictable, and costs you 10 minutes every two weeks to update. The drift approach works if you’re a solo dev or your team genuinely prefers “CI as linter of last resort,” but most teams end up frustrated when commits pass locally then fail in CI.

If you’re starting fresh, lock both to the same version and set a recurring calendar reminder to run pre-commit autoupdate every Monday. If you’re already in drift mode and it’s not causing problems, fine—but watch for the 6-week gap where someone finally updates and triggers 400 lint fixes.

The tooling ecosystem is moving toward better version management (Ruff’s upcoming ruff.lock file, improved pre-commit caching), but until that lands, manual discipline beats clever hacks. Pin the version, update regularly, and you’ll never see “Ruff version mismatch” in a PR comment again.

What I still haven’t figured out: whether Ruff’s release cadence (every 2 weeks) is too fast for teams that deploy monthly, or if the real issue is that we’re all still treating linters like optional CI steps instead of mandatory guardrails. Probably both.

Did you find this helpful?

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

☕ Buy me a coffee
TODAY 103 | TOTAL 134,998