Skip to main content

Docstrings and Documentation - Code That Explains Itself

Reading time: ~25 minutes | Level: Foundation → Engineering

# Version A: A comment that explains what, not why
def calc(p, r, n):
# multiply p by (1 + r) raised to the power n
return p * (1 + r) ** n

# Version B: A docstring that makes this function usable by anyone
def compound_interest(principal: float, annual_rate: float, years: int) -> float:
"""Calculate compound interest on a principal amount.

Uses the standard compound interest formula: A = P(1 + r)^t

Args:
principal: Initial investment amount in dollars. Must be positive.
annual_rate: Annual interest rate as a decimal (0.05 for 5%).
years: Number of years to compound. Must be a non-negative integer.

Returns:
Total accumulated amount (principal + interest) after the given years.

Raises:
ValueError: If principal is negative or years is negative.

Example:
>>> compound_interest(1000, 0.05, 10)
1628.894627777173
>>> compound_interest(5000, 0.07, 20)
19348.418120715456
"""
if principal < 0:
raise ValueError(f"principal must be non-negative, got {principal}")
if years < 0:
raise ValueError(f"years must be non-negative, got {years}")
return principal * (1 + annual_rate) ** years

Version A requires the reader to understand the math to know what p, r, and n mean. Version B can be called correctly by an engineer who has never seen compound interest before. That is the standard to aim for.

Documentation is not about writing more. It is about closing the gap between what your code does and what someone who has never seen it can understand in 60 seconds. The right documentation strategy combines good names, precise type annotations, well-placed comments, and docstrings - in that order of preference.

What You Will Learn

  • The documentation spectrum: from code names to external docs, and when to use each layer
  • When a comment is wrong vs when it is the right tool
  • Module docstrings: structure and conventions
  • Function and method docstrings in Google style (the industry default)
  • NumPy style docstrings for data science and scientific Python
  • Sphinx/reStructuredText style for legacy and stdlib-adjacent projects
  • Class docstrings: what belongs there and what does not
  • The __all__ convention and its impact on documentation
  • How type annotations complement and reduce docstring verbosity
  • pydoc and help() - how docstrings surface in the terminal
  • Sphinx with autodoc: generating HTML API docs from docstrings
  • mkdocs with mkdocstrings: the modern, simpler alternative
  • Writing examples in docstrings: doctest format and its value
  • The discipline of keeping documentation current

Prerequisites

  • Comfort writing Python functions and classes
  • Basic understanding of type annotations (int, str, list[str], etc.)
  • Familiarity with the command line

The Documentation Spectrum

Documentation exists at multiple layers, and using the wrong layer for a given piece of information is as harmful as having no documentation at all.

Code itself
└─ Names (functions, variables, classes)
└─ Type annotations
└─ Inline comments
└─ Docstrings
└─ README / architecture docs
└─ External docs site

Each layer has a different audience, lifespan, and maintenance cost:

LayerBest forMaintained byAudience
NamesWhat code doesEvery editAnyone reading the code
Type annotationsWhat types flow in/outAlongside code changesType checkers + humans
Inline commentsWHY a non-obvious choice was madeCode authorFuture maintainers
DocstringsHow to USE a function/class/moduleFunction authorCallers, API users
READMEProject setup, purpose, examplesTeamNew contributors
Docs siteTutorials, how-to guides, referenceDedicated effortEnd users

The key principle: prefer a lower layer over a higher one when it is sufficient. If a function name already tells the caller everything they need, a docstring is noise. If a rename makes a comment redundant, delete the comment.

When a Comment Is Wrong

A comment that explains WHAT code does is a symptom: the code is not clear enough to explain itself.

# BAD: comment explains what the code already says
# Increment the counter by 1
counter += 1

# BAD: comment restates the condition
# Check if the user is an admin
if user.role == "admin":
...

# BAD: comment explains a bad name
# x is the number of retries
x = 3

Each of these comments would disappear with better code:

# Good: no comment needed - the code is self-explaining
retry_count += 1

if user.is_admin:
...

MAX_RETRIES = 3

When a Comment Is Right

A comment is appropriate when it explains WHY a decision was made - something the code cannot express.

# RIGHT: explains a non-obvious business rule
# Per the billing team's decision on 2024-03-15, premium users receive
# the discount before tax is applied, not after. This differs from standard
# users. See Slack thread #billing-decisions for the rationale.
if user.is_premium:
amount = apply_discount(amount)
amount += calculate_tax(amount)

# RIGHT: explains a workaround for an external bug
# The Stripe SDK returns a float for amount but their docs promise cents (int).
# Converting here to avoid a downstream precision bug.
# Remove when Stripe SDK >= 5.2.0 is deployed (tracked in JIRA ENG-4421).
amount_cents = int(charge.amount)

