PEP 9999 – AST Format for Annotation Functions
- Author:
- Imogen Hergeth <me at imogen.tech>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Status:
- Draft
- Type:
- Standards Track
- Topic:
- Typing
- Requires:
- 749
- Created:
- 17-Dec-2025
- Python-Version:
- 3.15
- Post-History:
- 05-Nov-2025
Abstract
This PEP proposes that a new value, Format.AST, is added to the
annotationlib enum and associated protocol. It instructs annotation
functions to not evaluate the annotation expressions directly, but rather to
return the abstract syntax trees that define each of them. This lets runtime
consumers of annotations observe their full definition, rather than just the
object they evaluate to. Which, in turn, creates the possibility of simplifying
many existing type annotations and using existing intuitive Python syntax in
typing contexts.
Motivation
Background
When annotations were introduced to Python in PEP 3107 and PEP 526, they
were defined simply as expressions that relate to a variable and are stored in
a new special dictionary __annotations__. That is, writing
var_name: expression just causes the Python interpreter to evaluate
expression and store the result in the enclosing function’s or class’s
__annotations__ dictionary under the var_name key.
This approach not only makes the implementation very simple, it also gives users the freedom to use annotations to attach various kinds of metadata to variables. One possibility are type hints, which specify what sets of values a variable is expected to contain and how a function can safely be called. These have proven to be very popular and have first received official support in PEP 484 and many additional features since.
But this implementation also creates challenges for type hints. While simple
types such as the set of all integers can be spelled by just writing
var: int, there is no built-in syntax for more complicated typing concepts
like generics. When 484 introduced these, it thus had to
repurpose an existing form of expressions, namely indexing, and define the
needed operators on classes that should be usable in generic type annotations.
Note that while conceptually the expressions some_dict["key"] and
list[int] denote very different operations, dictionary indexing and
specialization of a generic type, Python treats them exactly the same, an
invocation of the __getitem__ operator.
PEP 649 and PEP 749 changed this behavior in some ways. Instead of the
__annotations__ dictionary being built as the annotations are encountered,
the annotation’s execution is now delayed until __annotations__ is actually
accessed. Internally, a new method __annotate__ is synthesized that creates
the dictionary by evaluating the class’s or function’s annotations. However,
what remains unchanged is that annotations are still treated as ordinary
expressions. They are evaluated just like any other expression in a different
context, their execution just is delayed until they are needed.
Problems with the Current Approach
This behavior can cause problems where users want to use an expression in
annotations in a way that clashes with the expression’s usual behavior. For
example, consider literal types, i.e. types that consist of some particular set
of literal values. Currently, these are written as e.g. Literal[1]. Many
users of type annotations would prefer to drop the redundant Literal[], the
context of it occurring in a type annotation already makes it clear that the
1 represents the literal type containing only 1. This also is reflected
in other languages, such as TypeScript, implementing literal types that way.
The problem arises when these literal types are combined with other typing
constructs. For example, the union type 1 | 2 | 3, representing values that
are either 1, 2 or 3, cannot be properly evaluated at runtime. We
would want it to evaluate to a UnionType containing references to
1, 2, 3. But since the __or__ special method is already implemented on
int as the bitwise or operation, it will just be evaluated to 3. There
is no way of recovering the 1 and 2 present in the annotation from the
__annotation__ dictionary.
Similar issues prevent us from using display syntax to denote the built-in
container types, forcing us to write set[int], list[int] and
dict[int, str] instead of just {int}, [int] and {int: str}.
For example, in the first case the display syntax causes a set object to be
constructed rather than a special object representing a type. And set
already implements __or__ to create a new set containing all elements of
both arguments, rather than a Union object.
While all of these examples do have existing workarounds, the
additional work required to spell these types and their readability issues do
present real problems. Many Python users, particularly those new to typing,
intuitively reach towards the easier and shorter syntax like (int, str)
to denote a tuple type. The more verbose syntax also is cumbersome to understand
when it occurs as a type argument for a generic type. A very common example are
matrix libraries like numpy that use integer tuples to define a matrix’s shape.
When creating such a matrix, you simply write
ndarray(..., shape=(16, 1000)). But that matrix’s type is spelled as
ndarray[tuple[Literal[16], Literal[1000]], ...]. A user unfamiliar with
the internals of the Python type system is hard-pressed to see such an
annotation and understand what information it is trying to tell them and why
they have to use these seemingly redundant Literal tags.
This example also shows that the annotation specification’s intention of them
being treated as any other expression is not being honored very well by this
implementation. While an annotation can contain arbitrary expressions, this is
only true from the interpreter’s point of view. The vast majority of users,
which use type annotations have to switch from the familiar (16, 1000)
syntax to the annotation-specific tuple[Literal[16], Literal[1000]].
There also are new typing features that are being held back by this requirement
to create syntactical workarounds. For example, inline typed dictionaries could
intuitively be defined inline as {"some_key": int, "other_key": str}. But
this again does not work because the semantics of dict objects to not work
properly in typing contexts. There is previous discussion
of this, with the main hurdle to implementation being the runtime behavior.
Other examples are conditional types,
which let users write ternary statements in type definitions, like
type SomeAlias[T] = int if issubclass(T, str) else str. Or extending integer
literal types to support basic arithmetic operations. This would enable array
libraries to properly track shapes across operations like concatenation. The
existing work on this
unfortunately concluded that while this is a very useful feature for many users,
implementing it with current semantics is too verbose and cumbersome to gain
much traction.
Rationale
To more easily understand this proposal, we’ll work through it all with an
example. Let’s say a function is defined as def func(arg: 1 | 2): ....
Currently, its __annotate__ method is generated to look (if it were Python)
like this:
def __annotate__(self, format):
if format >= 3:
raise NotImplementedError
return {
"arg": 1 | 2,
}
We can see that it just checks whether the requested format is one of the two
VALUE formats (for more detail on why two such formats exist, see
749) and returns a dict
mapping the argument name to its annotation.
Note here that while we see the string "1 | 2" in the source code, the
caller of the function never gets to see that. It merely receives the object
that expression evaluates to, the int object 3.
The AST Format
In this PEP, we address this issue by proposing to add a new format, AST.
It instructs __annotate__ methods to return the abstract syntax tree of the
annotation expressions, rather than their value. This will let consumers of
annotations evaluate the AST using whatever semantics are appropriate to their
domain rather than the built-in Python expression semantics.
Going back to our example, func will then have an __annotate__ method
that conceptually might look something like this:
def __annotate__(self, format):
if format <= 2:
return {
"arg": 1 | 2,
}
elif format == 5:
return {
"arg": BinOp(
left=Constant(value=1),
op=BitOr(),
right=Constant(value=2),
),
}
else:
raise NotImplementedError
The newly supported format 5 will be the value of the AST format. When
it is passed, the function returns a similar dictionary that contains the
annotation’s AST, as represented by the existing classes from the ast
module. Users will then be able to analyze the full annotation by inspecting
this AST object.
!!!! TUPLES !!!!
Analyzing the AST
The AST format intentionally leaves open many possibilities for users to treat
the returned AST objects in domain-specific ways. That being said, there are
some special considerations afforded to the most popular usage of annotations,
type hints. In particular, the typing module will contain helper
functions that internally use the AST format to retreive annotations and
then parse the syntax trees into familiar typing objects.
In our example, we are using type annotations and are presuming that the typing spec will have been changed to let bare integer literals refer to the literal type containing them.
We do not propose adding direct support for any of the specific type annotation
changes or additions that are discussed in this PEP and restrict this proposal
to laying the necessary groundwork that enables them. Such changes are mentioned
only as motivating examples to show why the AST format is needed.
In order to support such future additions to the typing spec, the
typing.get_type_hints helper function will support a mode that uses the
AST format to evaluate type annotations with the typing spec’s semantics.
There are some places other than annotations where users need to spell a type,
for example, the first argument to typing.cast. These use cases will be
covered by the existing practice of wrapping types in string literals. If such
a type needs to be introspected at runtime, a new helper function
typing.eval_type can be used, which internally uses the new format to
evaluate a string under the typing spec’s semantics.
Specification
Throughout this PEP, when we talk about annotation functions/methods, we are
referring to both the __annotate__ special methods found on some objects
and the following methods found on typing objects:
evaluate_valueontyping.TypeAliasTypeevaluate_bound,evaluate_constraints, andevaluate_defaultontyping.TypeVarevaluate_defaultontyping.ParamSpecevaluate_defaultontyping.TypeVarTuple
When referring to their return values, we mean either the objects contained in
the dictionary returned by __annotate__ methods or the single object
returned by the other methods.
The AST Format
A new value called AST is added to the annotationlib.Format enum with
value 5. Annotation functions do not have to support this format. If an
annotation function is called with this format, it must return instances of
ast.expr that do not contain ast.NamedExpr, ast.Yield,
ast.YieldFrom or ast.Await nodes (the Python grammar
already prohibits the use of these features in annotations).
Compiler-generated annotation functions will always support this format
and return the abstract syntax tree of the class’s or function’s annotations.
They will store the necessary AST data stored as tuple constants and then
construct a new ast.expr object each time they are called. These objects
will be functionally identical to the objects created by parsing the annotation
code directly.
Annotate functions that are generated by library code will need to manually
support the new format or choose to raise NotImplementedError() when it
is requested. Most of these use cases synthesize new annotation functions by
introspecting existing function or class objects and combining and/or modifying
their return values. The new format makes this easier to do since the AST
objects can always be created regardless of the existence of forward references
or similar issues, they can be modified to denote the synthesized annotations
using existing tools, and they can be evaluated to create the objects requested
by other formats.
Helper Functions
The existing typing.get_type_hints function will be modified to accept a
new, optional Boolean keyword argument use_type_syntax that defaults to
False. When set to true, get_type_hints does not call the underlying
annotation function with the requested format but always with Format.AST.
The returned AST is then evaluated to an object, using the semantics of Python
expressions, modified by rules that are defined in the typing spec. At the time
this PEP is written, no such rules exist, and thus the AST is always evaluated
normally.
When the keyword argument is set, any annotation expressions that are not
valid type annotation expressions, as defined by the typing spec
<https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions>__,
cause get_type_hints to raise a SyntaxError().
When the typing spec is changed to define new type expressions,
get_type_hints will be modified accordingly and return a particular object
when it previously would have raised a SyntaxError on the same inputs. This
is an exemption to the usual deprecation policy and will be documented as such.
Other behavior of get_type_hints will not be affected; users can still rely
on the stability of returned values and other raised exceptions.
A new helper method eval_type(tp: str, globals: Mapping[str, object],
locals: Mapping[str, object], format: Format = Format.VALUE) -> TypeForm
will be added to the typing module. It evaluates a string using the
same rules that get_type_hints uses with the type-specific semantics.
This function can be used to evaluate stringified types that are used in places
other than annotations.
In order to ease the creation of synthesized annotation functions, a new helper
function create_annotate_from_asts(annotations: dict[str, tuple[ast.expr,
Mapping[str, object]]]) -> Callable[[Format], dict[str, object]] will be
added to
annotationlib. It creates an __annotate__ method that supports
the VALUE, FORWARDREF, SOURCE and AST formats. The returned
values are the objects of the correct type that would be created if each of
the passed syntax trees were evaluated in the corresponding namespace.
Pseudocode
A function defined as def f(first: int, second: tuple[int, str]) will have
a compiler-generated __annotate__ method that might look something like this
were it written in Python:
def __annotate__(self, format):
if format <= 2:
return {
"first": int,
"second": tuple[int, str],
}
if format == 5:
from ast import _ast_from_tuple
return {
"first": _ast_from_tuple((26, "int", 1, 1, 1, 14)),
"second": _ast_from_tuple((...)),
}
raise NotImplementedError
Backwards Compatibility
Since this PEP only adds new functionality, there are no direct backwards compatibility concerns. However, we also want to point out that future additions like the ones outlined in the Motivation do not create backwards compatibility problems, even if adopted gradually.
Consider, for example, a change to the typing spec that made it so that
var: 1 is interpreted as a literal type Literal[1]. When a user
evaluates the annotation method directly or with one of the helper methods, they
will still observe the result as the plain integer 1. The changed semantics
only happen when the user opts in to the new semantics by calling
typing.get_type_hints with the new keyword argument set.
In the most common use case, annotations are not consumed by the user directly but by some library code that introspects user-defined objects. Thus, adoption of new annotation semantics could be stalled if library authors have to worry about existing user annotations being reinterpreted to different objects when the library is updated. But this also is not an issue. The typing spec already is covered by backwards compatibility concerns, which means that any currently valid type annotations will not be changed to mean something different. Potential future changes can only define new semantics to syntax constructs that currently are not valid type annotations and thus do not occur in user code.
The only potential for issues to happen is if a user has written annotations that currently are not legal type annotations but become legal with modified semantics in the future. We believe that this case is exceedingly rare since such users either do not intend to use annotations for type hints and will thus not use a library that expects them to be type hints, or the code will already not work since the annotations currently cannot be handled correctly by the library code.
Security Implications
There are no known security implications for this change. Calling annotation functions already could execute arbitrary code defined in the annotated object. Python also already offers the capability to access the source code and compiled byte code of introspected objects. The AST objects returned in the new format thus do not contain any previously unavailable information.
How to Teach This
Users of annotations are not directly affected by this proposal. It intends to enable the simplification of the way type annotations are spelled; any future changes based on this PEP will need to be evaluated on their own merits. Documentation will inform users that any new semantics is only natively supported in annotations. Since this is by far the most common place for types to be spelled, we expect this to not be a big limitation. Other places where types can occur already require users to wrap forward references in string literals, so this is a known practice. Type checkers should warn users if they do not wrap a type form using syntax that would be evaluated incorrectly in a place where it is statically known that a type form is expected.
The new format and changes to helper functions will be documented as part of the
language standard. Libraries that introspect type annotations will be able to
easily support any new type syntax by calling typing.get_type_hints and
should document this behavior so that their users are made aware of any
potential future changes.
Reference Implementation
This proposal is prototyped in a CPython fork.
Rejected Ideas
A New Argument for Annotation Functions
An initial idea was to add a new optional argument to annotation functions,
which instructs them to return objects of the specified format’s type but
using type annotation-specific semantics. This was rejected since it means that
the type-specific semantics need to be defined in the interpreter itself, which
limits the kinds of semantics that are possible. It also locks the usage of
typing features to the Python version that is being used, rather than allowing
the usual backporting via typing_extensions.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
Source: https://github.com/python/peps/blob/main/peps/pep-9999.rst
Last modified: 2026-01-06 23:36:08 GMT