Static Analysis Tools & CI Integration for Python

Integrating modern Python static analysis into CI/CD pipelines requires architectural precision. Teams must balance strict type enforcement with execution velocity. Python 3.10+ syntax shifts and analyzer divergence complicate baseline configurations.

Modern Python (3.10+) introduces native union operators and PEP 695 type parameter syntax. These features require explicit analyzer version pinning. Selecting the right baseline toolchain depends heavily on project scale. A detailed Pyright vs Mypy Comparison guides architectural decisions. CI integration must balance strictness, execution speed, and developer feedback loops. Pipelines should never block critical deployments due to false positives.

Python static analysis CI pipeline Python source code feeds ruff (linting), mypy (type checking), and pyright (type checking) in parallel. All three converge at a CI gate. Passing the gate enables merge. Python Source (.py files) ruff lint + style mypy type checking pyright type checking CI Gate all checks pass Merge protected branch
ruff, mypy, and pyright run in parallel; all three must pass the CI gate before a branch can merge.

Modern Python Type System & Analyzer Divergence

Python’s type syntax has shifted substantially between 3.8 and 3.13, and a CI pipeline that ignores those shifts will emit false positives the moment a contributor uses modern syntax. PEP 604 (Python 3.10) replaced Optional[int] and Union[int, str] with the int | None and int | str operator forms, and it also made those unions legal in isinstance() checks. PEP 585 (Python 3.9) deprecated typing.List/typing.Dict in favour of the builtin generics list[int] and dict[str, int]. PEP 695 (Python 3.12) went further, adding the type soft-keyword alias statement and inline type-parameter syntax — def first[T](xs: list[T]) -> T: and class Stack[T]: — which removes the old TypeVar boilerplate entirely. Code using any of these features parses only on the interpreter version that introduced them, so the analyzer must be told which grammar to emulate.

mypy versus pyright analyzer divergence A matrix comparing mypy and pyright across version targeting, strictness control, result caching, and type narrowing. mypy pyright Version target python_version pythonVersion / env Strictness knob strict = true typeCheckingMode Result cache .mypy_cache/ on disk in-memory (editor) Narrowing conservative aggressive flow
Identical source, different verdicts: the four knobs where mypy and pyright most often diverge.

The two dominant checkers reach different conclusions from identical source. mypy is a batch checker: it reads python_version from [tool.mypy], emulates that grammar regardless of the interpreter actually running it, and persists results in .mypy_cache/ for incremental reruns. pyright is a Node-based, editor-first checker whose default target is the interpreter it discovers (or the pythonVersion setting), and whose behaviour is governed by typeCheckingMode (off, basic, standard, or strict) plus fine-grained report* diagnostics such as reportUnknownMemberType and reportMissingTypeStubs. The two diverge most on type narrowing — pyright’s flow analysis is generally more aggressive, narrowing on assert, walrus assignments, and TypeGuard/TypeIs more eagerly, while mypy is stricter about Any propagation through returns. They also disagree on protocol variance and on how an unresolved third-party import degrades to Any.

from __future__ import annotations (PEP 563) is the escape hatch that lets 3.8/3.9 code use int | None and list[str] in annotation position without a runtime TypeError, because all annotations become strings and are never evaluated at import time. Both checkers understand this fully, but it does not help at runtime for tools that call typing.get_type_hints(), and it does not legalise the type statement or PEP 695 generics, which are true grammar changes. For features that outrun your minimum interpreter, import the symbol from typing_extensions (Self, override, TypeIs, ParamSpec) rather than typing; typing_extensions backports the newest constructs to older runtimes while keeping a single import that both analyzers resolve.

Pin exact analyzer versions Mismatched mypy or pyright versions between local environments and CI runners produce inconsistent results. Always pin exact versions in your dependency lockfile and mirror them in pre-commit hook rev: tags.
# Python 3.12+ PEP 695 syntax and PEP 604 union
from collections.abc import Sequence

type Number = int | float  # PEP 695 type alias
type Matrix = Sequence[Sequence[Number]]

def scale(matrix: Matrix, factor: Number) -> Matrix:
    return [[val * factor for val in row] for row in matrix]

def process(data: list[int] | dict[str, int]) -> None:
    if isinstance(data, dict):
        reveal_type(data)  # Type narrowing: dict[str, int]

Toolchain Configuration & Strictness Calibration

strict = true in mypy is not a single flag but an umbrella that enables roughly a dozen sub-flags at once: disallow_untyped_defs, disallow_incomplete_defs, disallow_untyped_calls, check_untyped_defs, warn_return_any, warn_unused_ignores, no_implicit_optional, disallow_any_generics, strict_equality, and more. Turning it on across a legacy codebase in one commit typically surfaces thousands of errors, so the durable pattern is a strictness ladder: start from the defaults, enable a few high-signal flags globally (warn_return_any, no_implicit_optional, warn_unused_ignores), then ratchet toward full strict one module at a time.