# RIGHT: explains an algorithm reference
# Binary search with the invariant: array[left] <= target < array[right]
# See Knuth TAOCP Vol. 3, Section 6.2.1, Algorithm B
while left < right:
mid = (left + right) // 2
...

The test: after reading the comment, do you understand something about the code that was not obvious from reading the code itself? If yes, it is a good comment. If no, delete it.

Module Docstrings

A module docstring appears at the very top of a Python file, before any imports. Its audience is a developer who opens the file for the first time and needs to understand its purpose quickly.

"""Payment processing utilities for the EngineersOfAI platform.

This module provides functions for charging cards, issuing refunds,
and querying payment history via the Stripe API. All monetary amounts
are in the smallest currency unit (cents for USD/EUR/GBP).

Typical usage:
from payments import charge_card, refund_charge

payment = charge_card(
amount_cents=4999,
currency="usd",
card_token="tok_visa_debit",
description="Pro plan - monthly",
)
print(payment.id) # ch_3OqZ8...

Dependencies:
stripe >= 7.0.0
See requirements.txt for the full version pinning.

Note:
All functions in this module are synchronous. For async usage,
see payments_async.py which wraps this module with asyncio.to_thread.
"""

import stripe
from .models import Payment, Refund

Key elements of a good module docstring:

  • One-sentence summary on the first line
  • What the module contains and its scope
  • A usage example showing the most common import pattern
  • Any important constraints (monetary units, thread safety, etc.)
  • Dependencies that are not obvious from imports

Function and Method Docstrings - Google Style

Google style is the most widely used Python docstring convention. It is readable in raw form, supports all necessary sections, and is parsed by Sphinx (with the napoleon extension) and mkdocstrings out of the box.

Structure

def function_name(param1: type, param2: type) -> return_type:
"""One-line summary of what the function does.

Optional extended description. Use this when the one-liner is
insufficient - for example, to describe the algorithm used,
the edge cases handled, or important performance characteristics.

Args:
param1: Description of param1. Include units, constraints,
and what happens at boundary values.
param2: Description of param2. Continuation lines are
indented by 4 spaces past "param2:".

Returns:
Description of the return value. If returning a dict or
complex object, describe the keys/fields.

Raises:
ValueError: When param1 is negative or param2 is empty.
ConnectionError: When the external service is unreachable.

Example:
>>> function_name(42, "hello")
"hello42"
>>> function_name(0, "")
Traceback (most recent call last):
...
ValueError: param2 cannot be empty
"""

A Real Example

from datetime import datetime

def calculate_age_in_days(birth_date: datetime, reference_date: datetime | None = None) -> int:
"""Return the number of complete days between birth_date and reference_date.

When reference_date is None, the current UTC datetime is used. This function
is useful for age-gating and subscription duration calculations.

Args:
birth_date: The start date. Must be timezone-naive (UTC assumed) or
timezone-aware. Cannot be in the future relative to reference_date.
reference_date: The end date for the calculation. Defaults to
datetime.utcnow() when not provided.

Returns:
Non-negative integer number of complete days elapsed. Returns 0 if
birth_date equals reference_date.

Raises:
ValueError: If birth_date is after reference_date.

Example:
>>> from datetime import datetime
>>> birth = datetime(2000, 1, 1)
>>> reference = datetime(2000, 1, 11)
>>> calculate_age_in_days(birth, reference)
10
"""
if reference_date is None:
reference_date = datetime.utcnow()
if birth_date > reference_date:
raise ValueError(
f"birth_date ({birth_date}) cannot be after reference_date ({reference_date})"
)
return (reference_date - birth_date).days

Section Reference

SectionPurposeRequired?
Summary (first line)One-sentence descriptionAlways
Extended descriptionAlgorithm, edge cases, contextWhen needed
Args:Each parameter: name, description, constraintsWhen params are non-obvious
Returns:What is returned, including type if not in annotationWhen return value needs explanation
Raises:Exceptions that callers should expectWhen the function raises
Example: or Examples:Doctest-format usage examplesStrongly recommended
Note:Important side effects or limitationsWhen relevant
Yields:For generators: what is yielded per iterationFor generators

NumPy Style Docstrings

NumPy style is the convention used by NumPy, SciPy, pandas, and most of the scientific Python ecosystem. It uses section underlines (dashes) for visual separation and is more verbose than Google style - appropriate for functions with many parameters or complex return types.

