skip to content

How does `typing.TYPE_CHECKING` break an import cycle caused only by type annotations?

level: seniorimportance: should knowfreq 42%

answer

  1. A constant that differs for the checker
  2. The block never executes at run time
  3. Annotations do not need the name present
  4. Lazy annotation evaluation since 3.14
  5. Resolving the hints raises NameError

basics

~10 s

typing.TYPE_CHECKING is False at run time and True for type checkers, so an import inside if TYPE_CHECKING: never executes and cannot create a cycle, while the checker still sees the name for annotations.

solid answer

~50 s

`typing.TYPE_CHECKING` is a plain constant that is `False` when Python runs, but every static type checker treats it as `True`. Putting `from planner import Route` inside `if TYPE_CHECKING:` means the import never executes at run time — so the cycle is gone — while the checker still resolves `Route` in annotations. On Python 3.14 that is all you need, because PEP 649 made annotations lazily evaluated: `def leg(r: Route) -> None` no longer touches `Route` at definition time. Before 3.14 the annotation also had to be a string, or the module needed `from __future__ import annotations`. The catch is that the name genuinely does not exist at run time, so anything that *resolves* annotations to objects — `typing.get_type_hints`, `annotationlib.get_annotations` in its default form, a runtime validation or serialization library — raises `NameError`. Use the guard only when the dependency is purely for typing.

code

python · 17 lines
python
from typing import TYPE_CHECKING, get_type_hints
import annotationlib

if TYPE_CHECKING:
    from decimal import Decimal


def total(price: Decimal) -> Decimal:
    return price * 2


print(TYPE_CHECKING, total(3))
print(annotationlib.get_annotations(total, format=annotationlib.Format.STRING))
try:
    get_type_hints(total)
except NameError as exc:
    print("NameError:", exc)

go deeper

for a junior

Know the shape of the pattern: if TYPE_CHECKING: around an import used only in annotations, and that the block never runs when Python executes the file. You will read this idiom long before you have to design with it.

for a middle

Explain that the constant is False at run time and True for checkers, and that on 3.14 lazily evaluated annotations mean the name need not be quoted, whereas 3.13 and earlier needed a string or the future import.

for a senior

Show the judgement: apply the guard only for typing-only dependencies, anticipate the NameError from anything that resolves hints, and know that annotationlib formats exist for tools that must introspect anyway.

for a principal

Take a position on the guard as a codebase policy — it is a legitimate tool for typing-only edges and a way of hiding a genuine cycle, and the difference has to be visible in review rather than left to individual taste.

### The pattern ```python from typing import TYPE_CHECKING if TYPE_CHECKING: from planner import Route # never executed at run time def cost(route: Route) -> float: return route.distance * 1.4 ``` `typing.TYPE_CHECKING` is defined as `False` in the standard library. Type checkers special-case it and analyse the block as if it were `True`. So the checker sees the import and can resolve `Route`; the interpreter skips the block, `planner` is never imported here, and a cycle that existed only because of that annotation disappears. ### Why it works on 3.14 without quoting Before 3.14, annotations were ordinary expressions evaluated when the `def` or `class` statement executed. An unquoted `Route` that had never been imported at run time therefore raised `NameError` immediately, and the pattern required either a string annotation (`"Route"`) or a module-level `from __future__ import annotations` (PEP 563, available since 3.7), which turned all annotations into strings. Python 3.14 implements PEP 649/749: the compiler stores annotations in a separate `__annotate__` function and `__annotations__` is computed on first access. The name is not looked up when the function is defined, so the guarded pattern now works with plain, unquoted annotations. `from __future__ import annotations` still exists and still works on 3.14, but is no longer needed for this. ### The failure this pattern hands you The name is genuinely absent at run time, and anything that asks for annotation **values** will say so: ```python typing.get_type_hints(cost) # NameError: name 'Route' is not defined cost.__annotations__ # NameError, for the same reason ``` This is not a bug in the pattern; it is the pattern. Reach for the guard only when the import exists *solely* to satisfy the type checker. If the class is needed at run time — an `isinstance` check, a default value, a call to the constructor, a registry lookup — the guard is a lie and you must fix the dependency direction or defer the import into the function instead. ### Getting the annotations back without resolving them When a tool needs to inspect annotations of code that uses the guard, 3.14's `annotationlib` gives it formats that do not require the names to exist. `annotationlib.get_annotations(cost, format=annotationlib.Format.STRING)` returns `{'route': 'Route', 'return': 'float'}` — text, no lookup. `annotationlib.Format.FORWARDREF` returns unresolvable names as forward-reference objects rather than raising. Introspection libraries that build things from annotations at class-creation time are expected to move to those formats; on 3.14 a `dataclass` field whose annotation names a guard-only symbol already builds successfully for exactly this reason. ### Practical notes * Only the *import* goes inside the guard. The annotation stays where it is. * Because the block never runs, mistakes inside it are invisible until a checker runs. An import there that is wrong, or that stays behind after the annotation is deleted, will not fail any test. * String annotations remain valid and remain necessary if you must support 3.13 or earlier, where the guard alone is not enough. * The guard removes the *run-time* edge only. Your import graph still has the edge as far as a reader and a type checker are concerned, so if the cycle reflects genuinely tangled responsibilities, the guard silences the symptom rather than curing it. Extracting the shared type into a module both sides import is still the better answer when the type is shared, and where those boundaries belong is a design question in its own right. ### What to say in an interview Name the three things: `TYPE_CHECKING` is `False` at run time and `True` to the checker; on 3.14 annotations are lazily evaluated so unquoted names work, whereas 3.13 and earlier needed a string or the future import; and anything that resolves annotations at run time will raise `NameError`, which is the deliberate cost of the trick.

  • When is the TYPE_CHECKING guard the wrong tool?
    Whenever the imported object is needed at run time: an `isinstance` check, a default argument value, constructing it, registering it, or any library that resolves annotations into real objects when the class is defined. The guard makes the name genuinely absent, so those all fail with `NameError`. In that case fix the dependency direction, or move the import inside the function that needs the object.
  • How can a tool read annotations from code that uses the guard?
    On 3.14, `annotationlib.get_annotations` accepts a format. `Format.STRING` returns the annotation source text without evaluating anything, and `Format.FORWARDREF` returns unresolvable names as forward-reference objects instead of raising. Both let an introspection tool see the shape of the annotation while the guarded import remains unexecuted.
  • Do you still need `from __future__ import annotations` on 3.14?
    Not for this. PEP 649 makes all annotations lazily evaluated by default on 3.14, so an unquoted guarded name no longer raises when the function or class is defined. The future import is still accepted and still turns annotations into plain strings, and remains necessary if the same source must run on 3.13 or earlier.

saying these in an interview costs you the question

  • Thinks TYPE_CHECKING is True at run time under some flag
  • Puts run-time-needed imports behind the guard
  • Claims the guarded import makes get_type_hints work
  • Believes a string annotation and the guard are the same fix
  • Says the guard removes the dependency from the design
  • Assumes eager annotation evaluation is still the 3.14 default

context