Incremental strictness ladder for mypy Five ascending steps show strictness rising from defaults through individual flags to full strict mode. defaults warn_return_any disallow_untyped_defs disallow_any_generics strict = true per-module ratchet, lenient → strict
Adopt strictness as a ladder: enable a few flags globally, then climb toward full strict per module.

Per-file and per-module overrides make that ratchet possible without a monolithic config. In pyproject.toml, [[tool.mypy.overrides]] blocks match a module glob and relax or tighten specific flags — for example disallow_untyped_defs = false for legacy.* while the global default stays strict. pyright expresses the same idea through executionEnvironments (per-path root entries with their own report* levels) and through inline # pyright: strict / # pyright: basic file headers. When you must silence a single line, scope the suppression to the exact code: x = untyped() # type: ignore[assignment] for mypy or # pyright: ignore[reportAssignmentType] for pyright. A bare # type: ignore hides future, unrelated errors on that line; with warn_unused_ignores / reportUnnecessaryTypeIgnoreComment enabled the checker will even flag ignores that no longer suppress anything, keeping the suppression set honest.

Baselines let you adopt strict rules without fixing every historical violation first. mypy has no built-in baseline, but the mypy-baseline tool records the current errors to a file and filters them out of subsequent runs, so only new violations fail CI; pyright’s --outputjson diffed against a committed count achieves the equivalent gate. The key configuration discipline is that python_version/pythonVersion, the strictness knobs, and the exclude globs live in version-controlled pyproject.toml, not in ad-hoc CLI flags, so local runs, pre-commit, and CI evaluate byte-identical settings. See Mypy Configuration & Strictness for the full flag catalogue.

[tool.mypy]
python_version = "3.12"
strict = true
incremental = true
warn_return_any = true
exclude = ["legacy/"]

[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"
reportUnnecessaryTypeIgnoreComment = true
include = ["src/"]

Unified Linting & Type Checking Pipelines

Ruff collapses a stack of previously separate tools into one Rust binary: it re-implements the checks of Flake8 (and dozens of its plugins), isort, pydocstyle, pyupgrade, and autoflake, and — via ruff format — Black-compatible formatting. Rules are addressed by stable codes grouped by origin: E/W (pycodestyle), F (Pyflakes), I (isort import sorting), UP (pyupgrade modernisation), B (flake8-bugbear), SIM (flake8-simplify), and RUF (Ruff-native). You opt in through [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] and pin a target-version = "py312" so UP rewrites match your language floor. Because it is a single process over one shared AST, ruff typically finishes a repo-wide lint in well under a second, which is what makes running it on every push and in pre-commit painless.

Ruff consolidates the legacy lint and format toolchain Six separate legacy tools funnel into one ruff process that also provides ruff format. flake8 isort pyupgrade pydocstyle autoflake black ruff check + fix + ruff format one Rust binary, sub-second
One binary replaces six: ruff subsumes Flake8, isort, pyupgrade, pydocstyle, autoflake, and Black.

Ruff and the type checkers occupy different layers and should run as independent, parallel CI jobs. Linting and formatting are syntactic and fast; type checking is semantic and slower, and neither should block the other. A common layout is a lint job (ruff check --output-format=github . to emit inline PR annotations, plus ruff format --check . to fail on unformatted diffs) running concurrently with a matrixed type-check job that fans out over [mypy, pyright]. Keep the responsibilities disjoint: let ruff own import sorting and style so the mypy/pyright configs never duplicate those concerns, and reserve the type checkers for semantic correctness. ruff check --fix (and --exit-non-zero-on-fix inside hooks) auto-repairs the mechanical violations, leaving human review for the semantic ones. See Ruff Linter Integration for rule-selection strategy.

For output interoperability, ruff speaks --output-format in github, json, junit, and sarif variants: the GitHub format surfaces findings as inline review annotations, a SARIF upload populates the repository Security tab, and JUnit XML feeds test-reporting dashboards. Standardising these formats is what lets a single PR bot render lint and type results side by side without bespoke parsing.

name: Static Analysis CI
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: '3.12'}
      - run: pip install ruff
      - run: ruff check .
  type-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        tool: [mypy, pyright]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: '3.12'}
      - run: pip install ${{ matrix.tool }}
      - run: ${{ matrix.tool }} src/

Developer Workflow & Pre-commit Guardrails

The pre-commit framework installs a Git pre-commit hook that runs your configured checks against staged files before a commit is created, moving the feedback loop from minutes (CI) to milliseconds (local). Its defining feature is reproducibility: every hook repo is pinned by a rev: (a tag or commit SHA), and pre-commit builds isolated, cached environments for each, so every developer and the CI runner execute the exact same tool versions. pre-commit autoupdate bumps those rev: pins in a reviewable commit, and keeping them equal to the versions your pyproject.toml/lockfile installs is precisely what prevents “passes locally, fails in CI” drift.