def moving_average(data: list[float], window_size: int, fill_value: float = 0.0) -> list[float]:
"""Compute the simple moving average of a data series.

Parameters
----------
data : list of float
Input time series. Must contain at least one element.
window_size : int
Number of data points to include in each average window.
Must be a positive integer no greater than len(data).
fill_value : float, optional
Value used to pad the result at positions where a full window
is not yet available. Default is 0.0.

Returns
-------
list of float
Moving averages of the same length as data. The first
``window_size - 1`` entries will equal ``fill_value``.

Raises
------
ValueError
If ``window_size`` is less than 1 or greater than ``len(data)``.
TypeError
If ``data`` contains non-numeric values.

See Also
--------
exponential_moving_average : Weighted variant that emphasizes recent values.
cumulative_moving_average : Running cumulative average.

Examples
--------
>>> moving_average([1, 2, 3, 4, 5], window_size=3)
[0.0, 0.0, 2.0, 3.0, 4.0]

>>> moving_average([10, 20, 30], window_size=2, fill_value=-1.0)
[-1.0, 15.0, 25.0]
"""
if window_size < 1 or window_size > len(data):
raise ValueError(f"window_size must be between 1 and {len(data)}, got {window_size}")
result = [fill_value] * (window_size - 1)
for i in range(window_size - 1, len(data)):
window = data[i - window_size + 1 : i + 1]
result.append(sum(window) / window_size)
return result

NumPy style distinctive features:

  • Section titles underlined with dashes (Parameters\n----------)
  • Type information in the parameter list, separate from the name (data : list of float)
  • See Also section linking related functions - invaluable in large libraries
  • More whitespace, more scannable for long parameter lists

Sphinx/reStructuredText Style

Sphinx/reStructuredText (reST) style is the oldest Python docstring convention, used by the Python standard library itself and many legacy projects. It uses inline field lists for parameters.

def parse_config(path: str, strict: bool = False) -> dict:
"""Parse a YAML configuration file and return its contents as a dict.

If ``strict`` is True, unknown keys will raise a ``ConfigError``.
Otherwise, unknown keys are silently ignored.

:param path: Absolute or relative path to the YAML config file.
:type path: str
:param strict: If True, raise ConfigError for unrecognized keys.
Defaults to False.
:type strict: bool
:returns: Parsed configuration as a nested dictionary.
:rtype: dict
:raises FileNotFoundError: If the file at ``path`` does not exist.
:raises ConfigError: If ``strict=True`` and an unknown key is found.

Usage::

config = parse_config("settings.yaml", strict=True)
db_host = config["database"]["host"]
"""

reST style is verbose and less readable in raw form. For new projects, prefer Google or NumPy style. Use reST when contributing to a project that already uses it, or when generating Sphinx docs without the napoleon extension.

Class Docstrings

A class docstring describes the class's purpose and its public attributes. It does not describe methods - methods have their own docstrings.

from dataclasses import dataclass, field
from datetime import datetime

class OrderProcessor:
"""Manages the lifecycle of customer orders from creation to fulfillment.

This class coordinates between the payment service, inventory system,
and notification service. It maintains an internal event log for audit
purposes and is not thread-safe - use one instance per request context.

Attributes:
order_id: Unique identifier assigned at creation time.
status: Current order status. One of 'pending', 'paid', 'shipped',
'delivered', or 'cancelled'.
created_at: UTC timestamp when the order was created.
events: List of dicts recording each status transition. Each dict
contains 'timestamp', 'from_status', 'to_status', and 'actor'.

Example:
>>> processor = OrderProcessor(order_id="ORD-001")
>>> processor.confirm_payment(card_token="tok_visa")
>>> processor.status
'paid'
"""

def __init__(self, order_id: str):
self.order_id = order_id
self.status = "pending"
self.created_at = datetime.utcnow()
self.events: list[dict] = []

Key conventions:

  • The Attributes: section lists public instance attributes, not all attributes.
  • Private attributes (those prefixed with _) are typically not documented in the class docstring.
  • Methods are documented in their own docstrings, not in the class docstring.
  • Include an Example showing the most common usage pattern.

The __all__ Convention

__all__ is a module-level list that explicitly declares the public API of a module. It affects documentation generation and wildcard imports (from module import *).

# payments.py

__all__ = [
"charge_card",
"refund_charge",
"get_payment",
"PaymentError",
]

# Public functions - included in __all__, appear in generated docs
def charge_card(amount_cents: int, currency: str, card_token: str) -> dict: ...
def refund_charge(charge_id: str, amount_cents: int | None = None) -> dict: ...
def get_payment(payment_id: str) -> dict: ...

# Internal helpers - not in __all__, excluded from public docs
def _build_stripe_params(amount_cents: int, currency: str) -> dict: ...
def _validate_currency(currency: str) -> None: ...

When Sphinx or mkdocstrings processes this module, only the names in __all__ are included in the generated documentation. This is how you control the public surface area without reorganizing the file.

Type Annotations vs Docstrings - Complementary, Not Redundant

Type annotations and docstrings serve different purposes and work together.

Type annotations are for the type checker and tooling. They tell mypy, Pyright, and IDEs what types are expected. They are enforced mechanically.

Docstrings are for human understanding. They explain semantics, constraints, units, and behavior that types cannot express.

