Integrating ruff check with mypy in CI: Zero-Conflict Pipeline Configuration

TL;DR

Run ruff check and mypy in parallel CI jobs with separate cache directories (.ruff_cache and .mypy_cache). Disable ruff’s ANN* rules when mypy is active to eliminate duplicate diagnostics. Use a wrapper script that captures both exit codes before returning a combined status so neither tool’s output is lost.

Combining ruff check and mypy in continuous integration requires strict orchestration. Without proper configuration, teams face duplicate diagnostics, conflicting exit codes, and cache collisions. This blueprint delivers a production-ready pipeline for fast, reliable feedback loops.

The core strategy decouples execution contexts. You must harmonize exit codes, isolate cache directories, and suppress overlapping rules. For foundational architecture patterns, review the Static Analysis Tools & CI Integration guidelines before deploying this configuration.

ruff + mypy parallel CI gate The CI runner spawns two parallel jobs: ruff check (with .ruff_cache) and mypy (with .mypy_cache). Both jobs return exit codes to a wrapper script that fails the build only if either tool reports violations. CI runner parallel jobs ruff check .ruff_cache (AST hashes) mypy .mypy_cache (type graph) exit-code gate wrapper script fail if ruff OR mypy ≠ 0
ruff and mypy run in parallel with isolated cache directories; a wrapper script aggregates both exit codes so the CI gate only passes when both tools are clean.

Exit Code Harmonization & CI Gating

Ruff and mypy use different exit-code schemas, and the crucial distinction is between a finding (the tool worked and reported problems) and a failure (the tool itself broke). Ruff returns 0 for a clean run, 1 when it reports violations, and 2 for an internal error or invalid configuration. mypy returns 0 for no errors, 1 when it finds type errors, and 2 for a fatal crash or config error. Pyright collapses more cases into 1. The 2 codes matter because they are the ones you must never silently swallow: a 2 means your gate isn’t actually checking anything, so treating “non-zero = fail” is correct only if you don’t also mask it with --exit-zero.

Combined gate result from ruff and mypy exit codes A grid crossing ruff exit codes against mypy exit codes, where the build passes only when both tools return zero. gate = pass only if ruff == 0 AND mypy == 0 mypy = 0 mypy = 1 / 2 ruff = 0 ruff = 1 / 2 PASS FAIL (mypy) FAIL (ruff) FAIL (both) exit 2 = tool broke, never mask it
The combined gate is a logical AND: the build passes only when both tools exit 0; an exit 2 is a tool failure you must surface, not suppress.

Use Ruff >=0.1.6 for consistent --exit-zero/--exit-non-zero-on-fix support. Modern mypy (>=1.0) prints error codes by default, so --show-error-codes is largely redundant but harmless. To gate CI reliably, run both tools and evaluate their exit codes together. A unified wrapper script ensures full log aggregation before returning a single status code — without it, the first failing tool’s non-zero exit can terminate the step and hide the second tool’s output entirely.

Avoid set -e in these wrappers to prevent premature pipeline termination. Capture each tool’s $? immediately after it runs (or use ${PIPESTATUS[0]} if you pipe its output through a formatter), because a later command overwrites $?.

#!/usr/bin/env bash
set +e

# Run ruff, capturing output
ruff_output=$(ruff check . 2>&1)
ruff_exit=$?

# Run mypy with strict mode
mypy_output=$(mypy --strict --show-error-codes . 2>&1)
mypy_exit=$?

echo "$ruff_output"
echo "$mypy_output"

# Unified gate: fail if either tool reports actual errors
if [[ $ruff_exit -ne 0 || $mypy_exit -ne 0 ]]; then
    exit 1
fi
exit 0

