Alright, let’s get our hands dirty. You’ve run mypy or pyright and your code is “clean.” You feel good. But then your code actually runs, and some function that expected a list[int] gets handed a single '42' because it came from a JSON API or, heaven forbid, user input. Your beautiful static types, which exist only in the ether of your editor and CI checks, are powerless at runtime. The runtime doesn’t care about your type hints. It’s the wild west.

This is where runtime type validation comes in. It’s the bouncer at the club of your function, checking IDs at the door to make sure no one’s going to cause a scene inside. We’re going to look at two main libraries that act as this bouncer: beartype and typeguard. They have the same goal but very different philosophies on how to achieve it.

The Core Idea: Decorating Your Functions

Both libraries work primarily through decorators. You slap a decorator on a function, and it automatically validates the types of the arguments passed in (and optionally, the return value) against the type hints you’ve already written. No more manual isinstance() checks cluttering up your function logic.

# This is the dream. No manual checks.
def process_data(data: list[dict[str, int]]) -> float:
    # ... your beautiful logic here

Beartype: The Speed Demon

beartype is my personal favorite for most use cases, and here’s why: it’s unbelievably fast. Its creator is a genius who figured out how to generate incredibly efficient validation code on function decoration. Instead of checking every item in a container every time, beartype uses probabilistic checks—it might only check one random item in a large list on the 1000th call. This sounds sketchy, but it’s a calculated risk that keeps performance near-native while still catching the vast majority of type violations almost immediately.

Install it: pip install beartype

from beartype import beartype
from typing import List, Dict

@beartype
def inflict_math(scores: List[int], weightings: Dict[str, float]) -> float:
    total = 0.0
    for score, weight in zip(scores, weightings.values()):
        total += score * weight
    return total / sum(weightings.values())

# This works fine.
result = inflict_math([90, 80, 70], {"final": 0.5, "midterm": 0.3, "homework": 0.2})
print(result)  # Output: 82.0

# This explodes gloriously and immediately at runtime.
# beartype.roar.BeartypeCallHintParamViolation: inflict_math() parameter weightings=...
inflict_math([90, 80, 70], {"final": 0.5, "midterm": "oops, a string!"})

The error message is fantastic—it tells you exactly which parameter violated which hint. The trade-off? Its deep checking of container types isn’t 100% exhaustive on every call, but for the performance gain, it’s a trade I’m willing to make in all but the most mission-critical, must-catch-every-single-edge-case scenarios.

Typeguard: The Meticulous Inspector

typeguard takes the more traditional, thorough approach. It checks everything, every time. It’s the bouncer who pats you down, checks every pocket, and makes you empty your boots. This makes it slower than beartype for complex types, but you are guaranteed that if your data is wrong, you’ll know.

You can use it as a decorator, but its real power is as an import hook or via pytest integration. The import hook is a killer feature: it automatically applies validation to all functions in your project without you having to add a single decorator.

Install it: pip install typeguard

from typeguard import typechecked
from typing import List

@typechecked
def get_first_item(container: List[str]) -> str:
    return container[0]

# This will fail at runtime.
get_first_item([1, 2, 3])  # TypeError: type of argument "container" must be list[str]; got list[int] instead

To use the import hook, you run your script with a special flag: python -m typeguard your_script.py

This is fantastic for testing and development, as it effectively turns your static type hints into runtime assertions across your entire codebase.

Best Practices and Pitfalls

  1. Don’t Use Them Everywhere: This is the biggest mistake. You’ll murder your performance. Use them at the boundaries of your system: when receiving data from an HTTP request, a queue, a file, or a user. Once the data is validated at the border, you can trust it inside your application’s core logic.

  2. They’re Not a Substitute for Static Analysis: Keep running mypy/pyright! Runtime checking is your last line of defense, not your first. Static analysis catches logical errors and API misuse before you run the code. Runtime checking catches problems that static analysis can’t possibly see because the data doesn’t exist yet.

  3. Be Wary of Any and Complex Types: Both libraries will mostly shrug at Any type hints because, well, anything goes. They also work with most standard types from typing, but extremely complex or custom types might cause issues or performance problems. Test them.

  4. The Union Problem: Checking for Union[str, int] is inherently slower because the validator has to check for multiple types. It’s a necessary evil, but be aware of the cost if you use it in a performance-critical path.

So, which one should you use? Start with beartype for its blistering speed and great error messages. If you find a case where you absolutely need 100% deterministic, exhaustive checking on every single item (e.g., validating configuration files on boot), or if you want the magic of the import hook for a project-wide safety net during development, then reach for typeguard. Either way, you’re making your runtime errors infinitely more debuggable.