# Over-documented: the type annotation makes the "type" in the docstring redundant
def apply_discount(price: float, rate: float) -> float:
"""Apply a discount rate to a price.

Args:
price (float): The original price. (REDUNDANT: annotation says float)
rate (float): The discount rate. (REDUNDANT: annotation says float)

Returns:
float: The discounted price. (REDUNDANT: annotation says float)
"""

# Well-balanced: docstring adds what the type cannot
def apply_discount(price: float, rate: float) -> float:
"""Apply a discount rate to a price.

Args:
price: Original price in dollars. Must be non-negative.
rate: Discount rate as a decimal (0.10 for 10% off). Must be
in the range [0.0, 1.0].

Returns:
Price after discount applied. Never negative.

Example:
>>> apply_discount(100.0, 0.10)
90.0
"""

The rule: do not repeat the type in the docstring when it is already in the annotation. Do use the docstring to explain constraints, units, edge cases, and semantics.

pydoc and help() - Docstrings in the Terminal

Python's built-in pydoc module and the help() function render docstrings directly in the terminal. This is how docstrings are used most frequently during development.

# From the terminal: render docs for any module or function
python -m pydoc payments
python -m pydoc payments.charge_card

# Start a local HTTP documentation server
python -m pydoc -p 8080
# Navigate to http://localhost:8080 in a browser
# From within a Python session
import payments
help(payments.charge_card)

The output of help() is exactly your docstring, formatted with the section structure preserved. This is the most common way engineers discover how to use a function they have not seen before. A well-written docstring means they never have to read the source code.

Sphinx with autodoc - Generating HTML API Documentation

Sphinx is the standard tool for generating professional HTML (and PDF) documentation from Python docstrings. It powers the Python standard library docs, Django, Flask, NumPy, and most major Python projects.

Setup

pip install sphinx sphinx-napoleon

# Initialize a Sphinx project
cd docs/
sphinx-quickstart
# Answer the prompts: project name, author, version

conf.py Configuration

# docs/conf.py

project = "EngineersOfAI"
author = "EngineersOfAI Team"
release = "1.0.0"

extensions = [
"sphinx.ext.autodoc", # Generate docs from docstrings
"sphinx.ext.napoleon", # Support Google and NumPy style
"sphinx.ext.viewcode", # Add [source] links to generated docs
"sphinx.ext.doctest", # Run doctests in documentation
"sphinx.ext.autosummary", # Generate summary tables
]

# Napoleon settings - choose your style
napoleon_google_docstring = True
napoleon_numpy_docstring = False
napoleon_include_init_with_doc = True
napoleon_attr_annotations = True

# Autodoc settings
autodoc_default_options = {
"members": True,
"undoc-members": False, # Exclude functions without docstrings
"show-inheritance": True,
"member-order": "bysource",
}

Documenting a Module in RST

.. docs/api/payments.rst

Payment Processing
==================

.. automodule:: payments
:members:
:undoc-members: False

Charge a Card
-------------

.. autofunction:: payments.charge_card

Refund a Charge
---------------

.. autofunction:: payments.refund_charge

Building the Docs

cd docs/
make html
# Output in docs/_build/html/index.html
open _build/html/index.html

Sphinx with autodoc scans your source files, extracts docstrings, and renders them into cross-referenced HTML with a search index. The napoleon extension handles Google and NumPy style conversion automatically.

mkdocs with mkdocstrings - The Modern Alternative

mkdocs is a simpler, faster alternative to Sphinx. With the mkdocstrings plugin it generates API documentation from docstrings with minimal configuration. It is increasingly the choice for new projects.

Setup

pip install mkdocs mkdocstrings[python] mkdocs-material

mkdocs.yml Configuration

# mkdocs.yml (project root)

site_name: EngineersOfAI Platform
theme:
name: material
features:
- navigation.tabs
- search.suggest

plugins:
- search
- mkdocstrings:
handlers:
python:
options:
docstring_style: google
show_source: true
show_root_heading: true
show_signature_annotations: true
members_order: source

Referencing a Module in Markdown

<!-- docs/api/payments.md -->

# Payment API

::: payments
options:
members:
- charge_card
- refund_charge
- get_payment

The ::: module_name directive tells mkdocstrings to render the docstrings for that module.

Building

mkdocs serve # Live preview at http://127.0.0.1:8000
mkdocs build # Static site in site/ directory

mkdocstrings parses Google and NumPy style natively. The output is clean, responsive, and searchable with minimal setup. For projects that already use mkdocs for their main documentation, this is often the better choice over Sphinx.

Writing Examples in Docstrings - doctest Format

Examples in docstrings serve two purposes: they document usage, and - when written in doctest format - they can be executed as tests.

Doctest Format

