languages/python/code_style_guide.md

Python Code Style Guide (PEP 8, Ruff, Typing Conventions)

Modern Python style guidance for 2026: PEP 8 fundamentals, Ruff configuration and rule selection, naming conventions, PEP 604/695 type hint style, and docstring conventions for maintainable, agent-readable code.

Introduction / Overview

PEP 8 remains the foundational style guide for Python, but in 2026 almost nobody applies it by hand. Ruff has consolidated linting and formatting into a single, extremely fast Rust-based tool that replaces Black, isort, flake8, pyupgrade, pydocstyle, and dozens of plugins. The practical skill is no longer "memorizing PEP 8" but configuring Ruff correctly, choosing a consistent type-hint style aligned with PEP 604 and PEP 695, and writing docstrings that are useful to both humans and coding agents.

Consistent style is not cosmetic. It reduces diff noise in code review, lets static analysis and RAG-based agents parse code reliably, and removes an entire category of bikeshedding from pull requests.

When to use this guidance: Setting up linting/formatting for a new Python project, onboarding a team onto a shared style, or auditing an existing codebase for consistency before a refactor.

Core Concepts

  • PEP 8: The original style guide (naming, whitespace, line length, imports). Still the semantic baseline; Ruff and the formatter enforce it mechanically so nobody needs to check it manually.
  • Ruff as linter + formatter: One binary, one configuration file (pyproject.toml or ruff.toml), sub-millisecond-per-file performance. Ruff reimplements the intent of Black's formatter and hundreds of flake8-family rule sets.
  • Rule selection is a design decision: Ruff ships with dozens of rule categories (E/W pycodestyle, F pyflakes, I isort, UP pyupgrade, B bugbear, SIM simplify, RUF Ruff-native, ANN annotations, D docstrings, and more). Only a curated subset should be enabled - enabling everything produces noise, not quality.
  • Type hint style as a first-class style concern: PEP 604 (X | Y) and PEP 695 (class Box[T], type Alias = ...) are now the idiomatic way to express unions and generics; the old Union, Optional, and TypeVar-based generic syntax are legacy forms retained for backward compatibility.
  • Docstrings as machine-readable contracts: With code increasingly read by agents and static analyzers rather than only humans, a consistent docstring convention (Google or NumPy style) is a search and comprehension aid, not decoration.

Best Practices

1. Adopt Ruff for Both Linting and Formatting

Rationale: A single tool with one configuration file eliminates the historical conflicts between Black, isort, and flake8, and runs orders of magnitude faster, which matters for pre-commit hooks and CI feedback loops.

# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["src"]

[tool.ruff.lint]
select = [
    "E", "W",   # pycodestyle
    "F",        # pyflakes
    "I",        # isort (import sorting)
    "UP",       # pyupgrade (modernize syntax)
    "B",        # flake8-bugbear (likely bugs)
    "SIM",      # flake8-simplify
    "RUF",      # Ruff-native rules
    "ANN",      # type annotation presence
    "D",        # pydocstyle
]
ignore = [
    "D203",    # conflicts with D211 (blank line before class docstring)
    "D212",    # conflicts with D213 (multi-line summary placement)
]

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.format]
quote-style = "double"
docstring-code-format = true

Why this works well in production:

  • ruff check . replaces flake8 + isort + pyupgrade + bugbear in one pass.
  • ruff format . replaces Black with near-identical output, so migration is close to a no-op.
  • uv run ruff check --fix . auto-fixes the large majority of findings.

2. Enable Rules Incrementally, Not All at Once

Rationale: Turning on ALL in an existing codebase produces thousands of findings and drowns real issues in noise. Start with correctness-oriented sets (E, F, B) and layer in stylistic sets once the codebase is clean.

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "B"]
# Add "UP", "SIM", "ANN", "D" as follow-up PRs once the baseline is green.

Production tip: Use ruff check --add-noqa sparingly and audit # noqa comments in review - a noqa without a rule code (# noqa vs # noqa: E501) silences everything on that line.

3. Follow PEP 8 Naming Conventions Consistently

ElementConventionExample
Modules / packagessnake_caseuser_service.py
Classes / exceptionsPascalCaseUserNotFoundError
Functions / variablessnake_casegetactiveusers()
ConstantsUPPERSNAKECASEMAXRETRYCOUNT = 3
"Private" attributesleading underscoreself.connectionpool
Type variables (legacy)short PascalCaseT, KT, VT

Ruff's N (pep8-naming) rule set catches most violations automatically; enable it alongside E/F.

4. Use PEP 604 Union Syntax and PEP 695 Generics

Rationale: X | Y and native generic syntax are shorter, require no imports from typing, and are what ruff (UP007, UP040) will auto-migrate old code toward.

# Legacy (still valid, but flagged by UP007/UP045)
from typing import Optional, Union, TypeVar, Generic

T = TypeVar("T")

class Repository(Generic[T]):
    def find(self, id_: int) -> Optional[T]:
        ...

def parse(value: Union[str, bytes]) -> int:
    ...

# Modern (PEP 604 + PEP 695, Python >= 3.12)
class Repository[T]:
    def find(self, id_: int) -> T | None:
        ...

def parse(value: str | bytes) -> int:
    ...

type UserId = int
type JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]

