Back to Blog
Python

Python Type Hints vs Runtime Types: What Actually Happens

python type hints vs runtime types: Understand the difference between Python type hints and runtime types, how they interact, and when to use static annotations versus...

type hintsruntime typesstatic typingisinstancetyping module
A visual comparison of Python type hints as static annotations versus runtime type checks, showing a function signature with annotations and a runtime isinstance check.

python type hints vs runtime types requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

Python's type hints and runtime types are often confused because they share the word "type." A type hint is an annotation that tells static analysis tools what type a variable should be. The runtime type is what the interpreter actually stores in memory. They are not the same, and knowing the distinction prevents subtle bugs.

Consider this function:

def greet(name: str) -> str: return "Hello, " + name

The annotation name: str and the return type -> str are type hints. If you call greet(42), Python will not raise an error. The function will happily concatenate the integer to a string, producing "Hello, 42". The type hint is ignored at runtime. The actual runtime type of name is int.

This article explains what type hints do and do not do, how to inspect runtime types, and when you should rely on each.

What Type Hints Do and Do Not Do

Type hints were introduced in Python 3.5 through PEP 484. They serve as documentation for humans and as input for static type checkers like mypy, pyright, and pyre. The interpreter itself does not enforce them. When Python executes a function, it does not check whether the arguments match the annotations.

The only runtime effect of a type hint is that the annotation is stored in the function's __annotations__ attribute. You can inspect it, but it does not affect execution.

def add(a: int, b: int) -> int: return a + b print(add.__annotations__) # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

Because type hints are not enforced, they do not protect you from passing incorrect types. They are a contract for static analysis, not a runtime guard. If you need runtime validation, you must write it explicitly.

Runtime Types and How to Inspect Them

The runtime type of a value is the actual class of the object. You can inspect it with the built-in type() function or the __class__ attribute.

value = 42 print(type(value)) # <class 'int'> print(value.__class__) # <class 'int'>

For more precise checks, use isinstance(). It returns True if the object is an instance of the given class or a subclass. It also works with tuples of classes and with abstract base classes from collections.abc.

from collections.abc import Iterable print(isinstance([1, 2], list)) # True print(isinstance([1, 2], Iterable)) # True

isinstance() is the standard way to verify a value's runtime type. It respects inheritance and virtual subclasses, which makes it more flexible than comparing type(value) == SomeClass.

Where Type Hints and Runtime Types Interact

Even though type hints are not enforced, they can be accessed at runtime. The typing module provides get_type_hints() which resolves forward references and evaluates string annotations.

from typing import get_type_hints def process(data: list[int]) -> None: pass print(get_type_hints(process)) # {'data': typing.List[int]}

This can be useful for libraries that generate documentation or perform validation based on annotations. However, the returned objects are often typing constructs, not simple runtime classes. For example, list[int] is a types.GenericAlias, not a real class you can pass to isinstance().

from typing import get_type_hints def f(x: list[int]): pass hints = get_type_hints(f) print(hints['x']) # list[int] # isinstance(value, hints['x']) would raise TypeError

This mismatch is a common source of confusion. Type hints describe a shape that may include generics, unions, and other typing constructs that do not map directly to runtime classes. If you want to validate against a type hint at runtime, you need a library like pydantic or typeguard that understands the typing grammar.

Checking Types at Runtime: isinstance vs Type Hints

When you need to enforce a type at runtime, isinstance() is the direct tool. It checks the actual object against a concrete class or tuple of classes. Type hints cannot be used directly for this purpose because they are not evaluated by the interpreter.

Consider a function that expects a list of integers:

def sum_list(items: list[int]) -> int: return sum(items)

If you call sum_list([1, "2", 3]), the type hint does not prevent the error. The function will attempt to sum a list containing a string and raise a TypeError at runtime. To catch this early, you must add an explicit check:

def sum_list(items: list[int]) -> int: if not all(isinstance(item, int) for item in items): raise TypeError("items must contain only integers") return sum(items)

Runtime checks are necessary when your function receives data from an external boundary: user input, API payloads, configuration files, or database results. Inside a well-typed codebase where all calls come from known functions, type hints plus a static checker are usually sufficient.

The decision between type hints and runtime checks often comes down to trust boundaries. If you trust the caller, use type hints and rely on mypy. If you do not trust the caller, validate with isinstance() or a validation library.

Performance and Overhead

Type hints themselves have negligible runtime cost. They are stored in __annotations__ once when the function is defined, and they do not affect calls. The interpreter does not read them during execution.

Runtime type checks, on the other hand, add real overhead. Every isinstance() call takes time to walk the MRO (method resolution order) and compare classes. In a tight loop, thousands of checks can add up. For example, validating every element in a large list with isinstance(item, int) is O(n) and may be noticeable.