def celsius_to_fahrenheit(celsius: float) -> float:
"""Convert a temperature from Celsius to Fahrenheit.

Args:
celsius: Temperature in degrees Celsius.

Returns:
Equivalent temperature in degrees Fahrenheit.

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

The >>> lines are Python expressions. The line immediately after is the expected output. To run all doctests in a file:

# Run doctests in a single module
python -m doctest payments.py -v

# Run doctests via pytest (recommended - integrates with your test suite)
pytest --doctest-modules src/

Pytest's --doctest-modules flag discovers and runs all >>> examples in all docstrings automatically. This gives you tests that are co-located with the code they test and always up to date - if you change the function signature and forget to update the example, the doctest fails.

What Makes a Good Doctest

def parse_duration(duration_str: str) -> int:
"""Parse a human-readable duration string into seconds.

Supports formats: "30s", "5m", "2h", "1d".

Args:
duration_str: Duration string with a unit suffix.
Valid units: 's' (seconds), 'm' (minutes), 'h' (hours), 'd' (days).

Returns:
Total duration in seconds as an integer.

Raises:
ValueError: If the format is unrecognized or the value is negative.

Example:
>>> parse_duration("30s")
30
>>> parse_duration("5m")
300
>>> parse_duration("2h")
7200
>>> parse_duration("1d")
86400
>>> parse_duration("invalid")
Traceback (most recent call last):
...
ValueError: Unrecognized duration format: 'invalid'
"""
MULTIPLIERS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
unit = duration_str[-1]
if unit not in MULTIPLIERS or not duration_str[:-1].isdigit():
raise ValueError(f"Unrecognized duration format: {duration_str!r}")
return int(duration_str[:-1]) * MULTIPLIERS[unit]

Doctest best practices:

  • Show the happy path first, then edge cases
  • Include at least one error case using Traceback (most recent call last): / ... / ExceptionType: message
  • Keep examples minimal - three to five lines is usually enough
  • Avoid examples that depend on external state (database, time, random) - they break non-deterministically

The "Update the Docs When You Update the Code" Discipline

Documentation drift is one of the most corrosive problems in a codebase. A docstring that says a function returns None when it actually raises an exception is worse than no docstring - it actively misleads the reader.

The discipline:

1. Docstrings are part of the function's interface. Changing a function's behavior without updating its docstring is an incomplete change. Code review should catch this.

2. Use doctests to enforce currency. Doctests in Args: examples and Example: sections break when behavior changes. They act as living documentation tests.

3. Add documentation to your definition of done. If a ticket includes implementing a new function or changing an existing one, the documentation update is part of that ticket. Not a follow-up ticket. Not "tech debt."

4. The "20-second rule" for docstrings. A competent engineer unfamiliar with a function should be able to answer four questions in 20 seconds from the docstring alone: What does this function do? What does it need as input? What does it return? What errors can it raise? If the docstring cannot answer these, it is incomplete.

# Incomplete docstring - fails the 20-second rule
def process_transaction(txn_id, opts):
"""Process the transaction.""" # What does "process" mean? What are opts?
...

# Complete docstring - passes the 20-second rule
def process_transaction(txn_id: str, opts: TransactionOptions) -> TransactionResult:
"""Authorize, capture, and record a payment transaction.

Authorizes the charge with the payment provider, captures the funds if
authorization succeeds, and writes an audit record to the database.
The operation is idempotent: calling it twice with the same txn_id
returns the existing result without a double charge.

Args:
txn_id: Unique transaction identifier. Must be a non-empty string.
Used for idempotency - the same txn_id always returns the same result.
opts: Configuration for this transaction, including amount, currency,
payment method, and metadata. See TransactionOptions for fields.

Returns:
TransactionResult with fields: transaction_id (str), status
('authorized' | 'captured' | 'failed'), amount_cents (int),
and timestamp (datetime).

Raises:
PaymentDeclinedError: If the payment provider declines the charge.
InvalidTransactionError: If txn_id is empty or opts is malformed.
NetworkError: If the payment provider is unreachable after retries.

