# typeguard: three ways to check Python types at run time, and what each one costs you

> typeguard adds run-time type checking on top of a static checker, through check_type, explicit argument and return calls, or source instrumentation. The three routes have very different limits, and the instrumentation route needs the original source at import time.

**agronholm/typeguard** — Run-time type checker for Python

- Repository: https://github.com/agronholm/typeguard
- Stars: 1,793 · Forks: 154
- Language: Python
- License: NOASSERTION
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/agronholm-typeguard

## The gap typeguard fills is between the annotation and the value

Python annotations are not enforced. Nothing stops a function annotated as taking a list from being handed a string at three in the morning, and nothing stops it from failing somewhere far from the call site. typeguard exists to close that gap: it performs run-time type checking for functions written with PEP 484 argument and return annotations, and for arbitrary objects as well.

The intended pairing is with a static type checker rather than instead of one. Static analysis finds the mismatches it can prove from the source; run-time checking finds the ones that depend on data that only exists once the program runs, such as a JSON payload from an external service. The repository layout shows a project built with that audience in mind: `src/`, `tests/`, a `docs/` directory, a `.pre-commit-config.yaml`, and a `pyproject.toml` whose test dependency group lists `mypy >= 1.2.0`.

It ships a pytest plugin too, registered through an entry point:

```toml
[project.entry-points]
pytest11 = {typeguard = "typeguard._pytest_plugin"}
```

That means an installed typeguard makes its checks available to pytest without any conftest change, which is the cheapest way to see whether your annotations describe reality before committing to a runtime policy.

## check_type is the cheap path, and it behaves like a checking cast()

The first of the three principal approaches is the `check_type` function. It works like `isinstance()` but accepts arbitrary type annotations rather than concrete classes, and it can stand in for `cast()` while actually verifying the value instead of only silencing a checker.

This is the route to reach for at a boundary: validate a value once, then use the checked result. It has no source requirements, no import hooks, and no recompilation, so it costs nothing at import time and stays out of the debugger's way. The README's summary of this route mentions nothing about instrumentation, and the project documents it as the lightest of the three.

One caveat runs through the whole library rather than this function alone. The annotations that typeguard evaluates are whatever your annotation expression evaluates to at run time. If a hint is built dynamically inside a nested function, the evaluation happens where that function runs, and the README is explicit that the `check_argument_types()` and `check_return_type()` route does not work reliably with dynamically defined type hints, for example in nested functions. Same class of problem, different surface.

## The import hook rewrites your modules and needs their source

The third approach is code instrumentation, and it is where the real limits live. Entire modules, or individual functions marked with `@typechecked`, are recompiled with checking code injected into them. The injected checks cover function arguments, return values, and assignments to annotated local variables. For generator functions, both regular and async, the injected checks cover yield and send values.

There are two ways to get instrumentation. The `@typechecked` decorator is applied by hand to the functions you want covered. The import hook is a function:

```python
install_import_hook()
```

The hook instruments targeted modules on import, with no manual edits inside them, and it needs to be installed before those modules are imported. Any module it touches must have its original source accessible, because recompilation means reading the file again. It may also clash with other import hooks, which matters in any application that already uses one for instrumentation, lazy loading, or coverage.

The debugger story is mixed too. The README calls the explicit argument and return checking functions debugger friendly, with the exception of running under the pydev debugger with the C extension installed. Instrumentation has its own interaction with debugging that the README does not spell out, so if your team's normal bug hunt is stepping through code in an IDE, that is worth checking in your own suite before enabling it broadly.

## Python 3.10 and a hard floor of typing_extensions 4.14

The packaging metadata is specific about what you need. `requires-python = ">= 3.10"`, and the classifiers run from Python 3.10 through 3.15, so any project still on 3.9 cannot use it. The runtime dependency list is short:

```toml
dependencies = [
    "typing_extensions >= 4.14.0",
]
```

That single dependency is the whole footprint. It also tells you something about which annotation syntax is safe: newer typing constructs come from `typing_extensions`, so pinning it matters more here than in a typical library. The version itself is dynamic, taken from `setuptools_scm`, which means installs from a source tree get a version derived from the git tag rather than a hardcoded string.

Building from source needs `setuptools >= 77` and `setuptools_scm[toml] >= 6.4`. The project ships `py.typed` as package data, so type information is available to static checkers for downstream users.

The supported Python range is wide, and that range is the upgrade cost. Adopting typeguard in a service means a policy decision about how much of the codebase gets instrumented, and each additional module is another module that must remain readable at run time in a zipapp, a frozen binary, or a container built from a wheel without sources.

## What breaks first: source-less installs, dynamic hints, and hook collisions

Three failure modes are documented, and they fail in different ways. The first is source availability. Instrumentation recompiles modules, so a deployment that ships only `.pyc` files or strips `.py` sources will fail at import, not with a type error but with something closer to a missing file. If you build container images by copying only installed packages, this is the thing to check.

The second is dynamic type hints. For the explicit checking functions the README says outright that they do not work reliably with dynamically defined type hints, for example in nested functions. Annotations that are only correct when some outer scope is in a particular state are exactly the sort that breaks quietly.

The third is the import hook clashing with other import hooks. Two mechanisms both wanting to rewrite the same module at import time is a resolution problem that will not be caught by type checking at all.