Shift-left: where a type error is caught, with and without pre-commit Without pre-commit the defect reaches CI and fails late; with pre-commit it is caught at the local hook. Without pre-commit — caught late edit commit push CI run fail in CI minutes later With pre-commit — caught locally edit pre-commit catch + auto-fix commit push CI green
Pre-commit shifts the catch point left, so the same defect never reaches an expensive CI run.

Ruff and mypy sit in a hook file differently. The official astral-sh/ruff-pre-commit repo ships ruff (add args: [--fix, --exit-non-zero-on-fix]) and ruff-format hooks that operate cleanly on the staged file list. mypy is trickier: it needs whole-module context and its own installed dependencies, so it is usually run as a local/system hook with pass_filenames: false and require_serial: true, pointing at the same --config-file pyproject.toml used in CI — otherwise mypy sees only the handful of staged files and misreports cross-module errors. Running mypy against a partial file set is the single most common reason pre-commit type results disagree with CI.

Pre-commit’s staged-file filtering (types: [python], plus files:/exclude: globs) keeps fast hooks fast by analysing only what changed, which preserves developer velocity. Heavier or non-fixable checks can be deferred to the pre-push stage, or to a stages: [manual] hook so they run in CI but not on every commit. Enforce parity by adding a pre-commit run --all-files step to CI: it guarantees the committed rev: pins actually pass repo-wide, closing the gap between the local guardrail and the pipeline. See Pre-commit Hooks Setup for a complete configuration.

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.14.0
    hooks:
      - id: ruff
        args: [--fix, --exit-non-zero-on-fix]
      - id: ruff-format
  - repo: local
    hooks:
      - id: mypy
        name: mypy
        entry: mypy --config-file pyproject.toml
        language: system
        types: [python]
        pass_filenames: false
        require_serial: true

CI/CD Execution & Pipeline Optimization

Static analysis cost scales with the module graph, not the diff, so naive “check everything on every push” pipelines waste minutes on large repos. The most effective lever for mypy is the daemon, dmypy: dmypy run -- --config-file pyproject.toml src/ keeps a warm in-memory graph between invocations and re-analyses only the modules whose fingerprints changed, turning a 90-second cold check into a sub-second warm one. Non-daemon mypy still gets incremental reuse from .mypy_cache/, but a cold cache pays the full price. A one-time mypy --install-types --non-interactive (or predeclared types-* stub packages) prevents the pipeline from stalling on interactive missing-stub prompts.

Choosing a type-check execution strategy A decision tree branches on run context, then on tool or repository shape, to a concrete execution strategy. run context? where does it run local / pre-commit CI full graph changed files only (fast pass) mypy: dmypy + .mypy_cache pyright: raise NODE heap, --stats monorepo: scope per package
Pick the execution strategy by run context first, then by tool or repository shape.

“Just check changed files” is a tempting optimisation that is subtly wrong for type checking. Passing git diff --name-only | xargs mypy analyses those files in isolation, so an edit that changes a function’s return type is never re-checked against its callers in unchanged files, and real breakages slip through. Safe changed-file targeting means checking the changed files plus their reverse dependencies — which in practice is exactly what dmypy and .mypy_cache incrementality already compute across the whole graph. Reserve raw diff-based invocation for pre-commit’s fast local pass, and run the full (cached) graph in CI.

pyright, being a Node process, is bounded by V8’s heap rather than a cache format; very large trees can exhaust the default limit, so raise it with NODE_OPTIONS="--max-old-space-size=8192" and profile with pyright --stats. Wrap every analysis job with an explicit timeout-minutes: so a runaway check fails fast instead of burning the job’s full ceiling, and use a concurrency: group keyed on the ref to cancel superseded runs when a branch is pushed again. For very large or multi-package repositories, scope checks per package rather than per file — see Monorepo Incremental Typing.

# Incremental mypy execution targeting changed files
git diff --name-only origin/main...HEAD | grep '\.py$' | xargs mypy --config-file pyproject.toml

# Pyright with memory cap via Node.js options
NODE_OPTIONS="--max-old-space-size=4096" pyright --stats src/

Caching Strategies & Artifact Management

mypy’s incremental speedup lives in .mypy_cache/, a directory of per-module data files (*.data.json) and dependency-hash meta files (*.meta.json); on each run mypy compares source fingerprints against the meta files and re-checks only what changed. Persisting that directory across CI runs with actions/cache@v4 is what converts every subsequent job from a cold full check into a warm incremental one. The cache is only as good as its key: base it on the tool version and the resolved dependency set, e.g. key: mypy-${{ hashFiles('pyproject.toml','poetry.lock') }} with a broader restore-keys: mypy- fallback so a near-miss still restores a mostly-warm cache instead of starting from zero.

