Ruff, Black, and mypy
Automate formatting, linting, import hygiene, and static type checking without tool overlap.
Ruff, Black, and mypy
Formatting, linting, and type checking answer different questions:
- a formatter makes layout deterministic;
- a linter detects suspicious or inconsistent source patterns;
- a type checker verifies contracts without executing the program.
Use all three categories, but avoid configuring two tools to fight over the same rule.
Install the toolchain
uv add --dev ruff mypyRuff includes a formatter designed as a Black-compatible replacement for most projects. For an existing project that must preserve Black's exact established output, keep Black:
uv add --dev ruff black mypyA focused configuration
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"I", # import sorting
"B", # bugbear
"UP", # Python upgrades
"SIM", # simplifications
]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
[tool.mypy]
python_version = "3.12"
strict = true
files = ["src"]
warn_unreachable = true
show_error_codes = trueThe formatter owns line wrapping, so disabling linter rule E501 prevents redundant complaints.
Start with a focused rule set and enable additional families deliberately; selecting every rule
usually produces contradictory requirements and noisy suppression.
If Black remains the formatter:
[tool.black]
line-length = 100
target-version = ["py312"]Run black . and ruff check ., but do not also run ruff format.
Local commands
# Ruff as formatter
uv run ruff format .
uv run ruff check --fix .
# Verification without modification
uv run ruff format --check .
uv run ruff check .
uv run mypy srcTreat automatic fixes as code changes: review the diff and run tests. --fix is not proof that
behavior is preserved.
Add useful type boundaries
from collections.abc import Iterable
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True, slots=True)
class LineItem:
sku: str
quantity: int
unit_price: Decimal
def order_total(items: Iterable[LineItem]) -> Decimal:
total = Decimal("0")
for item in items:
if item.quantity < 0:
raise ValueError("quantity cannot be negative")
total += item.unit_price * item.quantity
return totalTypes make the domain contract visible: callers may pass any iterable, values use Decimal, and the
result is also Decimal. Runtime validation is still necessary because type hints are not enforced
when Python executes.
Prefer precise types:
from typing import Literal, TypeAlias
OrderState: TypeAlias = Literal["draft", "submitted", "cancelled"]
def can_edit(state: OrderState) -> bool:
return state == "draft"Avoid spreading Any; it disables checking wherever it flows. At an untyped boundary, accept
object, validate or narrow it, then return a precise domain type.
Narrow untrusted data
from typing import TypedDict
class UserPayload(TypedDict):
id: int
name: str
def parse_user(value: object) -> UserPayload:
if not isinstance(value, dict):
raise TypeError("user must be an object")
user_id = value.get("id")
name = value.get("name")
if not isinstance(user_id, int) or not isinstance(name, str):
raise ValueError("user requires integer id and string name")
return {"id": user_id, "name": name}For large schemas, use a validation library such as Pydantic rather than repeating manual checks.
Suppress narrowly
When a suppression is unavoidable, include the rule or error code:
result = dynamic_library.call() # type: ignore[no-untyped-call]For Ruff:
value = legacy_api() # noqa: S307Add a short reason if it is not obvious. A bare # type: ignore can hide new, unrelated errors on
the same line.
Generated files and migrations
Exclude generated sources instead of formatting or typing them:
[tool.ruff]
extend-exclude = ["generated", "migrations/versions"]
[tool.mypy]
exclude = ["^generated/", "^migrations/versions/"]Do not broadly exclude application code to make checks green. Tighten types incrementally with module-specific overrides if a legacy codebase cannot adopt strict mode at once.
CI ordering
Fast, deterministic checks should fail first:
uv sync --locked
uv run ruff format --check .
uv run ruff check .
uv run mypy src
uv run pytestPin the resolved tool versions in the lockfile. Different formatter versions can produce needless diffs even when both are valid.
Common pitfalls
- Formatting as correctness: clean formatting does not prove valid behavior.
- Ruff and Black both formatting: select one formatter for a project.
- Types only in local variables: prioritize public functions, data models, and I/O boundaries.
cast()as conversion:typing.cast()changes only the checker's view; it does nothing at runtime.- Overusing
Optional:str | Nonerequires callers to handle absence. Do not addNonemerely to avoid choosing a meaningful default. - Ignoring third-party typing globally: prefer a targeted stub package or per-module override.
Practice
- Configure Ruff formatting and six intentional lint rule families.
- Introduce an unsorted import, an unused variable, and a mutable default; inspect Ruff's findings.
- Add strict mypy to a module that parses external JSON.
- Replace an
Anyreturn value withobjectplus runtime narrowing. - Compare Ruff formatter and Black on a temporary branch, then select exactly one.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK