Python Atlas
Tooling, web & data

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 mypy

Ruff 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 mypy

A 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 = true

The 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 src

Treat 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 total

Types 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: S307

Add 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 pytest

Pin 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 | None requires callers to handle absence. Do not add None merely to avoid choosing a meaningful default.
  • Ignoring third-party typing globally: prefer a targeted stub package or per-module override.

Practice

  1. Configure Ruff formatting and six intentional lint rule families.
  2. Introduce an unsorted import, an unused variable, and a mutable default; inspect Ruff's findings.
  3. Add strict mypy to a module that parses external JSON.
  4. Replace an Any return value with object plus runtime narrowing.
  5. 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

Ruff, Black, and mypy

1An established project must preserve Black's exact formatting. Which configuration avoids overlapping tool ownership?
2What is the safest typed boundary for JSON returned by an untyped library?