You only need this wrapper when both tools share a single CI step. If you split them into separate steps or jobs (the recommended topology — see Ruff Linter Integration), the CI runner already aggregates: each step’s non-zero exit fails its job independently, and a required status check that needs both jobs enforces the same AND without any shell glue. Reach for the wrapper specifically when you want both tools’ full output in one log even though one has already failed — for example in a pre-push hook or a local make check target. In that case emit machine-readable output (ruff check --output-format=github, mypy --output=json) so downstream tooling can parse findings from either tool without re-running them.

Cache Isolation & Parallel Execution

The two tools cache fundamentally different artifacts and must never share a directory. Ruff’s .ruff_cache holds per-file results keyed on a hash of the file contents, the resolved settings, and the Ruff version. mypy’s .mypy_cache holds the incremental type graph as *.data.json/*.meta.json fingerprints (or a single SQLite file under --sqlite-cache). Point both at one path and mypy’s cache-invalidation logic can trip over Ruff’s files, producing the worst failure mode in CI: not a crash, but a phantom pass where a stale graph reports clean on code that actually has new errors.

Shared cache corruption vs isolated caches On the left both tools write one directory and collide; on the right each tool owns its own directory and both stay valid. shared — collides ruff check mypy .cache (shared) stale graph → phantom pass isolated — clean ruff check mypy .ruff_cache AST hashes .mypy_cache type graph
One shared cache directory lets the tools clobber each other's state (risking a phantom pass); a directory per tool keeps both caches valid.

Explicitly configure environment variables to isolate these directories: RUFF_CACHE_DIR for Ruff and MYPY_CACHE_DIR for mypy (or the --cache-dir flags). Use exact hash keys for cache restoration so a dependency bump invalidates them, and validate integrity by forcing clean rebuilds on cache misses. One more nuance for parallel matrix builds: if you run several Python versions concurrently, put the interpreter version in the cache key, since a .mypy_cache built under 3.9 is not valid for a 3.12 job and restoring it cross-version forces a silent full rebuild.

- name: Restore static analysis caches
  uses: actions/cache@v4
  with:
    path: |
      .ruff_cache
      .mypy_cache
    key: ${{ runner.os }}-ruff-mypy-${{ hashFiles('**/pyproject.toml', '**/requirements*.txt') }}

- name: Run Ruff
  run: ruff check . --output-format=github
  env:
    RUFF_CACHE_DIR: ${{ github.workspace }}/.ruff_cache

- name: Run mypy
  run: mypy src/ --config-file pyproject.toml
  env:
    MYPY_CACHE_DIR: ${{ github.workspace }}/.mypy_cache

Explicit environment variable injection guarantees absolute path separation and prevents cross-job contamination in matrix builds.

Rule Suppression for Type-Overlap Elimination

Ruff performs syntactic analysis while mypy executes semantic type inference, and for the most part they cover disjoint ground — but one family genuinely overlaps: Ruff’s ANN (flake8-annotations) rules flag missing annotations, which is exactly what mypy’s disallow_untyped_defs (bundled into --strict) also enforces. Run both and a single unannotated function produces two diagnostics with different wording, doubling the triage surface. The resolution is to let the tool that checks more own the concern: mypy validates both presence and correctness of annotations, so when --strict is on, disable Ruff’s ANN* codes and keep Ruff focused on what mypy ignores — style (E/W), imports (I), modernization (UP), and bug patterns (B).

Assigning the overlapping ANN checks to mypy Ruff keeps style, imports, upgrades, and bugbear; the annotation-presence checks in the overlap are disabled in Ruff and owned by mypy strict, which also checks correctness. ruff owns E · W style I import order UP modernize B bug patterns overlap ANN — is every def annotated? disable in ruff → mypy --strict owns presence (disallow_untyped_defs) + correctness [no-untyped-def] [return-value] · [arg-type]
Give the overlapping annotation-presence check to mypy (which also verifies correctness) and keep Ruff on the style, import, upgrade, and bug rules mypy never touches.
Disable ANN* rules when mypy --strict is active Ruff's ANN* rule family checks annotation completeness — the same thing mypy's disallow_untyped_defs and warn_return_any enforce. Running both produces duplicate diagnostics that inflate CI output and confuse triage. Add all ANN* codes plus F821 to ruff's ignore list whenever mypy strict mode is enabled.