Example:
>>> opts = TransactionOptions(amount_cents=4999, currency="usd",
... card_token="tok_visa")
>>> result = process_transaction("TXN-001", opts)
>>> result.status
'captured'
"""

Interview Questions

Q1: What is the difference between a comment and a docstring in Python?

Answer: A comment (prefixed with #) is a note for whoever is reading the source code. It is not accessible at runtime and does not appear in generated documentation. A docstring is a string literal that appears as the first statement in a module, function, class, or method body. Python assigns it to the __doc__ attribute, making it accessible at runtime via help() and object.__doc__. Docstrings are extracted by documentation generators like Sphinx and mkdocstrings. The practical distinction is audience: comments are for maintainers reading the source; docstrings are for callers who may never read the source.

Q2: What are the three main Python docstring style conventions and when would you choose each?

Answer: Google style uses section headers followed by indented field lists (Args:, Returns:, Raises:). It is the most readable in raw form and is widely used across the Python industry. Choose it for new projects. NumPy style uses section headers with underline dashes and separates type from description in the parameter listing. It is standard in the scientific Python ecosystem (NumPy, SciPy, pandas) and works well for functions with many parameters. Choose it when contributing to or building on those libraries. Sphinx/reST style uses inline field list directives (:param name:, :type name:, :returns:). It is the oldest convention, used by the Python standard library. Choose it only when working in a codebase that already uses it, or when you need compatibility with Sphinx without the napoleon extension.

Q3: How do doctests work and why are they valuable beyond testing?

Answer: A doctest is a Python expression in a docstring prefixed by >>>, followed by the expected output on the next line. Python's doctest module and pytest (with --doctest-modules) execute these expressions and compare the actual output to the expected output, failing if they differ. Their primary documentation value is that they provide concrete, verified usage examples in the same file as the function. A reader who has never seen the function before can look at the Example section and immediately understand how to call it correctly. Their secondary testing value is that they break when the function's behavior changes without a documentation update, preventing documentation drift.

Q4: What is __all__ and how does it affect documentation generation?

Answer: __all__ is a module-level list of strings that explicitly declares the public API of a module. It affects two things: wildcard imports (from module import * only imports names in __all__), and documentation generation. When Sphinx's autodoc or mkdocstrings processes a module, by default they include only names listed in __all__. Internal helper functions (conventionally prefixed with _) that are not in __all__ are excluded from generated docs. This allows you to keep implementation helpers in the same file as public API without cluttering the generated documentation, without needing to move them to a separate private module.

Q5: How do type annotations and docstrings complement each other?

Answer: Type annotations tell the type checker and IDE what types flow into and out of a function. They are mechanically enforced by mypy or Pyright and reduce the need to document types in the docstring. Docstrings communicate semantics that types cannot express: what the value means, its valid range, its units, which edge cases are handled, what the caller should do with the result. The best practice is to use type annotations for all parameters and the return type, then use the docstring to add constraints and context beyond the type - for example, principal: float in the annotation, with "Must be non-negative; represents dollars, not cents" in the docstring. Repeating the type in the docstring when it is already in the annotation is redundant noise.

Q6: What is the difference between Sphinx autodoc and mkdocstrings, and how do you choose?

Answer: Sphinx autodoc is the established, feature-rich option. It generates HTML, PDF, and ePub output; supports cross-references across large multi-module projects; integrates with the python.org documentation ecosystem; and has a large extension ecosystem. Its configuration is complex and its output requires more setup. mkdocstrings is a plugin for mkdocs, a simpler documentation tool. It generates clean, responsive HTML with minimal configuration, supports Google and NumPy style natively, and integrates naturally with Markdown-based documentation. Choose Sphinx for large public-facing API documentation or when PDF output is needed. Choose mkdocstrings for project documentation that combines API reference with markdown-based tutorials and guides, which is the more common pattern in modern Python projects.

Practice Challenges

Beginner - Write a Complete Google Style Docstring

This function has no docstring. Write a complete Google style docstring for it, including Args:, Returns:, Raises:, and an Example: with doctests.

def paginate(items: list, page: int, page_size: int) -> dict:
if page < 1:
raise ValueError("page must be >= 1")
if page_size < 1:
raise ValueError("page_size must be >= 1")
total = len(items)
start = (page - 1) * page_size
end = start + page_size
return {
"items": items[start:end],
"page": page,
"page_size": page_size,
"total": total,
"total_pages": -(-total // page_size), # ceiling division
"has_next": end < total,
"has_prev": page > 1,
}
Solution
def paginate(items: list, page: int, page_size: int) -> dict:
"""Slice a list into a paginated result with navigation metadata.

Returns a dict describing the requested page of items along with
total count and navigation flags. Page numbering is 1-based.

Args:
items: The full list to paginate. May be empty.
page: The 1-based page number to return. Must be >= 1.
page_size: Number of items per page. Must be >= 1.

Returns:
A dict with the following keys:
- items (list): The slice of items for this page. May be empty
if page exceeds the available pages.
- page (int): The requested page number.
- page_size (int): The requested page size.
- total (int): Total number of items across all pages.
- total_pages (int): Number of pages at the given page_size.
Returns 0 when items is empty.
- has_next (bool): True if there is a subsequent page.
- has_prev (bool): True if there is a preceding page.

Raises:
ValueError: If page is less than 1 or page_size is less than 1.

Example:
>>> result = paginate(list(range(10)), page=2, page_size=3)
>>> result["items"]
[3, 4, 5]
>>> result["total_pages"]
4
>>> result["has_next"]
True
>>> result["has_prev"]
True

