DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
Getting Started
01
What is Python and Why Learn It in 2026
02
Installing Python on Windows, Mac, and Linux
03
Setting up VS Code / PyCharm for Python
04
Understanding the Python Interpreter and REPL
05
Writing and Running Your First Python Program
06
Python Syntax and Indentation Rules
Variables and Data Types
01
Variables and Naming Conventions in Python
02
Numbers in Python (int, float, complex)
03
Strings and String Methods in Python
04
Booleans in Python
05
Type Conversion / Casting in Python
06
Understanding the None Type in Python
Operators
01
Arithmetic Operators in Python
02
Comparison Operators in Python
03
Logical Operators in Python
04
Assignment Operators in Python
05
Bitwise Operators in Python
06
Identity (is) and Membership (in) Operators
07
Walrus Operator (:=) in Python
Input, Output & Strings
01
input() and print() Functions in Python
02
f-strings and String Formatting in Python
03
String Slicing and Indexing in Python
04
Common String Methods (split, join, strip, replace)
05
Escape Characters in Python Strings
Control Flow
01
if, elif, else Statements in Python
02
Nested Conditionals in Python
03
for Loops in Python
04
while Loops in Python
05
break, continue, and pass Statements
06
Loop with else Clause in Python
07
match-case (Structural Pattern Matching)
Data Structures
01
Python Lists: Creation, Indexing, Slicing, Methods
02
Python Tuples and Immutability
03
Python Sets and Set Operations
04
Python Dictionaries: Keys, Values, and Methods
05
List Comprehensions in Python
06
Dictionary & Set Comprehensions in Python
07
Working with Nested Lists and Dictionaries
Functions
01
Defining and Calling Functions in Python
02
Function Arguments: Positional, Keyword, Default
03
*args and **kwargs in Python
04
Return Statements in Python Functions
05
Lambda Functions in Python
06
Recursion in Python
07
Variable Scope: Local, Global, Nonlocal
08
Docstrings and Function Annotations
Functional Programming Basics
01
map(), filter(), and reduce() in Python
02
Closures in Python Explained
03
Python Decorators: Basic to Advanced
04
Generators and yield in Python
Object-Oriented Programming (OOP)
01
Classes and Objects in Python
02
The init Constructor in Python
03
Instance vs Class Variables in Python
04
Inheritance in Python (Single, Multiple, Multilevel)
05
Polymorphism in Python
06
Encapsulation in Python
07
Abstraction in Python with the abc Module
08
Magic/Dunder Methods in Python
09
Static and Class Methods in Python
10
The @property Decorator in Python
11
Python Dataclasses Explained
Error Handling
01
try, except, finally in Python
02
Handling Multiple Exceptions in Python
03
Raising Custom Exceptions in Python
04
Understanding Python's Exception Hierarchy
05
Assertions in Python
File Handling
01
Reading and Writing Text Files in Python
02
Working with the with Statement in Python
03
Working with CSV Files in Python
04
Working with JSON Files in Python
05
Working with Directories (os, pathlib) in Python
Modules and Packages
01
Importing Built-in Modules in Python
02
Creating Your Own Modules in Python
03
Understanding Packages and init.py
04
Python Standard Library Overview
05
Installing Packages with pip
Advanced Core Concepts
01
Iterators and Iterables in Python
02
Context Managers in Python (with, contextlib)
03
Multithreading Basics in Python
04
Multiprocessing Basics in Python
05
Asyncio Basics: async/await in Python
06
Memory Management and Garbage Collection in Python
07
Type Hints in Python (typing module)

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.

PREVIOUSNEXT LESSON