Ignore ANN* annotation rules when mypy --strict is active — mypy’s disallow_untyped_defs and related flags already enforce annotation completeness. Exclude F821 if mypy handles import resolution.

[tool.ruff]
line-length = 88
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B"]
ignore = [
  "ANN001", "ANN002", "ANN003", "ANN101", "ANN102",
  "ANN201", "ANN202", "ANN204", "ANN205", "ANN206", "ANN401",
  "F821"
]

[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true

This configuration aligns with best practices detailed in Ruff Linter Integration. It eliminates redundant type validation while preserving style enforcement. Two refinements are worth noting. First, ANN101/ANN102 (missing annotation on self/cls) no longer exist in current Ruff — they were removed as always-redundant, so listing them in ignore is dead weight; keep them only if you must support a very old Ruff. Second, the deeper question of syntactic vs semantic checking — why UP007 and disallow_untyped_defs are not substitutes — is covered in Ruff UP rules vs mypy --strict; the short version is that you keep UP in Ruff (mypy is indifferent to Optional[X] vs X | None) but hand ANN to mypy whenever strict mode is on.

Be deliberate about which ANN codes you actually drop. The completeness codes — ANN001 (arguments), ANN201/ANN202 (returns), ANN204 (special methods) — are the ones mypy’s disallow_untyped_defs duplicates, so those are the ones to disable. ANN401, which flags an explicit Any in a signature, is a policy Ruff enforces that mypy under plain --strict does not (mypy needs disallow_any_explicit, which --strict does not enable), so you may want to keep ANN401 in Ruff even while dropping the rest. F821 (undefined name) is the other code to watch: it can misfire on string forward references and on names only present under TYPE_CHECKING, and mypy resolves those correctly, so excluding F821 avoids false positives on exactly the patterns a typed codebase uses. Decide per-code rather than blanket-disabling the whole family, and document the reason inline so the next maintainer doesn’t re-enable a rule that duplicates mypy.

Incremental PR Targeting with git diff Piping

Full-scan execution can make PR feedback slow on large codebases, so a common optimization is to lint and type-check only the files a pull request actually touched. The two tools respond very differently to this, and that asymmetry is the crux. Ruff is embarrassingly parallel and per-file, so passing it a subset of paths is straightforward and safe — it does not need the rest of the tree. mypy is whole-program: a change in a.py can introduce a type error in b.py that imports it, so type-checking only the changed files with --follow-imports=skip can miss real errors in unchanged dependents. Scope Ruff aggressively; scope mypy cautiously, and always fall back to a full scan when configuration or dependency files change.

Scoped PR check with full-scan fallback git diff lists changed files; if config changed run a full scan, otherwise route changed py files to scoped ruff and cautious mypy. git diff changed *.py config changed? pyproject / lockfile yes full scan (safe fallback) no ruff check <changed> per-file — scope freely mypy --follow-imports=skip whole-program — scope with care
Route changed files to scoped checks, but fall back to a full scan whenever config or lockfiles change so incremental state can't go stale.

Use mypy --follow-imports=skip to bypass unchanged modules for quick PR checks, but understand it trades completeness for speed. A safer middle ground on a merge-queue is to keep the incremental .mypy_cache warm and run a full mypy src/ — the cache makes the full run nearly as fast as a scoped one without the risk of missing a dependent. Implement a fallback to full-scan when configuration files change to prevent stale incremental state.

The distinction matters most for the Protocol example above: because DataProcessor is structurally typed, a class in an unchanged module might stop satisfying the protocol when you edit the protocol’s definition in the PR. A scoped --follow-imports=skip run that only sees the changed protocol file would never re-check the implementers and would report clean on code that no longer type-checks. This is the whole-program property in miniature — the correctness of one file depends on the types in another — and it is exactly why the safe pattern is “scope Ruff, keep mypy whole-program with a warm cache.” If you must scope mypy on very large repos, at least widen the file set to include the reverse-dependency closure of the changed modules rather than the changed files alone, and treat any edit to a shared types.py, Protocol, or TypedDict module as a trigger for the full-scan fallback.

# example.py (Python 3.10+)
from __future__ import annotations
from typing import Protocol

class DataProcessor(Protocol):
    def process(self, payload: bytes) -> dict[str, int]: ...

def run_pipeline(processor: DataProcessor, raw: bytes) -> dict[str, int]:
    return processor.process(raw)

For changed-file targeting in CI:

- name: Run scoped static analysis
  run: |
    changed=$(git diff --name-only --diff-filter=AMR origin/main HEAD | grep '\.py$' || true)
    if [ -n "$changed" ]; then
      echo "$changed" | xargs ruff check
      echo "$changed" | xargs mypy --follow-imports=skip
    else
      ruff check .
      mypy src/
    fi

Three details make this robust. --diff-filter=AMR restricts the list to Added, Modified, and Renamed files so a deletion doesn’t feed a nonexistent path to the tools. The || true after grep keeps the pipeline from failing the step when a PR touches no Python files (grep exits 1 on no match). And the diff needs the base commit present — on GitHub Actions set fetch-depth: 0 (or fetch origin/main explicitly) in the checkout step, because the default shallow clone doesn’t contain origin/main to diff against. Guard the xargs calls against an empty list too (xargs --no-run-if-empty, the GNU default) so an empty changed-file set doesn’t invoke ruff check with no paths and accidentally lint the whole tree.

Common Mistakes

Each of these failures follows a cause-and-effect chain from a small configuration slip to a real CI problem — a hidden diagnostic, a phantom pass, or a masked failure. Naming the chain makes the fix obvious.

Cause-and-effect chains for ruff plus mypy CI mistakes Three rows each trace a mistake through its consequence to the fix that breaks the chain. mistake consequence fix ANN on + mypy --strict duplicate diagnostics ignore ANN* in ruff shared cache dir phantom pass on stale graph separate cache dirs set -e in wrapper second tool's output lost set +e, capture $? break the chain at the mistake, not the consequence
Each mistake propagates to a distinct CI symptom; the fix always sits at the configuration end of the chain.
  • Enabling ruff’s ANN* rules alongside mypy --strict: Causes duplicate diagnostics, inflates CI output, and produces two differently-worded complaints about the same unannotated function during triage. Disable ANN* in Ruff whenever mypy strict mode owns annotation completeness.
  • Sharing a single cache directory between ruff and mypy: Leads to corrupted cache states and, worse, a phantom pass where a stale type graph reports clean on genuinely broken code. Always give each tool its own directory and key the CI cache on the lockfile (and interpreter version).
  • Using set -e in wrapper scripts without explicit exit-code handling: Terminates the script on the first tool’s non-zero exit, so the second tool never runs and its diagnostics never appear. Use set +e, capture each $? immediately, and combine them at the end.
  • Type-checking only changed files with --follow-imports=skip: Because mypy is whole-program, a change in one module can break a type in an unchanged dependent that a scoped run never inspects. Keep the cache warm and run a full scan, or fall back to one whenever config changes.

FAQ

Should ruff check type annotations if mypy is already running in CI? No. Disable ruff’s ANN* rules and F821 when mypy --strict is active to eliminate duplicate diagnostics and reduce execution overhead.

How do I prevent mypy’s slow first-run from blocking PR merges? Use mypy --follow-imports=skip combined with changed-file targeting to restrict analysis to modified files. Cache .mypy_cache across CI runs using content-addressable keys.

What is the safest way to gate CI on both ruff and mypy results? Run both tools and capture their exit codes. Use a wrapper script that returns exit 1 only if either tool reports actual violations. This ensures complete output from both tools even when one fails.

Back to Ruff Linter Integration