Docstrings and Function Annotations

Good Python code doesn't just work — it explains itself. python docstrings and python function annotations (type hints) are the two main tools for that, and they complement each other rather than compete: one explains intent for humans, the other declares expected types for both readers and tooling. This article covers both, and how to use them together effectively.

Introduction: Two Complementary Ways to Document Code

Here's the clearest way to frame the distinction: docstrings explain the why and how, in plain language, for humans reading your code. Type hints specify the what — the expected types of parameters and return values — primarily for tools (and secondarily, for readers scanning a function's signature quickly).

Why both matter

As a project grows, or gains collaborators, the cost of unclear code compounds. A function without documentation or type information forces every future reader — including you, months later — to read the entire implementation just to understand how to call it safely. Docstrings and type hints both reduce that cost, in different ways: one through explanation, the other through explicit, checkable declaration.

A crucial caveat: annotations aren't enforced

Python remains dynamically typed, full stop — adding a type annotation doesn't change that at all. Annotations are hints, purely informational at runtime; the Python interpreter itself never checks or enforces them:

def add(a: int, b: int) -> int:
    return a + b

print(add("hello", "world"))   # "helloworld" — runs fine, no error, despite the type hints!

Nothing about writing a: int actually stops you from passing a string. Enforcement (if you want it) comes from external static type checkers, covered in Section 4 — not from Python itself.

Writing Docstrings

What a docstring is

A docstring is a string literal placed as the very first statement inside a function, module, or class — and unlike a regular comment, it's actually accessible at runtime, through the .__doc__ attribute or the built-in help() function.

def greet(name):
    """Return a friendly greeting for the given name."""
    return f"Hello, {name}!"

print(greet.__doc__)   # Return a friendly greeting for the given name.
help(greet)             # displays the same docstring, formatted nicely
One-line vs. multi-line docstrings

For simple, self-explanatory functions, a single-line docstring is often enough:

def square(x):
    """Return the square of x."""
    return x ** 2

For more complex functions, a multi-line docstring with a standard structure gives readers considerably more useful detail.

A standard structure: Google-style docstrings

One widely used convention (among several — NumPy-style and reStructuredText are other common options) is the Google style, which breaks a docstring into clearly labeled sections:

def calculate_discount(price, discount_percent):
    """Calculate the final price after applying a discount.

    Args:
        price: The original price before discount.
        discount_percent: The discount percentage to apply (0-100).

    Returns:
        The final price after the discount is applied.

    Raises:
        ValueError: If discount_percent is outside the 0-100 range.
    """
    if not (0 <= discount_percent <= 100):
        raise ValueError("discount_percent must be between 0 and 100")
    return price * (1 - discount_percent / 100)

A short summary line up top, followed by Args:, Returns:, and Raises: sections as needed — this structure is instantly recognizable to anyone familiar with Python conventions, and it's also what many documentation-generation tools (covered in Section 5) expect and parse automatically.

Docstrings vs. comments

This distinction is worth being explicit about: a docstring isn't just a comment that happens to sit at the top of a function. Comments generally explain implementation details — why a particular line of code does something a certain way. Docstrings explain intent and usage — what the function does, what it expects, and what it hands back — from the perspective of someone who's about to call the function, not someone reading through its internals.

Function Annotations (Type Hints) Basics

Basic syntax

Annotations go directly in the function signature: param: type for each parameter, and -> type for the return value.

def add(a: int, b: int) -> int:
    return a + b
A fully annotated example
def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()

print(greet("Alex", 2))   # Hello, Alex! Hello, Alex!

Here, name is expected to be a str, times is expected to be an int (with a default of 1), and the function is expected to return a str.

Where this syntax came from

Function annotation syntax itself was introduced via PEP 3107, which added the ability to attach arbitrary metadata to function parameters and return values without dictating what that metadata should mean. PEP 484 later formalized a specific, widely adopted convention for using that syntax for type information specifically — which is what essentially everyone means today when they say "type hints" in Python.

Modern Type Hint Syntax and the typing Module