There is a fourth, quieter limit that the README does not discuss: none of these routes are free at run time. Checks on hot paths run on every call. The library does not publish benchmark numbers and the repository documentation does not give a cost model, so if a function is called millions of times per request you will have to measure it yourself before enabling instrumentation globally.

## How this differs from pydantic and from a static checker

There are two comparisons worth making, and neither is a competitor in the strict sense.

Against a static checker such as mypy, the distinction is scope, not quality. mypy reads the source and never executes it; it cannot know that a value came from an HTTP body. typeguard executes the annotation expressions at run time and checks the value that actually arrived. The cost profile inverts too: static checking is slow once and free forever, run-time checking is free at rest and paid on every call.

Against a validation library such as pydantic, the difference is what gets produced. pydantic takes a declared model and builds a validated object with defaults, coercion and serialisation support. typeguard's `check_type` returns the value it was given, and its instrumented checks return whatever your function already returns. It verifies. It does not convert, does not fill in defaults, and does not define a schema for your payloads. If you want a validated object with fields, typeguard is the wrong tool; if you want to know that the `int` your function was handed is actually an `int`, it fits.

## Turning it on under pytest before you turn it on in production

The lowest risk way to evaluate this library is the pytest plugin, because it lets you see mismatches without committing to a deployment policy. Install the package into your test environment and the entry point registered in `pyproject.toml` does the rest.

A sensible sequence starts with `check_type` at the boundaries where external data enters the process, then moves to `@typechecked` on individual functions rather than whole modules. Only after that is worth considering the import hook, and only for modules you control.

The project's own test configuration is worth mirroring because it is strict in ways most suites are not:

```toml
[tool.pytest.ini_options]
addopts = "--tb=short"
testpaths = "tests"
xfail_strict = true
filterwarnings = ["error"]
```

`filterwarnings = ["error"]` turns every warning into a failure, which is the setting that will surface deprecations early when you upgrade across typeguard releases. Coverage is scoped to the package itself:

```toml
[tool.coverage.run]
source = ["typeguard"]
```

And mypy is pinned in the test dependency group with a marker that excludes PyPy, which tells you the maintainers do not treat PyPy as a supported configuration for type checking, even though nothing in the README says PyPy installs are blocked.

## Release cadence and licence metadata do not quite agree

The project is not archived, and the last push was on 2026-10-05. Recent releases are 4.6.0 on 2026-07-26, 4.5.2 on 2026-05-14, and 4.5.1 on 2026-02-19. That is a steady patch cadence rather than a burst, and the classifier `Development Status :: 5 - Production/Stable` matches what the release history shows.

The licence situation needs a second look. The repository metadata classifies the licence as a custom one that GitHub cannot categorise, pointing at the LICENSE file, while `pyproject.toml` declares:

```toml
license = "MIT"
```

The declaration and the classification disagree in form. There is nothing here that needs a lawyer, but if your organisation gates on licence identifiers automatically, the metadata GitHub shows you will not match the string in the packaging metadata, and you should read the LICENSE file yourself and decide which one your process trusts.

Upgrading across the 4.x line is likely to mean reviewing what your instrumentation targets, since the whole library's behaviour is tied to how annotations evaluate. The version history lives in the documentation rather than in the repository root, so check it against the version you are moving to before rolling the change through several services at once.

## Conclusion

Adopt typeguard when a service receives untyped or loosely typed data at a boundary and you want a failure with a clear type name instead of a wrong value three layers down. Do not adopt it as a substitute for mypy or pyright, and do not turn on the import hook across a whole dependency tree unless you have measured the start-up cost. Before shipping, run your own suite with filterwarnings = error turned on, confirm that every module you instrument has its source available at run time, and read the LICENSE file yourself, since the project metadata classifies it as a custom licence GitHub cannot categorise even though pyproject.toml declares MIT.

## FAQ

### How do I install typeguard and run it under pytest?

Install the typeguard package into your environment; it requires Python 3.10 or later and depends on typing_extensions >= 4.14.0. It registers a pytest plugin through the pytest11 entry point, so no conftest change is needed for its checks to be available during a test run.

### Does typeguard work with dynamically defined type hints, such as in nested functions?

Not reliably for the check_argument_types() and check_return_type() functions; the README states this limitation directly. If your annotations are only assembled at call time inside a nested scope, prefer check_type at the boundary instead.

### What does the typeguard import hook require at run time?

It needs the original source code of the modules it instruments, because they are recompiled with checking code injected. It must also be installed before the targeted modules are imported, and the README warns it may clash with other import hooks.

### Can typeguard replace a static type checker such as mypy?

It is designed to sit alongside one rather than replace it. mypy proves mismatches from the source without executing anything, while typeguard checks values that only exist at run time, such as data arriving from an external service.

### What does typeguard check in generator functions?

Under code instrumentation, generator functions, both regular and async, have their yield and send values checked along with arguments, return values and assignments to annotated local variables.

## Sources

- [agronholm/typeguard on GitHub](https://github.com/agronholm/typeguard)
- [Issues](https://github.com/agronholm/typeguard/issues)
- [README](https://github.com/agronholm/typeguard/blob/master/README.md)
- [Releases](https://github.com/agronholm/typeguard/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/agronholm-typeguard