>>> paginate([], page=1, page_size=10)["total"]
0

>>> paginate([1, 2], page=0, page_size=5)
Traceback (most recent call last):
...
ValueError: page must be >= 1
"""
if page < 1:
raise ValueError("page must be >= 1")
if page_size < 1:
raise ValueError("page_size must be >= 1")
total = len(items)
start = (page - 1) * page_size
end = start + page_size
return {
"items": items[start:end],
"page": page,
"page_size": page_size,
"total": total,
"total_pages": -(-total // page_size),
"has_next": end < total,
"has_prev": page > 1,
}

Intermediate - Set Up mkdocstrings for a Small Module

Given this module, write a mkdocs.yml, a docs/api/utils.md reference page, and verify the setup would correctly generate documentation with mkdocs serve.

# src/utils.py
"""Utility functions for the EngineersOfAI platform."""

__all__ = ["slugify", "truncate", "mask_email"]

def slugify(text: str) -> str:
"""Convert a string to a URL-safe slug."""
...

def truncate(text: str, max_length: int, suffix: str = "...") -> str:
"""Truncate text to max_length characters, appending suffix if truncated."""
...

def mask_email(email: str) -> str:
"""Mask an email address for display, preserving domain."""
...

def _normalize(text: str) -> str: # Internal - not in __all__
...
Solution
# mkdocs.yml
site_name: EngineersOfAI Platform
theme:
name: material

plugins:
- search
- mkdocstrings:
handlers:
python:
paths: [src] # Tell mkdocstrings where to find modules
options:
docstring_style: google
show_source: true
show_root_heading: true
show_signature_annotations: true
members_order: source
filters:
- "!^_" # Exclude private members (leading underscore)
<!-- docs/api/utils.md -->

# Utilities Reference

String manipulation and formatting utilities used throughout the platform.

::: utils
options:
members:
- slugify
- truncate
- mask_email
show_root_heading: true
heading_level: 2
# Verify setup
pip install mkdocs mkdocstrings[python] mkdocs-material
mkdocs serve
# Navigate to http://127.0.0.1:8000/api/utils/

The _normalize function will be excluded by the filters: ["!^_"] rule (matches names starting with _). Only the three names in __all__ - slugify, truncate, mask_email - will appear in the generated documentation.

Advanced - Write a Full Module with Docstrings and Doctests, Then Set Up Sphinx

Write a small rate_limiter.py module with complete Google-style docstrings and working doctests for all public functions. Then write the Sphinx conf.py and api/rate_limiter.rst file that would generate HTML docs for it.

The module should expose:

  • RateLimiter - a simple token bucket rate limiter class
  • is_allowed(key, limit, window_seconds) - a function that checks if a key should be allowed through
Solution
# src/rate_limiter.py
"""Simple in-memory rate limiting utilities.

Provides a RateLimiter class (token bucket algorithm) and a convenience
function for per-key rate limiting. All state is in-memory and is not
shared across processes or threads (not thread-safe).

Typical usage:
from rate_limiter import RateLimiter

limiter = RateLimiter(limit=10, window_seconds=60)

if limiter.is_allowed("user:42"):
process_request()
else:
return too_many_requests_response()
"""

import time
from collections import defaultdict

__all__ = ["RateLimiter", "is_allowed"]


class RateLimiter:
"""In-memory token bucket rate limiter.

Tracks request counts per key within a sliding time window.
Not thread-safe; use one instance per thread or protect with a lock.

Attributes:
limit: Maximum number of requests allowed per window.
window_seconds: Length of the time window in seconds.

Example:
>>> limiter = RateLimiter(limit=3, window_seconds=60)
>>> limiter.is_allowed("user:1")
True
>>> limiter.is_allowed("user:1")
True
>>> limiter.is_allowed("user:1")
True
>>> limiter.is_allowed("user:1")
False
"""

def __init__(self, limit: int, window_seconds: int):
"""Initialize the rate limiter with a request limit and time window.

Args:
limit: Maximum requests allowed per window. Must be >= 1.
window_seconds: Window length in seconds. Must be >= 1.

Raises:
ValueError: If limit or window_seconds is less than 1.
"""
if limit < 1:
raise ValueError(f"limit must be >= 1, got {limit}")
if window_seconds < 1:
raise ValueError(f"window_seconds must be >= 1, got {window_seconds}")
self.limit = limit
self.window_seconds = window_seconds
self._requests: dict[str, list[float]] = defaultdict(list)

def is_allowed(self, key: str) -> bool:
"""Return True if the key is within its rate limit for the current window.

Cleans up expired timestamps for the key before checking, so memory
usage stays bounded to active keys within the current window.

Args:
key: Identifier for the rate-limited entity. Typically a user ID,
IP address, or API key string.

Returns:
True if the request is within the rate limit and should proceed.
False if the key has exceeded its limit within the current window.

