2025, Dec 01 15:00
Why Python 3.12 Generic Bounds Don't Raise NameError: PEP 695 Lazy Evaluation and How to Force It
Learn how PEP 695 in Python 3.12 lazily evaluates generic type bounds, why NameError may be deferred, and how to force resolution with typing.get_type_hints.
Python 3.12 introduced a subtle but important change to how type parameters are handled at runtime. If you import a type only under TYPE_CHECKING and then reference it as a bound in the new generic syntax, you might expect a NameError. Instead, it works. This guide explains why that happens, how to trigger an error when you need one, and what to keep in mind when inspecting annotations.
Reproducing the surprise
The snippet below sets up a type only for static analysis and uses it in three ways: a quoted bound, an unquoted bound in the new generic syntax, and a classic TypeVar bound that fails as expected.
from typing import TYPE_CHECKING, TypeVar
if TYPE_CHECKING:
from pydantic import BaseModel
def ok_generic[U: "BaseModel"](cls: type[U]) -> U:
return cls()
def surprising_generic[V: BaseModel](cls: type[V]) -> V:
return cls()
# NameError: name 'BaseModel' is not defined
W = TypeVar("W", bound=BaseModel)
def use_traditional(arg: type[W]) -> W:
return arg()
Using BaseModel as a bound in the type parameter list works even though BaseModel is not present at runtime. But using it as a bound for a classic TypeVar fails with a NameError. That discrepancy is the core of the issue.
What’s actually happening
This behavior is defined by PEP 695, which was implemented in Python 3.12. PEP 695 specifies lazy evaluation for certain type expressions so that definitions can be processed without immediately resolving all referenced names.
This PEP introduces three new contexts where expressions may occur that represent static types: TypeVar bounds, TypeVar constraints, and the value of type aliases. These expressions may contain references to names that are not yet defined. ... If these expressions were evaluated eagerly, users would need to enclose such expressions in quotes to prevent runtime errors. ...
To prevent a similar situation with the new syntax proposed in this PEP, we propose to use lazy evaluation for these expressions, similar to the approach in PEP 649. ...
In other words, the bound in surprising_generic is not evaluated when the function is defined. It’s stored in a deferred form and only resolved if and when you ask for it. That’s why there is no immediate NameError. The traditional TypeVar path, on the other hand, does trigger the error because its bound is evaluated in a way that does not benefit from this lazy handling in your example.
You can also observe that the import is not necessary for the unquoted bound in the type parameter list to be accepted in this case. The definition still doesn’t fail at that point because of the lazy evaluation described above.
Forcing evaluation (and raising NameError)
If you need to resolve the bound at runtime and surface an error when the underlying name doesn’t exist, you can use typing.get_type_hints and then access the bound. The NameError appears when you inspect the bound itself.
import typing
# Triggers NameError when resolving the bound of V
typing.get_type_hints(surprising_generic)["return"].__bound__
# Or via the parameter annotation
typing.get_type_hints(surprising_generic)["cls"].__args__[0].__bound__
You can retrieve annotations for the parameter and the return type freely; the exception occurs only when you look up the __bound__ attribute of the type variable.
Why this matters
With Python 3.12 generics, type parameter bounds in function definitions may not be immediately resolvable at runtime. That’s by design. It avoids failures during import and lets you write clearer annotations without quotes in common cases. The trade-off is that introspection can surprise you: you won’t see an error until you force the bound to be evaluated. Meanwhile, using classic TypeVar with a bound behaves differently and can still fail eagerly as shown above.
Practical takeaways
If you depend on runtime checks or reflection, be explicit about when you want evaluation. Use typing.get_type_hints and access __bound__ to resolve deferred expressions and surface errors when appropriate. If you rely on the classic TypeVar style, be aware it may raise NameError where the new syntax will not. And when you only need static type checking, the new lazy behavior reduces the need for quoting or ensuring runtime imports solely for annotations.
Conclusion
In Python 3.12, bounds in the new generic type parameter lists are lazily evaluated as specified by PEP 695. That’s why an unquoted BaseModel in a bound doesn’t cause an immediate NameError even if it isn’t defined at runtime. If you must confirm the bound or fail fast, explicitly resolve it with typing.get_type_hints and then access __bound__. Keep the difference in mind when mixing the new syntax with traditional TypeVar usage so you know when names will actually be looked up.