Integrating ruff check with mypy in CI: Zero-Conflict Pipeline Configuration
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.
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.
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.
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).
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.
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.
- Enabling ruff’s
ANN*rules alongsidemypy --strict: Causes duplicate diagnostics, inflates CI output, and produces two differently-worded complaints about the same unannotated function during triage. DisableANN*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 -ein 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. Useset +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.