Skip to main content

Python Type Hints: Beginner's Guide

Python type hints are optional annotations that declare the expected types of function parameters, return values, and variables. Added in Python 3.5 via PEP 484, type hints let you document what types your code accepts and returns without changing how Python executes your program. They serve as machine-readable documentation that tools like mypy can verify, catching type errors before runtime.

In this article, you'll learn the syntax for basic type hints, understand why they matter, and write your first type-annotated functions. By the end, you'll be able to read and write type hints confidently, even if you've never seen them before.

What Are Type Hints and Why Do They Matter?

Type hints are optional annotations added to function signatures and variable declarations. Python still executes the same way—it does not enforce types at runtime by default. Instead, type hints serve two purposes: they document your intent (helping readers understand what types are expected), and they enable static type checkers like mypy to scan your code for inconsistencies without running it.

Here's a simple example without type hints:

def add(a, b):
return a + b

result = add(5, 3) # Works: result is 8
weird = add("hello", "world") # Also works: result is "helloworld"
broken = add(5, "hello") # Runs but may cause surprising behavior downstream

Without type hints, Python doesn't know whether add should accept only numbers, strings, or anything. Now with type hints:

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

result = add(5, 3) # Type-safe: mypy confirms both args are int, result is int
weird = add("hello", "world") # mypy reports an error: str is not compatible with int
broken = add(5, "hello") # mypy reports an error: can't add int and str

A static type checker spots these issues before you run the code, preventing entire categories of runtime bugs.

Basic Syntax: Annotating Function Parameters and Return Types

Function type hints use the colon syntax for parameters and -> for return types:

def greet(name: str) -> str:
return f"Hello, {name}!"

message: str = greet("Alice")
print(message) # Output: Hello, Alice!

Break down the syntax:

  • name: str — the parameter name must be a string
  • -> str — the function returns a string

For multiple parameters:

def multiply(x: int, y: int) -> int:
return x * y

result: int = multiply(4, 5)
print(result) # Output: 20

You can also annotate variables directly. This is useful for clarity or when the type is not obvious from assignment:

count: int = 0
message: str = "Processing..."
is_valid: bool = True

Using Built-in Types and Typing Module Imports

For simple types like int, str, float, bool, and bytes, use them directly in annotations. For more complex types (lists, dictionaries, optional values), import from the typing module:

from typing import List, Dict, Optional

# List of integers
numbers: List[int] = [1, 2, 3]

# Dictionary with string keys and integer values
scores: Dict[str, int] = {"Alice": 95, "Bob": 87}

# Optional integer (int or None)
age: Optional[int] = None

The Optional[int] means "either an int or None"—a common pattern for values that might not be present. In Python 3.10+, you can use the newer union syntax with |:

from typing import List, Dict

# Modern union syntax (Python 3.10+)
age: int | None = None

# List and Dict work the same way
names: List[str] = ["Alice", "Bob"]
config: Dict[str, int] = {"timeout": 30}

Practical Example: A Simple Calculator with Type Hints

Let's write a small calculator module using type hints throughout:

from typing import Union

def divide(numerator: Union[int, float], denominator: Union[int, float]) -> float:
"""
Divide two numbers and return the result as a float.

Args:
numerator: The dividend (int or float).
denominator: The divisor (int or float, must not be zero).

Returns:
The quotient as a float.

Raises:
ValueError: If denominator is zero.
"""
if denominator == 0:
raise ValueError("Cannot divide by zero")
return numerator / denominator

# Type-safe calls
result1: float = divide(10, 2) # result1 = 5.0
result2: float = divide(7.5, 2.5) # result2 = 3.0

# mypy would catch this error:
# bad_result: int = divide(10, 2) # Error: float assigned to int

This function accepts either an int or float (via Union[int, float]), and always returns a float. The docstring explains what each parameter does, and type hints reinforce the contract.

Annotating Collections and Complex Objects

