The Static Analysis Pipeline Model
Ruff, Black, mypy, and pre-commit all get lumped together as "code quality tooling," but they are not variations on one mechanism - they are three genuinely different techniques for reading source code without running it, wired together into a pipeline. Ruff Setup and the pages that follow it show how to configure each tool; this page explains why linting, formatting, and type checking exist as separate concerns at all, and why running the same checks in an editor, a pre-commit hook, and CI is a deliberate strategy rather than wasted repetition.
That distinction matters the moment something goes wrong: a formatter fighting a linter over line length, a type checker flagging something Ruff happily ignored, or a pre-commit hook that passes locally but fails in CI, all trace back to not knowing which of these three mechanisms is actually responsible for the check in question.
Summary
- Static analysis tools answer three distinct questions about source code without executing it - is this syntactically/stylistically sound (linting), is this consistently formatted (formatting), and does this type-check (semantic analysis) - and each uses a different internal mechanism to do so.
- Insight: Treating linting, formatting, and type checking as one undifferentiated "quality gate" leads to misconfigured pipelines - rules that fight each other, checks placed at the wrong pipeline stage, or type errors mistaken for style nits.
- Key Concepts: abstract syntax tree (AST), lint rule, formatter idempotency, type inference, shift-left, pre-commit hook.
- When to Use This Model: Deciding where a new check belongs (editor, pre-commit, or CI-only), diagnosing why a formatter and a linter disagree, explaining to a team why type checking is not "just a stricter linter," and designing a pipeline that fails fast without becoming slow or noisy.
- Limitations/Trade-offs: None of these tools proves the program is correct - a linter catches known bad patterns, a formatter enforces consistency, and a type checker verifies internal consistency of declared types, but a fully linted, formatted, and type-checked program can still have wrong business logic.
- Related Topics: the CI/CD pipeline these gates run in, pytest as the layer that actually verifies behavior, editor tooling and language servers.
Foundations
Linting and formatting both start from the same first step - parsing source into an abstract syntax tree (AST), a structured representation of the code's grammar with no comments or whitespace attached - but then do completely different things with it.
A linter walks that tree looking for patterns that match known problems: an unused import, a bare except:, a mutable default argument - each pattern is a rule, and a rule firing produces a diagnostic with a location and a message, optionally alongside an automatic fix.
A formatter, by contrast, does not look for problems at all; it takes the same AST (or a similar concrete syntax tree) and re-prints it according to one fixed style, discarding the original whitespace and line breaks entirely and regenerating them from scratch.
That's why running a formatter twice on already-formatted code produces no changes - idempotency is the formatter's core contract, and any formatter that can't guarantee it is broken by definition. A linter has no equivalent contract, because fixing one violation can sometimes reveal or introduce another (removing an unused import might reveal an unused variable that depended on it), so lint-and-fix is typically run to a fixed point or with a bounded number of passes rather than assumed to converge in one.
A useful analogy: a formatter is a copy editor enforcing one house style with no judgment calls - every manuscript comes out formatted the same way regardless of content. A linter is closer to a proofreader flagging specific known mistakes - a dangling modifier here, a mismatched tense there - each flagged individually, with some flags easy to auto-correct and others requiring a human decision.
# pyproject.toml - two different mechanisms, one config file
[tool.ruff.lint]
select = ["E", "F", "B"] # pattern-matching over the AST
[tool.ruff.format]
quote-style = "double" # deterministic re-printing, no pattern matchingMechanics & Interactions
A type checker like mypy or pyright is a different kind of tool entirely, and conflating it with "a stricter linter" is the single most common misunderstanding in this space.
Where a linter evaluates each pattern largely in local context, a type checker performs type inference across the whole reachable call graph: it reads every function signature and annotation, propagates inferred types through assignments and returns, and checks that every operation is consistent with the types it derived - catching a None passed where a str was expected even when the two lines involved are in completely different files.
That whole-program reasoning is also why type checking is slower than linting on a comparable codebase, and why it is usually run as a separate CI step with its own cache rather than folded into the same pass as Ruff.
The three mechanisms interact through configuration far more than through shared logic, and most of the friction teams hit comes from that interaction rather than from any one tool alone.
The classic example is line length: a formatter enforces one wrapping behavior deterministically, so a linter rule that also flags long lines (E501) will sometimes disagree with what the formatter just produced, which is why Ruff's own setup guidance disables E501 and lets the formatter own line length entirely rather than running two authorities on the same question.
Import sorting shows the same pattern in reverse - it is genuinely a linting concern (there's a correct, checkable order) that Ruff's I rules absorbed from the standalone isort tool, blurring the line between "linter" and "formatter" for that one narrow behavior without changing what either mechanism fundamentally does.
Shift-left describes where in the development loop each of these checks actually runs, and it is a genuine strategy rather than redundant repetition: an editor integration (a language server) surfaces a lint or type error as you type, a pre-commit hook catches anything that slipped through before it reaches version control, and CI is the final, non-negotiable gate that runs the same checks in a clean environment no local machine's drift can bypass.
Each layer exists because the earlier ones are optional or bypassable (a hook can be skipped with --no-verify, an editor extension can be disabled) - CI is what actually enforces the standard, and the earlier layers exist purely to make failing that gate rare and cheap to fix.
Advanced Considerations & Applications
Placement matters more than tool choice once a pipeline scales past a handful of files, because each mechanism has a different cost profile that determines where it belongs. Formatting and fast lint rules are cheap enough to run on every keystroke in an editor and on every commit in a hook; type checking's whole-program analysis is usually too slow for a pre-commit hook on a large codebase and belongs in CI with its own incremental cache, run less frequently but never skipped.
Auto-fixable and structural violations also demand different remediation paths, and pipelines that don't distinguish them create false confidence: a rule with a safe auto-fix (unused import, quote style) can be corrected mechanically and re-verified in the same run, while a structural violation (a genuine type mismatch, a real bug the linter's bugbear rules caught) requires a human to change the actual logic, and no --fix flag will ever resolve it.
Gradual adoption of stricter rules - moving a legacy codebase from check_untyped_defs to full strict = true in mypy, for instance - is really a claim about tolerable noise: enabling every rule at once on an untyped codebase produces thousands of diagnostics that teach engineers to ignore the tool entirely, while a staged rollout keeps the signal-to-noise ratio high enough that people actually act on it.
| Mechanism | Strength | Weakness | Best Fit |
|---|---|---|---|
| Linting (Ruff, pylint) | Fast, catches known bad patterns, many auto-fixable | Only as good as its rule set; misses whole-program logic errors | Every commit and CI run, high-frequency feedback |
| Formatting (Ruff format, Black) | Zero style debate, always idempotent | Has no opinion on correctness, only appearance | Every commit; should never be a manual review discussion |
| Type checking (mypy, pyright) | Catches real cross-file logic bugs (None-safety, wrong shapes) | Slower, whole-program analysis; noisy on untyped legacy code | CI gate, run separately with incremental caching |
Common Misconceptions
- "A type checker is just a linter with more rules." It performs whole-program type inference across your call graph, not local pattern matching - that's a fundamentally different (and slower) kind of analysis, not an extension of the same one.
- "If the formatter and linter disagree, one of them is broken." They're answering different questions by design; disagreement over something like line length usually means the same concern is configured in both places and needs to be owned by one.
- "Running the same lint rule in the editor, pre-commit, and CI is redundant." Each layer exists because the earlier ones are skippable - CI is the only layer that actually enforces anything; the earlier ones exist to make failing CI rare.
- "100% lint-clean and type-clean means the code is correct." Both tools verify internal consistency against known patterns or declared types - neither one checks that your business logic does the right thing, which is what tests are for.
- "Enabling strict mode everywhere immediately is the responsible choice." On an untyped or unlinted legacy codebase it produces so many diagnostics that the tool gets ignored; gradual, staged strictness keeps the signal usable.
FAQs
What's the actual difference between what a linter and a formatter do?
A linter searches an AST for known-bad patterns and reports (or auto-fixes) violations; a formatter re-prints the same AST in one deterministic style, with no notion of "problems" at all.
Why does running a formatter twice on the same file produce no changes?
Idempotency is a formatter's defining property - since it always regenerates whitespace and line breaks from the same fixed rules, formatting already-formatted code is a no-op by construction.
Is import sorting a linting concern or a formatting concern?
It's genuinely a linting concern (there's a checkable correct order), which is why Ruff's I rules absorbed the standalone isort tool's behavior rather than folding it into the formatter.
Why is a type checker slower than a linter on the same codebase?
A linter mostly evaluates patterns in local context; a type checker performs inference across the whole reachable call graph, propagating types through every assignment and return - that whole-program reasoning is inherently more expensive.
Why does disabling `E501` (line-too-long) alongside a formatter make sense?
Because the formatter already owns line-wrapping deterministically; a separate lint rule for the same concern can disagree with what the formatter just produced, so most Ruff setups let one authority own line length.
Why run the same lint rules in an editor, a pre-commit hook, and CI instead of just CI?
Each earlier layer is a cheaper, faster feedback loop that's also skippable - the editor catches issues as you type, the hook catches anything before it's committed, and CI is the one layer nobody can bypass, so together they make failing CI rare rather than duplicating effort.
Can a lint rule and a type checker flag the same bug?
Occasionally, but usually not - a linter's bugbear-style rules catch known dangerous patterns locally (like a mutable default argument), while a type checker catches cross-file inconsistencies a pattern-matching rule has no way to see.
Why does adopting `strict = true` in mypy all at once usually go badly on an existing codebase?
It surfaces every previously-unchecked typing gap simultaneously, often thousands of diagnostics on a large untyped codebase, which teaches engineers to ignore the tool rather than fix issues gradually.
Does an auto-fixable lint violation mean the underlying issue is trivial?
Not always - an auto-fix (like removing an unused import) is safe because it's mechanical, but a structural violation the same linter flags (a real logic bug bugbear caught) still requires a human to change the actual code.
Is a fully linted, formatted, and type-checked codebase guaranteed to be correct?
No - all three tools verify internal consistency (style, formatting, declared types), not that the business logic does the right thing; that's specifically what a test suite is for.
Why do pre-commit hooks sometimes pass locally but fail in CI?
Usually a version or cache mismatch between the locally installed hook and the pinned version CI runs, or a hook that was skipped locally (--no-verify) - CI running in a clean environment is exactly what makes it the authoritative gate.
Should type checking run on test files the same way it runs on application code?
Many teams relax type-checking rules for tests/ (via a mypy override) since test code has different ergonomic needs than production code, while still keeping the application source under full strictness.
Related
- Ruff Setup - configuring the AST-based lint and format mechanism directly
- Black / Ruff Format - the deterministic re-printing mechanism in depth
- Essential Lint Rules - which rule categories carry real signal
- mypy & pyright in CI - the whole-program type inference mechanism
- pre-commit Hooks - the shift-left layer before version control
- Linting in CI/CD - the non-bypassable enforcement layer
Stack versions: This page was written for Python 3.14.0, FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.