Built-in generics
Since Python 3.9, you can use built-in collection types directly as generics — list[int], dict[str, float] — without importing anything extra from the typing module:
def get_average(numbers: list[float]) -> float:
    return sum(numbers) / len(numbers)

def count_words(text: str) -> dict[str, int]:
    counts = {}
    for word in text.split():
        counts[word] = counts.get(word, 0) + 1
    return counts

Before Python 3.9, this required importing List and Dict from typing (List[float], Dict[str, int]) — you may still see that older style in codebases that need to support earlier Python versions, but the built-in lowercase form is now the preferred, modern approach.

Optional and Union types

For a value that might legitimately be None, Python 3.10 introduced the | union syntax directly in annotations, as a cleaner alternative to the older Optional from typing:

# Modern syntax (Python 3.10+)
def find_user(user_id: int) -> dict | None:
    ...

# Older, still-valid equivalent
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
    ...

Both mean exactly the same thing: this function returns either a dict, or None. The | syntax extends to combining any set of possible types, not just with None — this is what Union from typing was historically used for:

# Modern syntax
def process(value: int | str) -> str:
    ...

# Older, still-valid equivalent
from typing import Union
def process(value: Union[int, str]) -> str:
    ...

If you're working in a codebase that needs to support Python versions older than 3.10, the typing module imports remain the correct, compatible choice — otherwise, the built-in | syntax is generally considered cleaner and is now the recommended default.

Static type checkers

Here's the payoff that makes type hints genuinely valuable beyond documentation: static type checkers like Mypy read your annotations and analyze your code without running it, flagging places where your actual usage doesn't match your declared types:

def add(a: int, b: int) -> int:
    return a + b

add("hello", "world")   # Mypy would flag this as a type error, even though Python itself won't

Running mypy your_script.py against code like this would report a type mismatch — catching a class of bug before you ever run the program, purely by reading the declared types. This is worth restating clearly: type hints have zero effect on program execution on their own — Python's interpreter genuinely ignores them at runtime, as shown in Section 1. Their entire value comes from either human readability or from an external tool like Mypy actually reading and checking them.

Combining Docstrings and Type Hints Effectively

Letting each pull its own weight

Once you're using both together, there's no need for the docstring to repeat type information the annotations already declare. Type hints handle what type something is; the docstring should focus on why and how — the meaning and purpose behind each parameter, expected value ranges, and any behavior that isn't obvious just from the signature.

A full worked example
def calculate_discount(price: float, discount_percent: float) -> float:
    """Calculate the final price after applying a percentage discount.

    Args:
        price: The original price before any discount is applied.
        discount_percent: The discount to apply, expressed as a
            percentage between 0 and 100.

    Returns:
        The final price after the discount has been subtracted.

    Raises:
        ValueError: If discount_percent falls outside the 0-100 range.
    """
    if not (0 <= discount_percent <= 100):
        raise ValueError("discount_percent must be between 0 and 100")
    return price * (1 - discount_percent / 100)

Notice how the docstring doesn't say "price: a float" — the annotation already communicates that clearly and unambiguously. Instead, the docstring focuses on what these values actually mean in context, and calls out the one piece of behavior — the exception — that isn't visible from the signature at all.

The tooling payoff

This combination — annotations plus a well-structured docstring — pays off concretely in day-to-day development, not just in theory:

  • Autocomplete — modern editors (VS Code, PyCharm) use type hints to offer far more accurate autocomplete suggestions as you're calling a function, since they know exactly what type each argument expects.

  • Inline documentation popups — hovering over a function call in most editors shows both the signature (from type hints) and the docstring together, without needing to jump to the function's definition.

  • Automated documentation generation — tools like Sphinx can parse docstrings (particularly ones following a recognized format, like the Google style shown above) directly into polished, browsable documentation websites, with essentially no additional manual writing required beyond the docstrings you'd want to have anyway.

Together, these two tools turn a function signature from a bare, ambiguous set of parameter names into something genuinely self-documenting — readable by humans, checkable by tools, and immediately useful the moment someone else (or future you) needs to actually use it.