Type-check cache lifecycle and the stale-pass hazard States cycle from cache miss to build to store to key hit; changed inputs return to build, while a loose key leads to a stale pass. cache miss build + check store cache key hit incremental stale pass loose key next run: key match inputs changed
A cache key that omits the analyzer or stub version can serve a stale hit that passes CI against outdated types.

Cache correctness is a tradeoff against staleness. If your key omits the interpreter version, the mypy/pyright version, or the stub packages, a dependency bump can restore a cache built against the old types and silently mask a regression — the checker “passes” using outdated symbol tables. Always fold the analyzer version and the lockfile digest into the key, and let stub or dependency changes rotate the key automatically through hashFiles. Because GitHub Actions scopes caches per branch and evicts the least-recently-used entries once a repository exceeds its ~10 GB budget, keep artifacts lean (cache .mypy_cache/, not the whole virtualenv) so pipeline caches are not evicted under pressure.

pyright derives most of its speed from its in-process incremental model rather than an on-disk cache, so its main cacheable artifact is the downloaded stubs and bundled typeshed under ~/.cache/pyright; caching that mainly saves network time. For daemon-based flows, remember that dmypy’s state is in-memory and does not survive a fresh runner, so on ephemeral CI the on-disk .mypy_cache/ remains the durable artifact to persist. The invariant across all of this: a cache must be a pure function of the inputs that determine analysis output, so a hit is always safe and any changed input always forces a miss.

- name: Cache mypy artifacts
  uses: actions/cache@v4
  with:
    path: .mypy_cache
    key: mypy-${{ hashFiles('pyproject.toml', 'requirements*.txt') }}
    restore-keys: |
      mypy-
- name: Cache pyright stubs
  uses: actions/cache@v4
  with:
    path: ~/.cache/pyright
    key: pyright-${{ hashFiles('pyproject.toml') }}

Common Mistakes

The failure modes below recur across teams adopting static analysis in CI; each pairs a tempting shortcut with the discipline that prevents it.

Common static-analysis CI mistakes and their fixes Four mistake panels on the left map by arrows to their corresponding corrective-practice panels on the right. Mistake Fix strict = true on day one ratchet per module + baseline analyzer version drift pin rev: + lockfile, run --all-files bare # type: ignore ignore[code] + warn_unused_ignores type-check only the diff full cached / daemon graph in CI
Each anti-pattern has a direct, configuration-level remedy.
  • Enabling strict = true globally on day one: flipping full strictness on a legacy tree floods the build with disallow_untyped_defs and disallow_any_generics errors, and the team responds by blanket-adding # type: ignore, which poisons the codebase. Ratchet instead: enable a few flags globally, tighten per module via [[tool.mypy.overrides]], and record a mypy-baseline so only new code faces full strictness.
  • Ignoring analyzer version drift between local and CI: inference rules and error codes change between releases, so a contributor’s mypy 1.8 can accept code that CI’s mypy 1.11 rejects (or vice versa). Pin exact versions in the lockfile, mirror them in every pre-commit rev:, and run pre-commit run --all-files in CI to prove the pins pass repo-wide.
  • Writing bare # type: ignore: an unscoped ignore silences the intended error and every future, unrelated error on that line. Always use # type: ignore[code] / # pyright: ignore[rule], and turn on warn_unused_ignores / reportUnnecessaryTypeIgnoreComment so the checker garbage-collects ignores that no longer suppress anything.
  • Type-checking only changed files in CI: git diff | xargs mypy skips callers in unchanged modules, so a signature change passes CI and breaks at runtime. Run the full cached (or dmypy) graph in CI and reserve diff-based invocation for the fast local pre-commit pass.
  • Caching without an invalidation trigger: a cache key that omits the analyzer and stub versions restores a stale .mypy_cache/ and reports a false pass after a dependency bump. Fold the tool version and lockfile digest into the key via hashFiles, with restore-keys for warm near-misses.

Frequently Asked Questions

Should I run mypy and pyright simultaneously in CI? Running both is generally redundant and slows CI. Choose one as the primary type checker based on ecosystem alignment. Use the other only for targeted validation or migration phases.

How do I handle third-party libraries without type stubs? Use types-* packages from typeshed. Configure ignore_missing_imports selectively per module. Generate local .pyi stubs for critical libraries. Avoid global suppression to maintain type safety.

What is the recommended CI timeout for static analysis? Allocate 5-10 minutes for incremental checks. Reserve 15-20 minutes for full-baseline scans. Implement timeout guards and fallback to incremental mode if thresholds are exceeded.

Can static analysis replace unit testing? No. Static analysis catches type mismatches and syntax violations at compile-time. Unit tests validate runtime behavior, business logic, and edge cases. They remain strictly complementary.

Back to Home