If you need runtime validation for performance-sensitive code, consider validating once at the boundary rather than inside every function. For internal functions, type hints plus static analysis give you safety without runtime cost.

Another overhead comes from libraries that automatically validate type hints at runtime, such as typeguard. These libraries use decorators or wrappers to check arguments and return values. They can slow down function calls significantly because they inspect and evaluate the annotations on every invocation. Use them only where the extra safety justifies the cost.

Choosing Between Static Typing and Runtime Validation

The choice depends on where the data comes from and how strict you need to be.

Use type hints (with a static checker) when:

  • The code is called only from other modules you control.
  • You want to catch type errors during development and CI.
  • You want to improve readability and editor autocompletion.
  • The performance of runtime checks would be unacceptable.

Use runtime validation when:

  • Data enters from external systems: HTTP requests, file input, database queries.
  • The function is part of a public API that other teams or users call.
  • You need to produce clear error messages for invalid input.
  • The types are dynamic and cannot be fully expressed with static hints.

Many projects use both. They annotate internal functions with type hints and run mypy in CI. At the edges, they validate the data with isinstance() or a schema library. This hybrid approach gives you the benefits of static typing without sacrificing runtime safety where it matters.

Common Pitfalls and Compatibility

Several pitfalls arise when mixing type hints and runtime types.

Mutable default arguments are a classic runtime issue, but type hints can mask it. A type hint like def append(item: int, lst: list[int] = []) does not prevent the mutable default from being shared across calls. The runtime behavior remains problematic regardless of annotations.

Forward references appear when a type hint refers to a class defined later. Without from __future__ import annotations, Python evaluates annotations at definition time, which can raise NameError. With the future import, annotations are stored as strings and only evaluated by get_type_hints(). This affects runtime introspection but not static checking.

Optional and Union types are common in type hints. At runtime, Optional[int] is a typing.Union object, not a class. You cannot use it with isinstance(). If you need to check for None or an integer, you must check each separately:

def process(value: int | None) -> None: if value is not None and not isinstance(value, int): raise TypeError("value must be int or None")

The | union syntax works in Python 3.10+ and is also supported by from __future__ import annotations in earlier versions. But at runtime, int | None becomes types.UnionType, which is still not a valid second argument to isinstance().

Generic aliases like list[int] cannot be used with isinstance() either. Trying to do so raises TypeError: isinstance() arg 2 must be a type.... You must check the concrete class and the contents separately.

These limitations are not bugs; they reflect the design that type hints are for static analysis, not runtime behavior. When you need runtime type information, use the built-in type() and isinstance() with concrete classes.

Gradual Typing and Maintaining a Codebase

Python's type system is gradual: you can add type hints to some parts of a codebase and leave others untyped. This allows you to adopt static typing incrementally. The runtime behavior of the code does not change when you add annotations, so you can introduce them without risking production failures.

However, type hints can become stale. If you change a function's implementation but forget to update its annotations, mypy will not catch the mismatch because it only checks against the annotation, not the actual behavior. This is why it is important to run a type checker in CI and to keep annotations accurate.

Runtime validation, on the other hand, always reflects the actual code because it is executed. If you validate with isinstance() and the code changes, the check will fail if the new type is not accepted. This makes runtime checks more reliable for enforcing invariants, but it also means you must update them when requirements change.

A practical approach is to use type hints for the internal API and runtime validation for the external boundary. This keeps the cost low and the safety where it is needed. For example, a web framework endpoint can validate the request body with a schema library, while the internal service functions use type hints and rely on mypy.

Final Code Example: Combining Both Approaches

Here is a small example that uses type hints for documentation and static checking, and isinstance() for runtime validation at the boundary.

from typing import Iterable def process_numbers(numbers: Iterable[int]) -> int: # Runtime validation: ensure each item is an int for n in numbers: if not isinstance(n, int): raise TypeError(f"Expected int, got {type(n).__name__}") return sum(numbers) # External input: user-provided list user_input = [1, 2, "3"] try: total = process_numbers(user_input) except TypeError as e: print(f"Invalid input: {e}")

Inside process_numbers, the type hint Iterable[int] tells mypy what to expect. The isinstance() check catches the string "3" before sum() raises an obscure error. This combination gives you both static safety and clear runtime errors.

When you write a function, ask yourself: will this function be called from code I fully control? If yes, type hints are enough. If the data crosses a trust boundary, add runtime checks. Being clear about that distinction is the core of using Python type hints vs runtime types effectively.

python type hints vs runtime types: Practical Usage and Code | RYUSLOG DEV