Type hints become more valuable when you document what's inside collections:

from typing import List, Dict, Tuple

def process_students(names: List[str], scores: Dict[str, int]) -> Tuple[str, float]:
"""Return the best student name and average score."""
if not scores:
raise ValueError("No scores provided")

best_name = max(scores, key=scores.get)
avg_score = sum(scores.values()) / len(scores)
return best_name, avg_score

students: List[str] = ["Alice", "Bob", "Charlie"]
student_scores: Dict[str, int] = {"Alice": 95, "Bob": 87, "Charlie": 92}

top_student, average = process_students(students, student_scores)
print(f"{top_student} achieved {average:.1f} average")
# Output: Alice achieved 91.3 average

Here:

  • List[str] means a list of strings
  • Dict[str, int] means a dict with string keys and integer values
  • Tuple[str, float] means a tuple of exactly (string, float)

These hints tell both humans and type checkers what you expect inside each container.

Common Pitfalls and Best Practices

Pitfall 1: Forgetting to import from typing Using List or Dict without importing them causes a runtime error in Python < 3.9:

# Wrong (Python < 3.9):
def get_items() -> List[str]: # NameError: List is not defined
return []

# Correct:
from typing import List
def get_items() -> List[str]:
return []

# Also correct (Python 3.9+):
def get_items() -> list[str]: # Lowercase list works directly
return []

Pitfall 2: Using Any as a crutch The Any type (from typing) bypasses type checking. Avoid it unless truly necessary:

from typing import Any

# Avoid:
def process(data: Any) -> Any: # Tells mypy nothing
return data

# Better:
def process(data: Dict[str, int]) -> int: # Clear intent
return sum(data.values())

Best Practice 1: Annotate function signatures, not every variable Type hints are most valuable on function boundaries. Inside functions, Python's type inference and mypy's flow analysis often eliminate the need for every variable annotation:

def calculate_tax(price: float, rate: float) -> float:
# No need to annotate 'tax'—mypy infers it from the calculation
tax = price * rate
return tax

Best Practice 2: Use docstrings alongside type hints Type hints document what types flow through your code; docstrings explain why:

def fetch_user(user_id: int) -> Dict[str, str]:
"""
Fetch user details from the database.

Args:
user_id: The unique identifier of the user.

Returns:
A dictionary with 'name', 'email', and 'role' keys.
"""
# Implementation here
pass

Key Takeaways

  • Type hints are optional annotations that declare expected types for function parameters, returns, and variables without changing Python's runtime behavior
  • Use the colon (:) syntax for parameters and -> for return types: def func(x: int) -> str:
  • Import complex types from the typing module: List[int], Dict[str, int], Optional[str], Union[int, float]
  • Type hints enable static type checkers like mypy to catch errors before runtime
  • Annotate function signatures (especially public APIs) and use docstrings to explain intent
  • Avoid overusing Any and remember that type hints are optional—use them strategically for clarity

Frequently Asked Questions

Do type hints slow down my Python code?

No. Type hints are read by static type checkers and development tools; Python ignores them at runtime. There is zero performance cost.

What if I have a function that can accept multiple types?

Use Union[Type1, Type2] from the typing module, or in Python 3.10+, the pipe syntax: Type1 | Type2. For maximum flexibility without losing type safety, consider Protocol (covered in later articles).

Do I need type hints for every function?

No. Type hints are most valuable on public APIs and function boundaries. Internal helper functions with obvious types are less critical, though adding hints improves documentation and catches errors.

Can I use type hints with existing untyped code?

Yes. Type hints are optional and backward-compatible. You can add them gradually to an existing codebase. Start with public APIs and work inward.

What's the difference between List[int] and list[int]?

In Python 3.9+, you can use lowercase list[int] directly without importing from typing. In Python 3.8 and earlier, you must use List[int] from typing. Both are equivalent in modern Python.

Further Reading