Python Docstring Conventions — Deep Dive

How Python Stores Docstrings

When Python compiles a module, it stores the first expression in a function, class, or module body as the __doc__ attribute — but only if that expression is a string literal. This is a compiler-level feature, not a runtime convention:

def greet(name):
    """Say hello."""
    return f"Hello, {name}"

print(greet.__doc__)  # "Say hello."

Docstrings vs Comments

Comments (#) are stripped during compilation and never appear at runtime. Docstrings become part of the object and are accessible via help(), inspect.getdoc(), and Sphinx autodoc. This distinction matters: use docstrings for interface documentation (visible to users) and comments for implementation notes (visible only in source).

The __doc__ Attribute

__doc__ is writable. Decorators can replace it, which is useful for wrapper functions:

import functools

def logged(func):
    @functools.wraps(func)  # preserves __doc__ and __name__
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Without @functools.wraps, the wrapper replaces the original docstring with None, breaking help() and autodoc.

Docstrings and Type Checkers

Redundancy Between Type Hints and Docstrings

With PEP 484 type hints, parameter types in docstrings can become redundant:

# Redundant — type is in both places
def fetch(url: str, timeout: int = 30) -> Response:
    """Fetch a URL.

    Args:
        url (str): The target URL.
        timeout (int): Timeout in seconds.
    """

The modern approach: omit types from docstrings when type hints are present, and let Sphinx’s autodoc_typehints = "description" merge them:

def fetch(url: str, timeout: int = 30) -> Response:
    """Fetch a URL.

    Args:
        url: The target URL.
        timeout: Timeout in seconds. Zero disables the timeout.
    """

This avoids synchronization issues where the type hint says int but the docstring says float.

sphinx-autodoc-typehints

This extension renders type hints directly in Sphinx output, eliminating the need for :type: and :rtype: fields entirely:

pip install sphinx-autodoc-typehints
# conf.py
extensions = ["sphinx.ext.autodoc", "sphinx_autodoc_typehints"]
typehints_fully_qualified = False
always_document_param_types = True

Writing Docstrings for Complex Signatures

Overloaded Functions

Python’s @overload creates multiple signatures. Document the general behavior, then explain each overload:

from typing import overload

@overload
def parse(data: str) -> dict: ...
@overload
def parse(data: bytes) -> dict: ...
@overload
def parse(data: str, raw: Literal[True]) -> str: ...

def parse(data, raw=False):
    """Parse input data into a structured result.

    The return type depends on the input:

    - ``str`` input returns a parsed ``dict``
    - ``bytes`` input is decoded to UTF-8 first, then parsed
    - With ``raw=True``, returns the intermediate string representation

    Args:
        data: Input to parse. Accepts strings or bytes.
        raw: If True, return the raw parsed string instead of a dict.

    Returns:
        A dict of parsed fields, or a raw string if ``raw=True``.
    """

Callback Parameters

When a parameter is a callable, document its expected signature:

def on_event(callback):
    """Register an event handler.

    Args:
        callback: A function with signature
            ``(event_type: str, payload: dict) -> None``.
            Called whenever an event is received.
    """

Context Managers

Document both the enter and exit behavior:

def transaction(conn):
    """Execute operations within a database transaction.

    Begins a transaction on entry. Commits on successful exit,
    rolls back if an exception propagates.

    Args:
        conn: An active database connection.

    Yields:
        A cursor bound to the transaction.

    Example:
        >>> with transaction(conn) as cur:
        ...     cur.execute("INSERT INTO users ...")
    """

Docstring Testing with doctest

Embedding Testable Examples

def celsius_to_fahrenheit(celsius):
    """Convert Celsius to Fahrenheit.

    Args:
        celsius: Temperature in Celsius.

    Returns:
        Temperature in Fahrenheit.

    Examples:
        >>> celsius_to_fahrenheit(0)
        32.0
        >>> celsius_to_fahrenheit(100)
        212.0
        >>> celsius_to_fahrenheit(-40)
        -40.0
    """
    return celsius * 9 / 5 + 32

Run with:

python -m doctest my_module.py
# or via pytest:
pytest --doctest-modules

Limitations of doctest

  • Floating-point comparisons are brittle (0.1 + 0.2 shows 0.30000000000000004)
  • Complex setup (database connections, file creation) is awkward
  • Output must match exactly, including whitespace

Use doctest for simple pure functions. For anything involving I/O or complex state, stick to pytest.

Custom Docstring Linting

Building a ruff Plugin (via flake8 rules)

ruff supports custom rules through its plugin system. For simpler needs, write a script:

# scripts/lint_docstrings.py
"""Enforce team docstring standards beyond pydocstyle."""
import ast
import sys

REQUIRED_SECTIONS = {"Args", "Returns"}

def check_function(node, filepath):
    docstring = ast.get_docstring(node)
    if not docstring:
        return

    # Check for required sections in public functions
    if node.name.startswith("_"):
        return

    missing = []
    for section in REQUIRED_SECTIONS:
        if f"{section}:" not in docstring and f"{section}\n" not in docstring:
            missing.append(section)

    if missing and node.args.args:  # skip no-arg functions
        print(f"{filepath}:{node.lineno}: {node.name}() missing sections: {', '.join(missing)}")
        return 1
    return 0

def check_file(filepath):
    with open(filepath) as f:
        tree = ast.parse(f.read())

    errors = 0
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            errors += check_function(node, filepath) or 0
    return errors

if __name__ == "__main__":
    total = sum(check_file(f) for f in sys.argv[1:])
    sys.exit(1 if total else 0)

Integrating with pre-commit

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: docstring-lint
        name: Custom docstring linter
        entry: python scripts/lint_docstrings.py
        language: python
        types: [python]

Docstring Coverage Metrics

interrogate Configuration

# pyproject.toml
[tool.interrogate]
ignore-init-method = true
ignore-init-module = true
ignore-magic = true
ignore-semiprivate = true
ignore-private = true
fail-under = 90
exclude = ["tests", "docs"]
verbose = 2

Generating Badges

interrogate --generate-badge docs/_static/interrogate.svg

Add the badge to your README to signal documentation commitment to users and contributors.

Docstrings in Protocols and ABCs

Abstract Method Docstrings

When using abc.ABC or typing.Protocol, docstrings on abstract methods serve as the interface contract:

from typing import Protocol

class Repository(Protocol):
    def find_by_id(self, id: int) -> Model | None:
        """Retrieve a model by its primary key.

        Args:
            id: The primary key to look up.

        Returns:
            The matching model, or None if not found.
            Implementations must not raise on missing records.
        """
        ...

Implementations inherit this docstring (via inspect.getdoc) unless they override it. This means the contract documentation flows to all implementing classes automatically.

Internationalization

For projects serving multilingual audiences, docstrings can be translated using Sphinx’s gettext builder:

sphinx-build -b gettext docs/ docs/_build/gettext
sphinx-intl update -p docs/_build/gettext -l ja -l de

This generates .po files for translation. Translators work on .po files, and Sphinx builds localized documentation from them. The source docstrings remain in English.

Tradeoffs

  • Google style maximizes readability but lacks the formal structure some tools expect.
  • NumPy style is verbose but extremely clear for complex scientific APIs with multiple return values.
  • reST style has the best Sphinx integration but is painful to read in source code.
  • Type hints + minimal docstrings reduces duplication but may not satisfy documentation coverage tools.
  • doctest examples keep docs honest but are fragile for complex scenarios.

The one thing to remember: Docstrings are a contract between the author and every future reader — invest in a consistent style, automate enforcement, and focus on documenting intent and edge cases that type hints cannot express.

pythondocumentationbest-practices

See Also