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. Ruff returns 1 for lint violations and 0 for clean runs. mypy returns 0 for success, 1 for type errors, and 2 for fatal crashes. Pyright diverges further by returning 1 for both warnings and errors.

Use Ruff >=0.1.6 for consistent --exit-zero support. Use mypy >=1.0 for reliable --show-error-codes. 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.

Avoid set -e in these wrappers to prevent premature pipeline termination.

#!/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

Cache Isolation & Parallel Execution

Concurrent runners frequently corrupt shared cache directories. Ruff stores AST and lint state in .ruff_cache. mypy stores incremental type graphs in .mypy_cache. These must be kept separate.

Explicitly configure environment variables to isolate these directories. Use exact hash keys for cache restoration. Validate integrity by forcing clean rebuilds on cache misses.

- 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. Enabling both creates diagnostic duplication. Disable ruff rules that mypy already validates strictly.

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.

Incremental PR Targeting with git diff Piping

Full-scan execution blocks pull request merges on large codebases. Restrict both tools to modified files and direct dependencies.

Use mypy --follow-imports=skip to bypass unchanged modules for quick PR checks. Implement a fallback to full-scan when configuration files change to prevent stale incremental state.

# 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

Common Mistakes

  • Enabling ruff’s type-checking rules alongside mypy --strict: Causes duplicate diagnostics, inflates CI runtime, and creates conflicting error messages during triage. Disable ANN* rules when mypy is active.
  • Sharing a single cache directory between ruff and mypy: Leads to corrupted cache states and unpredictable false positives when runners reuse artifacts. Always use separate directories.
  • Using set -e in wrapper scripts without explicit exit code handling: Terminates the CI job immediately on the first tool failure, preventing log aggregation and masking secondary violations.

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