---
title: How and why to run modified Python code using the ast module
description: >-
  Learn how to use Python's ast module to transform and execute modified code,
  using eval-type-backport as a real-world example.
date: '2026-06-08'
authors:
  - Alex Hall
categories:
  - Open Source
  - Python
canonical: 'https://pydantic.dev/articles/eval-type-backport'
---

> Markdown version of [How and why to run modified Python code using the ast module](https://pydantic.dev/articles/eval-type-backport) — the canonical HTML page.
>
> By [Alex Hall](https://pydantic.dev/authors/alex-hall.md) · 2026-06-08 · Open Source, Python
>
> Related: [A production-ready full-stack template for AI agents with Pydantic AI and Logfire](https://pydantic.dev/articles/full-stack-ai-agent-template-pydantic-ai.md) · [Pydantic AI v2: capable agentic loops](https://pydantic.dev/articles/pydantic-ai-v2.md)
>
> All articles: [/articles.md](https://pydantic.dev/articles.md) · Site index: [/llms.txt](https://pydantic.dev/llms.txt)

---

I'm about to show you some powerful stuff: how to use the `ast` module
to parse Python code into a structure that you can understand precisely,
manipulate to your will, and execute. This lets you achieve things that are otherwise
difficult or impossible.

I'll explain at the end [why I think this is a valuable skill for you to learn](#why-you-should-learn-this),
especially as the world shifts toward AI. I'll share how I've used this in various personal projects,
how it's helped me get jobs, and how it provides enhanced observability features in the
[Pydantic Logfire](https://pydantic.dev/logfire?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline) SDK.
But to avoid being like that annoying cliché about online recipes starting with a long life story,
I'll get straight to the point first using a simple real-world example.

## The motivating example: `eval-type-backport`

The [`eval-type-backport`](https://github.com/alexmojaki/eval_type_backport) library makes runtime inspection with the
`typing` module possible with newer syntax in older Python versions.
Specifically, it transforms `X | Y` into `typing.Union[X, Y]`
and `list[X]` into `typing.List[X]` etc.
if the original syntax is not supported in the current Python version.

For users of `pydantic`, merely having this package installed should make `pydantic` models with newer syntax work in
older Python versions.

Here's an example of how to use it directly:

```python
from __future__ import annotations
import typing
from eval_type_backport import install_patch

install_patch()


class Foo:
    a: int | str


assert typing.get_type_hints(Foo) == {'a': typing.Union[int, str]}
```

In Python 3.9 and earlier, without the `install_patch`, the above code will raise
`TypeError: unsupported operand type(s) for |: 'type' and 'type'`.

I'm going to show you step by step exactly how the interesting part of the library works,
especially [these lines](https://github.com/alexmojaki/eval_type_backport/blob/8f66cf003e1b28b72187722e03c5ed847287c070/eval_type_backport/eval_type_backport.py#L138-L158).
The rest of the library is wiring and fiddling with internals of the `typing` module.

## How

### Problem statement

Goal: evaluate a string such as `'list[str | int | None] | module.MyClass'`
such that every occurrence of `a | b` is treated as `Union[a, b]` in a robust way.

(Converting e.g. `list[str]` to `List[str]` follows roughly the same pattern)

Let's start by defining a helper function `safe_or(a, b)`
that emulates the new `a | b` behavior as closely as is practical across Python versions:

```python
from typing import Any, Union


def safe_or(a: Any, b: Any):
    try:
        return a | b
    except TypeError as e:
        if str(e).startswith('unsupported operand type(s) for |: '):
            # The error message is something like:
            # "unsupported operand type(s) for |: 'type' and 'type'"
            # The operands could be other things, e.g. None,
            # so we won't check what exactly they are.
            # We'll just assume that it's meant to be a Union.
            return Union[a, b]
        raise


assert safe_or(3, 9) == 3 | 9 == 11
assert safe_or(int, str) == Union[int, str]
```

New goal: replace all occurrences of `a | b` in a string with `safe_or(a, b)`.

Don't parse Python or [HTML with regex](https://stackoverflow.com/a/1732454/2482744).
Use the `ast` module instead.

### The `ast` module

The [`ast`](https://docs.python.org/3/library/ast.html) module allows us to parse Python code strings into an abstract syntax tree (AST), which we can then manipulate. Here's what parsing `int | str` looks like:

```python
import ast

tree = ast.parse('foo = int | str')
print(ast.dump(tree, indent=4))
```

`tree` is our AST object. It has a useless `repr`, so we use `ast.dump` to get a more readable representation. The
`indent=4` argument makes the output more readable by adding indentation, but requires Python 3.9 or later. This will
output something like:

```python
Module(
    body=[
        Assign(
            targets=[
                Name(id='foo', ctx=Store())],
            value=BinOp(
                left=Name(id='int', ctx=Load()),
                op=BitOr(),
                right=Name(id='str', ctx=Load())))],
    type_ignores=[])
```

`Name(id='foo', ...)` is the variable name `foo` in code. `ctx=Store()` means a value is being stored in the name, since
`foo` is on the left of the assignment. `ctx=Load()` means the variable is being loaded, i.e. evaluated.

`BinOp(left=<int>, op=BitOr(), right=<str>)` represents `int | str`. The `op=BitOr()` means the operator is `|`. A
different operator would have a different `op` value, e.g. `Add()` for `+`. This is the kind of thing we want to replace
with `safe_or(int, str)`. Let's see what the AST for that looks like:

```python
import ast

tree = ast.parse('safe_or(int, str)', mode='eval')
print(ast.dump(tree, indent=4))
```

Result:

```python
Expression(
    body=Call(
        func=Name(id='safe_or', ctx=Load()),
        args=[
            Name(id='int', ctx=Load()),
            Name(id='str', ctx=Load())],
        keywords=[]))
```

This time I added `mode='eval'` to `ast.parse` because we are parsing a single expression rather than one or more
statements. The default `mode` is `'exec'`. The difference between the two modes corresponds to the difference between
the `eval()` and `exec()` built-in functions. This will matter more later when we want to evaluate the transformed AST.
For now, it just makes the tree a bit simpler: the interesting part is the `tree.body` rather than `tree.body[0].value`.

Now we know what structure we're aiming for, here's a simple script that modifies an AST 'manually':

```python
import ast

tree = ast.parse('int | str', mode='eval')
binop = tree.body
assert isinstance(binop, ast.BinOp)
assert isinstance(binop.op, ast.BitOr)
call = ast.Call(
    func=ast.Name(id='safe_or', ctx=ast.Load()),
    args=[binop.left, binop.right],
    keywords=[],
)
assert ast.unparse(call) == 'safe_or(int, str)'
```

The `ast.unparse` function (added in Python 3.9) converts an AST back into a string of Python code, which makes it
easier to check that we constructed the AST correctly.

### Transforming the AST recursively

The typical way to transform an AST is to subclass `ast.NodeTransformer`, like this:

```python
import ast


class BackportTransformer(ast.NodeTransformer):
    def visit_BinOp(self, node: ast.BinOp):
        if not isinstance(node.op, ast.BitOr):
            return node

        return ast.Call(
            func=ast.Name(id='safe_or', ctx=ast.Load()),
            args=[node.left, node.right],
            keywords=[],
        )


tree = ast.parse('list[list[int | str] | None]', mode='eval')
tree = BackportTransformer().visit(tree)  # recursively transform the tree
print(ast.unparse(tree))
```

In general, the subclass should implement a `visit_<NodeType>` method for each node type it wants to transform. In our
case, we only care about `BinOp` nodes, so we implement `visit_BinOp`. This method is called for every `BinOp` node in
the AST. If the operator is not `|`, we return the node unchanged. Otherwise, we perform the same modification as
before, replacing it with a call to `safe_or`.

But this script prints `list[safe_or(list[int | str], None)]`. It hasn't transformed the inner `int | str`! The problem
is that while `ast.NodeTransformer` recursively visits all nodes by default, it doesn't automatically recurse into nodes
visited by a `visit_<NodeType>` method. Adding `self.generic_visit(node)` to the start of `visit_BinOp` fixes this, and
we get `list[safe_or(list[safe_or(int, str)], None)]` as expected.

### Evaluating the transformed AST

You could maybe use `eval(ast.unparse(tree))` to evaluate the transformed AST, but that wouldn't work before Python
3.9, it would be inefficient, and it's
[not](https://github.com/python/cpython/issues/84070)
[always](https://github.com/python/cpython/issues/90678)
[correct](https://github.com/python/cpython/issues/111257).
And for some reason, you can't pass
an AST directly to `eval` or `exec`. Instead, you have to compile the AST into a code object first, and then execute
that code object:

```python
import ast

tree = ast.parse('1 + 2', mode='eval')
code = compile(tree, filename='<ast>', mode='eval')
result = eval(code)
assert result == 3
```

The `filename` argument is used when displaying tracebacks. In our case, displaying a perfect traceback isn't important,
so any random string will do.
A code object can be passed to either the `eval()` or `exec()` builtin functions.
Since we want to evaluate a single expression, rather than execute one or more statements, we need to use `eval()`.
Therefore, we need `mode='eval'` for both `ast.parse` and `compile`.

If we try to `compile` the transformed AST from before, we get `TypeError: required field "lineno" missing from expr`.
Again, for the purpose of tracebacks, every AST node needs location attributes like `lineno`. The lazy way to add these
is to call `ast.fix_missing_locations` on the transformed tree.

With that in mind, here's the full code:

```python
import ast

from typing import Any, Union, List


def safe_or(a: Any, b: Any):
    try:
        return a | b
    except TypeError as e:
        if str(e).startswith('unsupported operand type(s) for |: '):
            return Union[a, b]
        raise


class BackportTransformer(ast.NodeTransformer):
    def visit_BinOp(self, node: ast.BinOp):
        self.generic_visit(node)  # visit children recursively
        if not isinstance(node.op, ast.BitOr):
            return node

        call = ast.Call(
            func=ast.Name(id='safe_or', ctx=ast.Load()),
            args=[node.left, node.right],
            keywords=[],
        )
        return ast.fix_missing_locations(call)  # ensure lineno etc. are set


tree = ast.parse('List[List[int | str] | None]', mode='eval')
tree = BackportTransformer().visit(tree)
code = compile(tree, filename='<ast>', mode='eval')
result = eval(code)
assert result == List[Union[List[Union[int, str]], None]]

if hasattr(ast, 'unparse'):  # only in Python 3.9+
    assert ast.unparse(tree) == 'List[safe_or(List[safe_or(int, str)], None)]'
```

Note that `eval` and `exec` can take optional `globals` and `locals` dictionaries to control the namespace in which the
code is executed. They default to the current namespaces, which works for this demo script. In reality you'd use e.g.
the dict of the module where the original string came from as the globals. You'd also need to include `safe_or` in a
namespace. To avoid name conflicts, it's best to actually define a unique name, and use that in both the `ast.Call` and
the namespace, e.g. `safe_or_<uuid>`.

### Another example: printing every subexpression

Here's another example of using `ast.NodeTransformer` to print every subexpression in a string during evaluation:

```python
import ast

source = '(a + b) * c'
namespace = dict(a=1, b=2, c=4)


def debug_node(node_source: str, value):
    print(f'{node_source} == {value!r}')
    return value


class DebugTransformer(ast.NodeTransformer):
    # We want to transform all expression nodes. These are instances of ast.expr.
    # Since there isn't one specific class, we have to override generic_visit.
    # Defining visit_expr wouldn't work.

    def generic_visit(self, node: ast.AST):
        super().generic_visit(node)
        if not isinstance(node, ast.expr):
            return node

        # Get the original source code for this node.
        # Using ast.unparse would give us the modified source, which is not what we want.
        node_source = ast.get_source_segment(source, node)

        return ast.fix_missing_locations(
            # e.g. this transforms the node for `a` into `debug_node('a', a)`
            ast.Call(
                func=ast.Name(id='debug_node', ctx=ast.Load()),
                args=[
                    ast.Constant(value=node_source),
                    node,
                ],
                keywords=[],
            )
        )


tree = ast.parse(source, mode='eval')
tree = DebugTransformer().visit(tree)
code = compile(tree, filename='<ast>', mode='eval')
result = eval(code, dict(**namespace, debug_node=debug_node))
assert result == eval(source, namespace)
```

This prints:

```python
a == 1
b == 2
a + b == 3
c == 4
(a + b) * c == 12
```

This implementation is a bit basic and has some flaws, but you should be able to fully understand how it works and be
able to see how this could be useful. To use something like this in practice, I recommend trying
[`birdseye`](https://github.com/alexmojaki/birdseye) or
[the `pp.deep` function in `snoop`](https://github.com/alexmojaki/snoop/#ppdeep-for-tracing-subexpressions),
which are both Python debugging libraries I've written using similar techniques.

## Why

### The backstory

I first learned how to work with the AST to write [`birdseye`](https://github.com/alexmojaki/birdseye). It uses the
library [`asttokens`](https://github.com/gristlabs/asttokens), so I made many contributions to that library in the
process. This impressed the creator of `asttokens` and led to a job at his company
[Grist](https://www.getgrist.com/). Their product is a spreadsheet/database hybrid with formulas written in a slightly
customized Python. Understanding the nuances of the AST and other Python internals was important here and there.

The AST was also essential to several other projects of mine, including [`executing`](https://github.com/alexmojaki/executing). As mentioned
in my [previous blog post](https://pydantic.dev/articles/inline-snapshot?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline), `executing` powers the
[`inline-snapshot`](https://github.com/15r10nk/inline-snapshot) library and the
[f-string magic feature](https://pydantic.dev/docs/logfire/instrument/add-manual-tracing/?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline#f-strings)
of the Logfire SDK.
But long before that, I used `executing` and `asttokens` to [fix several issues in
`python-devtools`](https://github.com/samuelcolvin/python-devtools/pull/82), a library by [Samuel Colvin](https://pydantic.dev/authors/samuel-colvin?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline), the creator of
Pydantic.

Years later, I was looking for a new job. I saw that Pydantic had formed a [company](https://pydantic.dev/articles/company-announcement?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline), but I had missed the call for
hiring. I decided to try and impress them anyway and started making contributions to the `pydantic` library.
And it worked! When an opening appeared, Samuel reached out to me, because he believed that my deep knowledge of Python
would help make [Logfire](https://pydantic.dev/logfire?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline) the best platform for observability.

One way I'd demonstrated this knowledge was the contribution to `python-devtools` mentioned above.
The other biggest factor was probably creating `eval-type-backport`
[specifically to integrate it into Pydantic](https://github.com/pydantic/pydantic/pull/8209)
and make newer type annotation syntax just magically work in older Python versions.

One of the first things I did in my new job was to reimplement
[Logfire's auto-tracing](https://pydantic.dev/docs/logfire/instrument/add-auto-tracing/?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline) feature
using AST modification (roughly wrapping every function body in `with logfire.span(...):`) and import hooks.
These were tricks I'd used in `birdseye`, and I'll explain more in a future blog post.

As you can see, these are valuable techniques with real applications, especially for impressing employers!

### Why you should learn this

[![Transcription: Why I could never be a math teacher: Student: "Teacher! Will we ever use any of this algebra?" - Teacher: "You won't, but one of the smart kids might." - Credit to SMBC](https://pydantic.dev/assets/blog/eval-type-backport/smbc-algebra.png)](https://www.smbc-comics.com/comic/why-i-couldn39t-be-a-math-teacher)

You may be wondering when you'd ever apply this.
Maybe your work involves writing 'normal' code that works with things like databases and web APIs,
not writing the kind of libraries that Pydantic writes.
But the problem is that there's lots of 'normal' code out there for AI to train on.
It's becoming more important to learn advanced skills that are harder to automate.
I think metaprogramming is a good option.

This stuff is also potentially very useful if you're working with AI agents, regardless of what those agents do.
One of the newest and biggest features of
Pydantic AI is [Code Mode](https://pydantic.dev/docs/ai/harness/code-mode/?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline), which lets agents call tools by running
simple Python code in the [Monty sandbox](https://github.com/pydantic/monty) instead of calling one tool at a time with
JSON. AI is good at writing Python, so [this helps an agent to express itself](https://pydantic.dev/articles/your-agent-would-rather-write-code?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline)
rigorously and efficiently. Being able to
inspect and modify the code it produces is a good way to understand and control it. For example, imagine plugging in the
[`DebugTransformer`](#another-example-printing-every-subexpression) above
so that an agent can analyze every detail of the execution of its code and spot problems.

This isn't just hypothetical. One of Grist's biggest features is
an [AI assistant](https://www.getgrist.com/ai-assistant/) that generates Python spreadsheet formulas. I implemented the
first version of this feature. Back then, it used GPT-3! Getting it to reliably produce good output was hard, and
various post-processing helped a lot. Some of that used the `ast` module.

For a more dramatic example, consider Astral. They were building completely free open source software without clear concrete
plans for how to monetize this. Then [OpenAI announced plans to acquire them](https://openai.com/index/openai-to-acquire-astral/):

> By bringing Astral’s tooling and engineering expertise to OpenAI, we will accelerate our work on Codex and expand what AI can do across the software development lifecycle.

I think this is more about the tooling and expertise than it is about Codex or software development.
All AI agents, not just coding agents,
[can benefit from being able to perform actions by writing code](https://pydantic.dev/articles/pydantic-monty?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline).
It turns a fuzzy unpredictable process that
produces unstructured text into something precise, logical, and controllable. Otherwise, your options for
things like guardrails are very limited. If the output is natural language, then automatically inspecting it
requires either [simple brittle heuristics](https://en.wikipedia.org/wiki/Scunthorpe_problem)
or relying on more AI. Simple structured data like JSON is better, but still has obvious limitations.

You *could* express arbitrary complex logic in JSON. But have you ever used software written entirely in JSON?
No one does that. Therefore, neither does AI. It's much better for it to write that logic in one of the most
popular programming languages so that it has lots of training data and so that people can read its output.
If you wanted the best coding agent, you'd need it to be good at many different languages.
Python is *not* a good choice for a lot of software development. But it's excellent for short and simple scripts.
So it's an ideal output format for agents in a wide range of contexts.
That's why OpenAI thinks good Python tooling is so valuable.

## Future Python metaprogramming blog posts

Inspired by the success of my [previous blog post](https://pydantic.dev/articles/inline-snapshot?utm_source=pydantic.dev&utm_medium=internal&utm_campaign=eval-type-backport&utm_content=inline) about
[`inline-snapshot`](https://github.com/15r10nk/inline-snapshot), I decided to write a series of blog posts about Python
metaprogramming techniques. This is the second post in that series. Future posts will cover things like import hooks,
tracing, frames, and tracebacks. The goal will be for you to learn:

- How Python works at a deeper level,
- How to use advanced techniques to do cool things yourself,
- What libraries you can use that have done the hard work for you, and
- How this fits into real world contexts such as at Pydantic.

Hopefully, I don't end up regretting diluting my competitive advantage.