Example:
>>> limiter = RateLimiter(limit=2, window_seconds=60)
>>> limiter.is_allowed("alice")
True
>>> limiter.is_allowed("alice")
True
>>> limiter.is_allowed("alice")
False
>>> limiter.is_allowed("bob") # different key, independent limit
True
"""
now = time.monotonic()
cutoff = now - self.window_seconds
# Remove timestamps outside the window
self._requests[key] = [t for t in self._requests[key] if t > cutoff]
if len(self._requests[key]) >= self.limit:
return False
self._requests[key].append(now)
return True

def reset(self, key: str) -> None:
"""Clear all recorded requests for the given key.

Useful in tests or when a user's rate limit should be manually lifted.

Args:
key: The key to reset.

Example:
>>> limiter = RateLimiter(limit=1, window_seconds=60)
>>> limiter.is_allowed("user:5")
True
>>> limiter.is_allowed("user:5")
False
>>> limiter.reset("user:5")
>>> limiter.is_allowed("user:5")
True
"""
self._requests.pop(key, None)


# Module-level default limiter: 60 requests per minute per key
_default_limiter = RateLimiter(limit=60, window_seconds=60)


def is_allowed(key: str, limit: int = 60, window_seconds: int = 60) -> bool:
"""Check whether a key should be allowed through a rate limit.

Uses a module-level RateLimiter instance when called with default arguments.
Creates a fresh per-call limiter when custom limit/window are provided -
note that this means custom parameters do NOT share state across calls.
For stateful custom rate limiting, use the RateLimiter class directly.

Args:
key: Identifier for the rate-limited entity (user ID, IP, etc.).
limit: Maximum requests allowed per window. Defaults to 60.
window_seconds: Length of the time window in seconds. Defaults to 60.

Returns:
True if the request should proceed. False if it exceeds the limit.

Example:
>>> # Using default 60 req/min limit
>>> is_allowed("api:key:abc")
True
"""
if limit == 60 and window_seconds == 60:
return _default_limiter.is_allowed(key)
# Custom parameters: caller should use RateLimiter directly for stateful use
return RateLimiter(limit=limit, window_seconds=window_seconds).is_allowed(key)
# docs/conf.py
project = "EngineersOfAI Platform"
author = "EngineersOfAI Team"
release = "1.0.0"

import sys, os
sys.path.insert(0, os.path.abspath("../src"))

extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
"sphinx.ext.doctest",
]

napoleon_google_docstring = True
napoleon_numpy_docstring = False
napoleon_include_init_with_doc = True

autodoc_default_options = {
"members": True,
"undoc-members": False,
"show-inheritance": True,
}

html_theme = "furo"
.. docs/api/rate_limiter.rst

Rate Limiter
============

.. automodule:: rate_limiter
:members:
:undoc-members: False

.. autoclass:: rate_limiter.RateLimiter
:members:
:special-members: __init__

.. autofunction:: rate_limiter.is_allowed
# Build and verify
pip install sphinx sphinx-napoleon furo
cd docs && make html
open _build/html/api/rate_limiter.html

Quick Reference

StyleParam SyntaxUsed ByBest For
GoogleArgs:\n name: descMost Python projectsNew projects; default choice
NumPyParameters\n----------\nname : type\n descNumPy, SciPy, pandasData science; many parameters
Sphinx/reST:param name: descPython stdlib, legacyLegacy projects; Sphinx without napoleon
Documentation LayerWhen to Use
Good function nameAlways - first line of defense
Type annotationAlways - for all params and return type
Inline commentWhen explaining WHY, not WHAT
DocstringFor any public function, class, or module
__all__To declare the public API of a module
Sphinx / mkdocsFor published API documentation
ToolCommandPurpose
help()help(module.function)Read docstring in REPL
pydocpython -m pydoc module.functionTerminal docs
pytest doctestpytest --doctest-modules src/Run all doctests
Sphinxmake htmlGenerate HTML API docs
mkdocsmkdocs serveLive-preview docs site

Key Takeaways

  • Documentation is a spectrum: prefer better names over comments, prefer comments over docstrings, prefer docstrings over external docs - use the lowest layer that is sufficient.
  • Comments explain WHY. Code explains WHAT. If your code needs a comment to explain what it does, rewrite the code.
  • Google style docstrings are the default for new Python projects: readable in raw form, supported by all major documentation tools.
  • Type annotations and docstrings complement each other - annotations handle types, docstrings handle semantics, constraints, and usage examples.
  • Write doctests in your Example sections: they document usage and break when the function changes, preventing documentation drift.
  • __all__ explicitly declares the public API of a module and controls what appears in generated documentation.
  • Updating documentation when you update code is not optional - a wrong docstring is worse than no docstring.
© 2026 EngineersOfAI. All rights reserved.