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.
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.
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.
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.
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 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.
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.
“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.
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.
- Enabling
strict = trueglobally on day one: flipping full strictness on a legacy tree floods the build withdisallow_untyped_defsanddisallow_any_genericserrors, 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 amypy-baselineso 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 runpre-commit run --all-filesin 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 onwarn_unused_ignores/reportUnnecessaryTypeIgnoreCommentso the checker garbage-collects ignores that no longer suppress anything. - Type-checking only changed files in CI:
git diff | xargs mypyskips callers in unchanged modules, so a signature change passes CI and breaks at runtime. Run the full cached (ordmypy) 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 viahashFiles, withrestore-keysfor 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.