skip to content

How do you write Python annotations that reference a class in a module you cannot import at runtime?

level: seniorimportance: should knowfreq 45%

answer

  1. The import must not run, but must be seen
  2. A constant the checker reads differently
  3. False at runtime, true to the analyser
  4. typing.TYPE_CHECKING plus a quoted name
  5. Breaks anything that resolves annotations later

basics

~20 s

Guard the import with an if TYPE_CHECKING: block, which type checkers follow but the interpreter never executes, and write the annotation as a quoted string so nothing tries to resolve the missing name while the program runs.

solid answer

~50 s

`typing.TYPE_CHECKING` is a constant that is `False` at runtime and that every type checker treats as `True`. Putting the offending import inside `if TYPE_CHECKING:` gives the checker full knowledge of the class while the interpreter never executes the import, so the cycle never forms. The annotation itself must then be a quoted string — `def prorate(amount: "Decimal")` — because the name genuinely does not exist at runtime. The trade-off is the part to say out loud: anything that *reads* those annotations at runtime, such as a decorator that builds a class from its annotations, will raise `NameError`, because the import never happened. A type that runtime machinery must resolve needs a real import, and then the cycle has to be broken structurally instead — usually by moving the shared type into a module both sides can import.

code

python · 10 lines
python
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from decimal import Decimal

def prorate(amount: "Decimal", days: int) -> "Decimal":
    return amount

print(TYPE_CHECKING)
print(prorate.__annotations__)

go deeper

for a junior

Recognise the pattern when you meet it: an import tucked under a TYPE_CHECKING block exists for tooling only, and the matching annotations are quoted. Do not move that import out to the top of the file to tidy up.

for a middle

Explain the mechanics: the constant is False at runtime and true to a checker, so the block is analysed but never executed, and the annotation is quoted because the name is genuinely unbound. Be able to write the pattern from memory.

for a senior

Demonstrate the judgment: decide whether the cycle is annotation-only or real, and know that this pattern breaks anything that resolves annotations at runtime, with a late and confusing NameError. Name the structural fix for when a real import is required.

for a principal

Own the boundary policy: which module owns shared domain types so cycles do not arise, whether the codebase permits runtime introspection of annotations at all, and what convention you write down so teams stop reaching for the guard as a general cure for circular imports.

## The situation Split a subscription-billing system into modules and this appears within a day. `invoices.py` has a function that takes a `Ledger`; `ledger.py` has a method that returns an `Invoice`. Each module needs the other's class *only to name it in an annotation*, and importing both ways is a circular import. Nothing here is a real runtime dependency — no code in either module calls into the other at import time — so paying for a genuine restructuring would be overkill. ## The mechanism `typing.TYPE_CHECKING` is a module-level constant whose runtime value is `False`. Type checkers special-case it: when they analyse your source they behave as though it were `True`. So a block guarded by it is fully visible to the checker and completely absent from the running program. ```python from typing import TYPE_CHECKING if TYPE_CHECKING: from decimal import Decimal def prorate(amount: "Decimal", days: int) -> "Decimal": return amount prorate.__annotations__ # {'amount': 'Decimal', 'days': <class 'int'>, 'return': 'Decimal'} ``` The checker sees a real import and validates every use of the class. The interpreter sees a `False` branch, imports nothing, and stores the annotation as a string it never tries to interpret. ## Why the annotation is quoted Because the name truly is not bound at runtime, and a quoted annotation is never looked up at all. On **Python 3.14** an unquoted annotation would survive the definition, since annotations are no longer evaluated when the `def` executes — but the failure has only moved: ```python from typing import TYPE_CHECKING if TYPE_CHECKING: from decimal import Decimal def prorate(amount: Decimal) -> Decimal: return amount prorate(1) # fine — calling never touches annotations prorate.__annotations__ # NameError: name 'Decimal' is not defined ``` On 3.13 and earlier the same code raises at import time. Either way, quoting is what keeps the annotation inert, which is exactly what you want for a name that will never exist. ## The trade-off a senior is expected to name This pattern makes the annotation unresolvable at runtime *on purpose*, and that is a real cost, not a free win. Any consumer that walks `__annotations__` — a decorator that builds a constructor from a class's fields, a serializer, a validation layer that turns annotations into checks, or a framework that inspects a callable's parameters — will look for `Decimal` in that module's namespace and not find it. The failure is late and confusing: the module imports fine, the code runs fine, and then something reads the annotations and raises `NameError`. In a six-hour nightly billing run, the natural place for that to surface is at hour five, in the one code path that finally introspected the model. So the decision rule is: - **Annotation-only dependency, no runtime introspection** → `TYPE_CHECKING` guard plus a quoted annotation. Cheap, standard, zero import cost. - **Something resolves these annotations at runtime** → you need a real import. Then break the cycle structurally: move the shared type (a `Money` or a `LineItem`) into a third module that both sides import, or invert the direction so only one module knows about the other. ## Related honesty The guard fixes an *annotation-only* cycle. It does nothing for a genuine one where module A calls a function in module B at import time and B does the same to A — for that, deferring the import into the function body, or extracting the shared code, is the fix. Candidates who reach for `TYPE_CHECKING` as a general circular-import cure are treating a symptom, and it is worth saying which of the two problems you actually have before you pick the tool. ## Cost and hygiene The guarded import is free at runtime, which is a small side benefit: a heavy module used only for typing is never imported by production processes. The price is that the module's annotations no longer stand on their own, so any team convention that lets code introspect annotations must be stated alongside this pattern rather than discovered by an exception.

  • What is `typing.TYPE_CHECKING` at runtime, and why does the guard work at all?
    It is a plain constant whose value is `False` while the program runs, so the guarded block never executes and the import never happens. Type checkers special-case the name and analyse the block as if it were `True`, so they see the import and validate every use of the class. The result is full static knowledge at zero runtime cost.
  • When is the TYPE_CHECKING guard the wrong answer?
    When something resolves those annotations at runtime — a decorator that builds a class from its fields, a serializer, or a framework that inspects a callable's parameters. The guarded name is not in the namespace, so resolution raises `NameError`. Then you need a real import and a structural fix for the cycle: move the shared type into a module both sides import, or invert the dependency.
  • Does the guard also fix a genuine circular import between two modules?
    No. It fixes a cycle that exists only because of annotations. If module A actually calls into B at import time and B into A, no annotation trick helps — you defer the import into the function body where it is used, or extract the shared code into a third module. Diagnose which of the two you have before choosing.

saying these in an interview costs you the question

  • Believes the guarded import is available while the program runs
  • Assumes the guard also fixes a real runtime dependency cycle
  • Expects code that reads annotations to resolve the guarded name
  • Cannot say what typing.TYPE_CHECKING evaluates to at runtime
  • Deletes the annotation instead of deferring the import
  • Claims quoting the annotation is unnecessary in every modern version

context