Code Quality: ruff, mypy & Quality Gates¶
What this lesson gives you
A linter catches bugs before they ship; a type checker catches them before they run. ruff is fast enough to run on every save; mypy statically verifies your annotations — together they form an automated first line of code review.
Estimated time: 18 min read · Part: Python Tooling & Data Fundamentals
Learning Objectives
After this lesson you will be able to: (1) configure ruff to enforce linting and formatting in a single tool; (2) write type-annotated Python functions and run mypy; (3) understand the difference between gradual and strict typing; (4) set up pre-commit hooks for local quality gates; (5) integrate ruff and mypy as required CI steps that block merges.
Why Automated Quality Gates Pay Off¶
Code review is expensive: a senior engineer spending 30 minutes reviewing a PR for style issues is a poor use of their time. Automated tools enforce style and catch common bugs in milliseconds, so human reviewers can focus on logic, architecture, and correctness. Three categories of issues are worth automating: style (formatting, import ordering, line length), correctness linting (undefined names, unused variables, shadowed builtins, common bug patterns), and type errors (passing a string where an int is expected, calling a method that doesn't exist on a type).
ruff: How One Tool Replaced Five
Until 2023, a Python project's quality toolchain looked like: flake8 (linting) + isort (import sorting) + black (formatting) + pyupgrade (Python version upgrades) + bandit (security). Five tools with five config sections, five sets of plugins, and a slow combined runtime. ruff, also from Astral and also written in Rust, replaced all five in a single binary. On a large codebase it runs in under a second versus tens of seconds for the old stack. The performance difference is significant enough to run ruff in a file-save editor hook without it feeling slow.
ruff: Linting & Formatting¶
ruff enforces two distinct concerns: linting (checking code for style and correctness issues) and formatting (rewriting code to a canonical style, like black). They are separate subcommands. The full rule set is organized into named categories; you opt in to the ones you want:
[tool.ruff]
line-length = 100
target-version = "py311" # enables rules that require this Python version
exclude = ["migrations/", "scripts/legacy/"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes (undefined names, unused imports)
"I", # isort (import ordering)
"UP", # pyupgrade (modernise syntax for target Python version)
"B", # flake8-bugbear (common bug patterns — highly recommended)
"C4", # flake8-comprehensions (unnecessary list/dict/set calls)
"PIE", # flake8-pie (misc improvements)
"RET", # flake8-return (unnecessary else after return)
"SIM", # flake8-simplify (simplifiable constructs)
"S", # flake8-bandit (security — use with care, many false positives)
]
ignore = [
"E501", # line too long — handled by formatter, not linter
"S101", # use of assert — fine in tests
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S", "B"] # relax security/bugbear rules in tests
# Check for linting violations (does not modify files)
uv run ruff check src/
# Auto-fix what ruff can fix (safe fixes only by default)
uv run ruff check --fix src/
# Format code (like black — modifies files)
uv run ruff format src/
# Check formatting without modifying (for CI)
uv run ruff format --check src/
# Both checks together in CI (fail on any violation)
uv run ruff check src/ && uv run ruff format --check src/
src/acme_data_pipeline/ingestor.py:14:5: F401 os imported but unused src/acme_data_pipeline/pipeline.py:31:20: B006 Do not use mutable data structures for argument defaults Found 2 errors. | Rule prefix | Source tool | Catches | |---|---|---| | E / W | pycodestyle | Style: indentation, whitespace, blank lines | | F | pyflakes | Correctness: undefined names, unused imports/variables | | I | isort | Import ordering: stdlib, third-party, local | | UP | pyupgrade | Modernise syntax: f-strings, type annotations, open mode | | B | flake8-bugbear | Bug-prone patterns: mutable defaults, bare except, loop variable shadowing | | S | bandit | Security: hardcoded passwords, subprocess shell=True, etc. | | C4 | flake8-comprehensions | Unnecessary list() calls, dict.keys() in loops |
mypy: Static Type Checking¶
Python's type annotations are optional hints — the interpreter ignores them at runtime. mypy is a static type checker that reads those annotations and verifies that the code is internally consistent without running it. A mypy error is a bug caught before a single line of code executes. Type-annotating Python code also improves editor intelligence (autocomplete, go-to-definition) and makes the code dramatically easier to read — a function signature is a compact contract.
from __future__ import annotations # postponed evaluation — avoids circular refs
from typing import TypedDict
from collections.abc import Sequence
# TypedDict gives a dict with a fixed, typed schema
class RawEvent(TypedDict):
user_id: str
event: str
value: int
class NormalisedEvent(TypedDict):
user_id: str
event: str
value: int
timestamp: str
def normalise_event(raw: RawEvent) -> NormalisedEvent:
"""Strip and lower user_id; add ISO timestamp."""
from datetime import datetime, timezone
uid = raw["user_id"].strip().lower()
if not uid:
raise ValueError("user_id must not be blank after stripping")
if raw["value"] < 0:
raise ValueError("value must be non-negative")
return NormalisedEvent(
user_id=uid,
event=raw["event"],
value=raw["value"],
timestamp=datetime.now(timezone.utc).isoformat(),
)
def parse_event_batch(events: Sequence[RawEvent]) -> list[NormalisedEvent]:
if not events:
raise ValueError("batch must not be empty")
result: list[NormalisedEvent] = []
for raw in events:
try:
result.append(normalise_event(raw))
except ValueError:
pass # silently drop invalid events
return result
- src/acme_data_pipeline/pipeline.py:8:
- error
- Incompatible return value type (got "int", expected "str") [return-value] Found 1 error in 1 file (checked 4 source files)
Gradual Typing: Start Where You Are
Python supports gradual typing: you add annotations incrementally. By default, mypy ignores unannotated functions — enabling adoption in a large existing codebase without annotating everything at once. Start by annotating the most critical paths (API handlers, data models, database functions). As confidence grows, enable strict = true in mypy config, which turns on a family of checks including requiring annotations on every function and disallowing Any. A good strategy for consulting handoff: enforce strict mypy on all new code in src/, and leave existing legacy code unannotated with a per-module-options exemption.
Pre-commit Hooks & CI Quality Gates¶
Quality tools only help if they run consistently. Two complementary enforcement points: pre-commit hooks run on your local machine before every git commit, catching issues before they even enter the repo. CI quality gates run in the pipeline on every push and PR, enforcing the same checks for every contributor regardless of local setup.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.9
hooks:
- id: ruff # lint + auto-fix
args: [--fix]
- id: ruff-format # format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [pydantic, types-requests]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-toml
# Install hooks into the local git repo (run once per clone)
uv run pre-commit install
# Run all hooks against all files (useful when adopting on an existing project)
uv run pre-commit run --all-files
CI is the Authoritative Gate
Pre-commit hooks are a developer convenience — someone can bypass them with git commit --no-verify, and not all contributors will have them installed. The CI pipeline is the authoritative enforcement point: if uv run ruff check . or uv run mypy src/ fails, the PR cannot be merged, regardless of local setup. Think of pre-commit as fast local feedback and CI as the actual contract.
WHY automation over manual review Manual code review is the most expensive QA mechanism. Automating style and type errors frees reviewers to focus on logic, architecture, and product correctness — the things machines can't check. One ruff rule catching a mutable default argument pays for the entire tool configuration in the first week.
HOW the two-layer approach Pre-commit for local speed, CI for authoritative enforcement. Run ruff check --fix && ruff format on save in your editor for instant feedback. The pre-commit hook catches anything that escaped. CI blocks the merge if anything slips through.
WHERE every production Python project Non-negotiable for any team project. For solo consulting scripts, at minimum run ruff before sharing. For client handoff, ensure the quality gates are in CI so the client's engineers maintain quality without knowing your personal preferences.
Knowledge check
InterviewWhat is the difference between ruff and mypy, and why would you run both?
- They are the same tool; ruff is just faster.
- ruff lints and formats (style, common bug patterns, import ordering); mypy statically verifies type annotations (type correctness). They catch different classes of problem and complement each other.
- mypy replaces ruff; you only need one static analysis tool.
- ruff enforces types; mypy enforces formatting.
Answer
ruff lints and formats (style, common bug patterns, import ordering); mypy statically verifies type annotations (type correctness). They catch different classes of problem and complement each other.
ruff operates on code structure — it finds unused imports, style violations, known bug patterns, and reformats to a canonical style. None of this requires understanding types. mypy reads type annotations and verifies that functions receive and return the types they claim to — it would catch parse_event_batch(42) (passing an int instead of a sequence) at analysis time without running the code. Together they form a multi-layer quality net.
Knowledge check
PracticalA developer complains that mypy is too strict — they can't annotate third-party library types and it's blocking their work. What is the pragmatic resolution?
- Remove mypy from the project entirely.
- Set
ignore_missing_imports = truefor libraries without type stubs, useper-module-optionsto lower strictness on legacy modules, and adopt annotations incrementally rather than requiring them everywhere at once. - Switch from mypy to a runtime type-checking library instead.
- Annotate everything with
Anyto suppress all errors.
Answer
Set ignore_missing_imports = true for libraries without type stubs, use per-module-options to lower strictness on legacy modules, and adopt annotations incrementally rather than requiring them everywhere at once.
Gradual typing is mypy's designed adoption path. ignore_missing_imports = true suppresses errors for libraries without py.typed markers or stub packages (many third-party libraries). Per-module options let you enforce strict typing on new code while giving legacy modules a pass. Annotating with Any everywhere defeats the purpose. The right path: strict typing on new modules, pragmatic exemptions for legacy code, and install stub packages ( types-requests , etc.) for popular third-party libraries.
Exercise
You inherit a function with no type annotations and several ruff violations. (1) Add type annotations so mypy strict would accept it. (2) Fix the ruff violations. (3) Write the CI YAML step that would enforce both checks. The original function: def process(events, threshold=[], limit=None): return [e for e in events if e['value'] > threshold]
Show solution
# Fixed: type-annotated, ruff-clean version
from collections.abc import Sequence
def process(
events: Sequence[dict[str, int]],
threshold: int = 0, # was: mutable default [] (B006 violation)
limit: int | None = None,
) -> list[dict[str, int]]:
result = [e for e in events if e["value"] > threshold]
return result[:limit] if limit is not None else result
# CI YAML step:
# - name: Lint and type-check
# run: |
# uv run ruff check src/ && uv run ruff format --check src/
# uv run mypy src/
Key Takeaways
- ruff replaces flake8, isort, black, and pyupgrade in one Rust-powered binary — fast enough to run on save.
- mypy statically verifies type annotations without running the code — catches type errors before tests do.
- Gradual typing: annotate incrementally; use
ignore_missing_importsfor untyped third-party libraries. - Pre-commit hooks give fast local feedback; CI gates are the authoritative enforcement point.
- Both tools belong in CI as required steps that block PR merges on violations.