Why this works well in production: fewer imports, better readability at call sites, and type aliases are lazily evaluated, avoiding forward-reference string quoting in most cases.

5. Write Docstrings for the Public API Surface

Rationale: Docstrings are the primary retrieval unit for both IDE tooltips and RAG-based coding agents. A consistent convention (this KB assumes Google style) makes automated extraction reliable.

def retry_with_backoff(
    func: Callable[[], T],
    *,
    max_attempts: int = 3,
    base_delay_seconds: float = 0.5,
) -> T:
    """Execute ``func`` with exponential backoff on failure.

    Args:
        func: Zero-argument callable to execute.
        max_attempts: Maximum number of attempts before raising.
        base_delay_seconds: Initial delay, doubled after each failure.

    Returns:
        The return value of ``func`` on the first successful attempt.

    Raises:
        RuntimeError: If all attempts are exhausted.
    """

Note: This project's own repository convention (see Clean Code Principles) forbids comments/docstrings in internal implementation code in favor of self-documenting names; reserve docstrings for genuinely public, reusable APIs and library boundaries.

Common Pitfalls & Anti-Patterns

Pitfall: Mixing Ruff with Black, isort, and flake8 Simultaneously

Problem: Running Black and ruff format together produces churn as each tool fights the other's opinion on edge cases (e.g., magic trailing comma handling), and CI becomes flaky.

Recommended approach: Pick one formatter. In 2026, ruff format is the pragmatic default for new projects; keep Black only if a large legacy codebase depends on Black-specific behavior not yet matched by Ruff.

Pitfall: Suppressing Warnings with Bare # noqa

Problem: A bare # noqa silences every rule on that line, including ones introduced later, hiding real regressions.

Recommended approach: Always scope the suppression: # noqa: E501 and require a short justification in review for any new suppression.

Pitfall: Inconsistent Union Style Across a Codebase

Problem: Mixing Optional[str], Union[str, None], and str | None in the same codebase makes grep-based search and agent pattern-matching unreliable.

Recommended approach: Enable UP007/UP045 in Ruff and run ruff check --fix once to normalize the entire codebase in a single, reviewable commit.

Testing Strategies

Style enforcement is not tested with pytest, but it must be part of the same automated gate as tests:

  • Run ruff check . and ruff format --check . in CI as a required status check, failing the build on any violation or unformatted file.
  • Use pre-commit with the official ruff-pre-commit hooks so violations are caught before commit, not in CI.
  • Treat new Ruff rule adoption like a dependency upgrade: bump in an isolated PR, review the diff, and pin the Ruff version in dependency-groups.dev so CI and local runs stay in sync.
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

Performance, Security & Scalability Considerations

  • Ruff is written in Rust and typically lints/formats an entire mid-size codebase (50k+ lines) in well under a second, making it viable to run on every keystroke in editor integrations without perceptible latency.
  • Enabling S (flake8-bandit-derived security rules) surfaces common issues such as use of eval, weak hashing, or hardcoded credentials directly in the linter, shifting a class of security review left into every pull request.
  • Consistent formatting reduces merge conflict surface area significantly in large teams, since diffs reflect only semantic changes rather than incidental whitespace differences.
  • CI cost: Ruff's speed means style checks add negligible time to pipelines compared to legacy flake8 + isort + Black chains, which is significant at scale (hundreds of PRs/day).

Edge Cases & Advanced Usage

  • Monorepos with multiple target Python versions: Set target-version per pyproject.toml if sub-packages support different minimum Python versions; Ruff will avoid suggesting syntax not available in the older target.
  • Generated code: Exclude generated files (protobuf stubs, migrations) via [tool.ruff] exclude = [...] rather than sprinkling # noqa throughout generated output.
  • Gradual typing migrations: Use ANN rules with ignore overrides per-path ([tool.ruff.lint.per-file-ignores]) to enforce strict annotations on new modules while grandfathering legacy ones.
  • Preview rules: Ruff ships a preview mode with rules not yet stabilized. Useful for early adoption but should not gate CI on unstable rule behavior.

When Not to Use / Alternatives

DimensionRuffBlack + isort + flake8Winner
SpeedOutstanding (Rust)Slow (pure Python, multi-tool)Ruff
Configuration surfaceSingle file, one toolThree+ config filesRuff
Plugin ecosystem maturityGrowing, covers most flake8 plugins nativelyVery mature, long tail of niche pluginsLegacy stack (rare cases)
Editor integrationExcellent (LSP, fast enough for on-type)Good but noticeably slowerRuff

Use the legacy Black/isort/flake8 stack only when a specific flake8 plugin with no Ruff equivalent is mission-critical, or when organizational policy mandates tools with a longer audit history.

Glossary

  • PEP 8: Python's official style guide covering layout, naming, and idioms.
  • Ruff: A Rust-based linter and formatter that supersedes Black, isort, flake8, and many plugins.
  • PEP 604: Introduces X | Y syntax for union types, replacing typing.Union/typing.Optional in modern code.
  • PEP 695: Introduces native generic syntax (class Box[T]) and the type statement for type aliases.
  • noqa: A comment directive that suppresses a linter warning on a specific line; should always be scoped to a rule code.
  • Rule set: A category of related lint rules in Ruff, identified by a letter prefix (e.g., F for pyflakes).