You write def greet(name: str) -> str:, pass an integer, and Python runs it without complaint. No error. No warning. Nothing. This is not a bug — it is a deliberate design decision baked into the language since PEP 484 introduced type hints in Python 3.5. Understanding exactly why the interpreter behaves this way will make you a sharper Python developer and help you use the type system the way it was intended.
The confusion around type hints almost always comes from comparing Python to statically typed languages like Java, C++, or Rust, where type declarations directly control what the compiler allows. Python's relationship with types works differently, and that difference is entirely intentional.
Type Hints Are Metadata, Not Constraints
In Python, type hints are annotations — a form of metadata attached to variables, function parameters, and return values. The Python interpreter reads them, stores them, and then moves on. It does not use them to gate whether a function call is allowed or to raise an error when a mismatched type arrives.
Consider this straightforward example:
def add_numbers(a: int, b: int) -> int:
return a + b
# Type hint says int, but we pass strings
result = add_numbers("hello", " world")
print(result) # Output: hello world
print(type(result)) # Output: <class 'str'>
The annotation a: int tells any developer — and any static analysis tool — that a is expected to be an integer. The interpreter, however, treats the annotation as documentation. It does not verify that "hello" matches int. The function runs, string concatenation happens via the + operator, and Python returns the result without any type-related error.
The Python documentation for the typing module states this plainly: "The Python runtime does not enforce function and variable type annotations. They can be used by third party tools such as type checkers, IDEs, linters, etc." This is not a limitation waiting to be fixed — it is the stated design.
What PEP 484 Actually Says
PEP 484, published in 2014 and accepted for Python 3.5, is the foundational document for Python's type hint system. Its stated primary goal is to enable static analysis — not runtime enforcement. The PEP explicitly makes this priority clear: of all the goals behind the typing system, static analysis is the most important.
The PEP also explicitly states that Python will remain a dynamically typed language, and that the authors have no desire to ever make type hints mandatory, even by convention. This is a philosophical commitment, not just a technical limitation.
"It should also be emphasized that Python will remain a dynamically typed language, and the authors have no desire to ever make type hints mandatory, even by convention." — PEP 484
The PEP does acknowledge that runtime type checking is possible and that third-party tools could build on top of the annotation system to achieve it. But the interpreter itself was never going to take on that responsibility. The design deliberately separates the concerns: Python provides the syntax and the storage mechanism for annotations; external tools decide what to do with them.
This also reflects a broader Python philosophy. Python grew popular in large part because of its flexibility and low friction for beginners. Mandatory type enforcement at runtime would break enormous amounts of existing code and would conflict with duck typing — a core Python idiom where what matters is whether an object supports the required operations, not what class it belongs to.
How Annotations Are Stored at Runtime
Although the interpreter does not enforce annotations, it does process them. When Python loads a function definition, it evaluates any annotation expressions and stores the results in a special dictionary called __annotations__. You can inspect this dictionary directly:
def greet(name: str, age: int) -> str:
return f"Hello {name}, age {age}"
print(greet.__annotations__)
# Output: {'name': <class 'str'>, 'age': <class 'int'>, 'return': <class 'str'>}
The annotations are right there, accessible as a plain Python dictionary. Static type checkers like mypy read this dictionary (and equivalent stub files) to perform their analysis. Libraries that want to add runtime enforcement also read from __annotations__ — they simply go further and actually compare the runtime types of passed arguments against what the annotations say.
It is worth knowing that Python 3.14 introduced lazy evaluation of annotations. Before 3.14, annotation expressions were evaluated at module import time, which could cause issues with forward references and added a small but real startup cost. With lazy evaluation, annotations are only resolved when explicitly requested, making the runtime footprint of type hints even lighter.
Use typing.get_type_hints(func) rather than accessing func.__annotations__ directly when writing tools that process annotations programmatically. The get_type_hints() function handles string annotations and forward references correctly, whereas raw __annotations__ access can behave unpredictably in certain contexts, especially before Python 3.10.
Static Checkers vs. Runtime Behavior
The practical consequence of this design is that type safety in Python lives in two entirely separate layers: static analysis before your code runs, and optional runtime enforcement if you choose to add it.
| Layer | Tool / Mechanism | When It Runs | Raises Error? |
|---|---|---|---|
| Static analysis | mypy, Pyright, Pylance | Before execution (in CI, in IDE) | Reports errors; does not raise exceptions |
| Runtime enforcement | Beartype, Typeguard | During execution, on each function call | Raises TypeError or library-specific exception |
| Interpreter default | CPython / PyPy / etc. | During execution | Never raises for type hint violations |
| IDE integration | VS Code, PyCharm, etc. | As you type | Shows inline warnings; does not raise exceptions |
Static checkers like mypy analyze your source code the same way a compiler would in a statically typed language. They read your annotations, trace data flow through your program, and report any places where a type is used inconsistently with what was declared. Critically, mypy never runs your program. It simply reads the text and reasons about types. This means it can catch a bug in code that might not even be reached during a given test run.
# mypy will flag this before the code runs
def multiply(x: int, y: int) -> int:
return x * y
result = multiply(4, "oops")
# mypy output: error: Argument 2 to "multiply" has
# incompatible type "str"; expected "int"
Python will still execute this code without raising any exception. Mypy reports the problem — at the development stage, before any user ever runs the program.
How to Enforce Type Hints at Runtime
There are situations where runtime enforcement makes sense: API boundaries where data arrives from untrusted external sources, validation-heavy data pipelines, or codebases where catching type errors immediately at the call site is preferable to discovering them through downstream behavior. Two libraries are widely used for this purpose.
-
1
Install a runtime enforcement library
Beartype is the recommended choice for most use cases. It is designed for minimal performance overhead and works with standard PEP 484 type hints without any changes to your annotation syntax.
-
2
Decorate functions with
@beartypeImport and apply the decorator to any function whose annotations you want enforced. Beartype wraps the function and checks the types of arguments and return values on every call, raising
BeartypeCallHintParamViolationwhen a violation occurs. -
3
Run mypy as a pre-execution check
Runtime enforcement and static checking are complementary, not alternatives. Running
mypy your_module.pybefore deployment catches violations before the code ever executes, which is always cheaper than catching them at runtime. -
4
Use Typeguard's import hook for broader coverage
If you want to enforce types across an entire module or package without decorating every function individually, Typeguard provides an import hook via
typeguard.install_import_hook("your_package")that instruments code automatically at import time.
# pip install beartype
from beartype import beartype
@beartype
def add(a: int, b: int) -> int:
return a + b
add(2, 3) # Works fine — returns 5
add("two", 3) # Raises BeartypeCallHintParamViolation at runtime
# pip install typeguard
from typeguard import install_import_hook
# Instruments the entire package at import time
install_import_hook("my_package")
import my_package # All annotated functions now enforce types at runtime
Runtime type checking adds overhead to every instrumented function call. Beartype is designed to minimize this, but any runtime enforcement library has a cost. For performance-sensitive hot paths in production code, consider running runtime checks only in development or testing environments, and relying on mypy for production-time guarantees.
Frequently Asked Questions
No. The Python interpreter ignores type hints entirely at runtime. They are stored as metadata in the __annotations__ attribute of a function or module but are never evaluated for correctness during execution. A function annotated to accept an int will run without error even if you pass a string.
PEP 484 introduced type hints primarily to support static analysis tools like mypy and IDE features like autocompletion and inline error detection. The Python authors explicitly stated that Python will remain a dynamically typed language and that enforcing hints at runtime was never the goal. The hints serve as documentation and as input for external tools, not as interpreter-level constraints.
__annotations__ is a dictionary automatically created by the Python interpreter on functions and modules when type hints are present. It stores the annotation expressions as values keyed by parameter name. The interpreter populates this dictionary but does not act on it — reading, validating, or enforcing the annotated types is left entirely to external tools.
Yes, but you need a third-party library. Beartype and Typeguard are the two libraries used most often. Beartype uses a decorator approach and is designed to have minimal performance overhead. Typeguard can be applied per-function or imported as an import hook that instruments an entire module automatically. Neither approach is built into the interpreter itself.
In standard Python, type hints add a very small overhead at module import time because the interpreter evaluates the annotation expressions and stores them in __annotations__. In Python 3.14 and later, annotations are evaluated lazily, meaning they incur no cost unless explicitly requested. Using runtime enforcement libraries like Beartype or Typeguard does add measurable overhead because those libraries are performing actual type checks on every call.
Mypy is a static analysis tool that checks your code before it runs, the same way a compiler would in a statically typed language. It never executes your program. Beartype is a runtime library that wraps your functions in a decorator and raises exceptions when a type violation occurs during an actual function call. They serve different purposes and can be used together.
Key Takeaways
- Type hints are metadata by design. PEP 484 explicitly scoped type hints as documentation and static analysis aids, not interpreter constraints. This decision preserves Python's dynamic typing philosophy and backward compatibility.
- Annotations are stored, not enforced. The interpreter saves your annotations to
__annotations__and stops there. It does not compare argument types against those annotations during a function call. - Static checkers catch errors before execution. Tools like mypy and Pyright analyze your annotated code the way a compiler would, reporting type violations without running your program. Integrating a static checker into your workflow gives you the safety benefits of typed languages without giving up Python's runtime flexibility.
- Runtime enforcement requires a third-party library. Beartype and Typeguard both provide decorator-based or import-hook-based mechanisms to raise exceptions when type violations occur at runtime. Both have a performance cost that should be weighed against the need.
- The two approaches are complementary. Running mypy before deployment and using Beartype in development or on API boundaries together gives you layered type safety without making Python feel like a statically typed language.
Python's decision to keep type hints advisory rather than mandatory is one of the more carefully considered design choices in the language's history. It gives teams the freedom to adopt typing gradually — annotating critical code paths first, then expanding coverage over time — without forcing a big-bang rewrite. Used alongside a static checker and a disciplined development workflow, type hints are a powerful tool even without any runtime enforcement at all.