skip to content

Since Python 3.14, do you still need quotes around a forward reference in an annotation?

level: juniorimportance: should knowfreq 35%

answer

  1. When does the annotation expression run?
  2. Definition time versus first read
  3. 3.14 changed the timing
  4. A hidden function holds the expressions
  5. PEP 649 and __annotate__

basics

~10 s

No. Python 3.14 evaluates annotations lazily, so a name used in an annotation only has to exist when something reads the annotations, not when the def or class statement runs. Quotes became optional.

solid answer

~40 s

Before 3.14, the expression after a colon or `->` was evaluated immediately as part of executing the `def` or `class` statement, so naming a class that had not been defined yet raised `NameError` and the workaround was to write it as a quoted string. PEP 649, shipped in Python 3.14, compiles those expressions into a separate function stored on the object as `__annotate__` and only runs it when someone actually reads `__annotations__`. By then the module has finished importing, so an unquoted forward reference resolves fine. Quotes are still valid syntax and are still required if you must run on 3.13 or earlier, and a name that never exists at runtime — one imported only under `typing.TYPE_CHECKING` — still fails whenever the annotations are read.

code

python · 8 lines
python
def connect(node: Node) -> Node:
    return node

class Node:
    pass

print(connect.__annotations__)
# {'node': <class '__main__.Node'>, 'return': <class '__main__.Node'>}

go deeper

for a junior

Be ready to say that since Python 3.14 an annotation is not evaluated until someone reads it, so a class named before it is defined no longer needs quotes. Know that quoting is still legal.

for a middle

Explain the mechanism: the compiler stores the annotation expressions in a separate __annotate__ function and __annotations__ calls it on first access. Be able to say what still raises NameError and when.

for a senior

An interviewer expects you to know the version floor this creates. Dropping quotes or the future import is only safe once the project no longer supports 3.13, and any runtime annotation consumer needs a plan for names that never exist.

for a principal

Own the rollout question: when the codebase moves its minimum to 3.14, whether unquoting is worth a mechanical sweep, and how libraries you publish should behave for consumers still on older interpreters.

### What an annotation actually is An annotation is the expression written after a colon on a parameter or a variable, or after `->` for a return type. It is ordinary Python source: `int`, `list[str]`, `Node | None`. Static type checkers read that source and never execute it, but the interpreter has always had to decide *when*, if ever, to turn the expression into a runtime object. ### The old rule: evaluate at definition time On Python 3.13 and earlier, annotations were evaluated eagerly while the `def` or `class` statement executed. That made `__annotations__` a plain dict of real objects, but it meant every name in every annotation had to be bound *at that instant*. The classic casualty is a self-referencing class: ```python class Node: def link(self, other: Node) -> Node: # NameError before 3.14 ... ``` The class object does not exist until the whole `class` body has finished running, so on 3.13 the annotation on `link` blows up mid-body. The same happens with two classes that reference each other, and with a type imported only for checking. ### The old workaround: a quoted string The fix was to write the annotation as a string literal — `other: "Node"` — because a string is just a string and evaluates to itself. Type checkers understood the convention and un-quoted it themselves. It worked, but it was noise, it was easy to forget, and it pushed the real failure to whatever later tried to resolve the string. ### The new rule: evaluate on first read PEP 649, together with the amendments in PEP 749, landed in Python 3.14. The compiler now collects a function's, class's or module's annotation expressions into a separate implicitly-created function and stores it as the `__annotate__` attribute. The `def` line no longer evaluates anything. The first time something reads `__annotations__`, the interpreter calls `__annotate__`, builds the dict, and caches it. Because the read almost always happens after the module has finished importing, an unquoted forward reference now resolves normally: ```python def connect(node: Node) -> Node: return node class Node: pass print(connect.__annotations__) # {'node': <class '__main__.Node'>, 'return': <class '__main__.Node'>} ``` That is the headline: **the quotes are gone, and what you get back is a real class object, not a string.** ### What still fails Deferral moves the failure; it does not delete it. If a name genuinely does not exist at runtime, reading the annotations still raises `NameError` — it just raises it at read time instead of definition time. The commonest case is a symbol imported only inside an `if typing.TYPE_CHECKING:` block: the checker sees it, the interpreter never binds it, so any runtime consumer of the annotation hits the same error it always did. The difference is that the error is now *avoidable*. Code that never inspects annotations at runtime — which is most code — never pays it. Code that does inspect them can ask for a format that tolerates unresolved names instead of one that demands real objects. ### Things that got better as a side effect Annotations that refer to a name in an enclosing function's scope work, because the generated `__annotate__` closes over that scope the way any nested function would. Definition-time cost drops: annotating a function no longer executes `list[dict[str, int]]` on import, it just attaches a small code object. And nothing pays a resolution cost unless it reads the annotations. ### Version boundaries — the part that bites This behaviour is **3.14 and later only**. If your package still supports 3.9 through 3.13, an unquoted forward reference is a hard `NameError` there, so either keep the quotes or keep `from __future__ import annotations` until your floor moves to 3.14. Quoted annotations remain perfectly legal on 3.14 — nothing was removed, and a codebase full of them keeps working. Deleting the quotes is a cleanup you do when your minimum version allows it, not an urgent migration. ### What to say in an interview Name the version, name the mechanism, and name the residual failure: since 3.14 annotations compile into a lazily-called `__annotate__` function, so forward references need no quotes; the `NameError` for a name that truly does not exist merely moves from definition time to the moment something reads `__annotations__`.

  • If the name in the annotation never exists at runtime, when does the error appear?
    At the moment something reads `__annotations__`, not at the `def` line. A symbol imported only inside an `if typing.TYPE_CHECKING:` block is the usual case: the interpreter never binds it, so any runtime consumer of the annotation raises `NameError`. Nothing is cached when the call fails, so the next read retries and raises again. Code that never inspects annotations at runtime never notices.
  • Are quoted annotations now wrong, and should you strip them from an existing codebase?
    They are not wrong — string annotations are still valid on 3.14 and every tool still understands them. Stripping them is a cleanup, not a fix, and it is only safe once your supported-version floor is 3.14: on 3.13 and earlier the unquoted form raises `NameError` at definition time. Treat it as a change you make with the version bump, not ahead of it.

The old behaviour was paying a bill the moment the envelope arrived; the new one files the envelope unopened and only settles up if someone asks what you owe.

saying these in an interview costs you the question

  • Claims quotes are still mandatory for forward references on 3.14
  • Says 3.14 turns all annotations into strings
  • Thinks the change applies retroactively to 3.11 or 3.12
  • Believes deferral means annotation errors can never happen
  • Confuses this with what a static type checker does to source

context