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.2shows0.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.
See Also
- Python Api Design Principles Design Python functions and classes that feel natural to use — like a well-labeled control panel.
- Python Code Documentation Sphinx Turn Python code comments into a beautiful documentation website automatically.
- Python Project Layout Conventions Organize Python project files like a tidy toolbox so every teammate finds what they need instantly.
- Python Semantic Versioning Read version numbers like a label that tells you exactly how risky an upgrade will be.
- Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.