skip to content

AST Parsing and Rewriting

Treating code as data: ast.parse builds a tree, NodeVisitor walks it, NodeTransformer rewrites it, and compile() turns the edited tree back into runnable code. Asked of linter and codemod work.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

What is the difference between ast.NodeVisitor and ast.NodeTransformer in Python?

level: middleimportance: must knowfreq 50%

answer

  1. Same dispatch, different return contract
  2. One reads the tree, one replaces nodes
  3. Returning nothing is not harmless
  4. Your visit_ method owns that subtree
  5. generic_visit is what descends

basics

~10 s

ast.NodeVisitor reads a tree: its visit_<NodeType> methods return whatever they like and the tree is untouched. ast.NodeTransformer rewrites it: whatever a visit method returns replaces the node, and returning None deletes it.

solid answer

~40 s

Both dispatch on node type - `visit(node)` looks for a method named `visit_` plus the node's class name, such as `visit_Call`, and falls back to `generic_visit`. The difference is what the return value means. With `ast.NodeVisitor` the return value is ignored by the traversal, so it is for collecting: a linter accumulates findings on `self`. With `ast.NodeTransformer` the return value **replaces the node in its parent**: return the node to keep it, a new node to swap it, a list of statements to splice several in place of one, or `None` to remove it. The trap in both is `generic_visit`: the base `visit_` dispatch does **not** descend automatically, so a `visit_X` method that does not call `self.generic_visit(node)` stops the walk at that node and misses everything nested inside it.

code

python · 22 lines
python
import ast

SOURCE = """
def load(rows):
    cache = list(rows)
    return list(cache)
"""


class CallCounter(ast.NodeVisitor):
    def __init__(self):
        self.calls = []

    def visit_Call(self, node):
        if isinstance(node.func, ast.Name):
            self.calls.append((node.func.id, node.lineno))
        self.generic_visit(node)


counter = CallCounter()
counter.visit(ast.parse(SOURCE))
print(counter.calls)

go deeper

for a junior

Recall which of the two rewrites and which only reads, and that methods are named visit_ plus the node class, such as visit_Call. Knowing ast.NodeTransformer edits and ast.NodeVisitor inspects is the level bar here.

for a middle

Explain the return-value contract of a transformer - node, new node, list, or None to delete - and why self.generic_visit(node) is mandatory when you want to reach nested nodes. Expect to write a small visitor on a whiteboard.

for a senior

Show the failure modes you have hit: silent deletion from a missing return, subtrees skipped by a missing generic_visit, and single-pass traversal not revisiting generated nodes. Argue for separate collect and rewrite passes over a single clever traversal.

for a principal

Weigh whether hand-rolled AST passes belong in your toolchain at all versus an off-the-shelf analyser, and set the standard for testing them - visitors that silently match nothing are the failure mode that survives review.

## One dispatch mechanism, two contracts `ast.NodeVisitor` and `ast.NodeTransformer` share their dispatch. You call `visitor.visit(tree)`; `visit` looks up a method named `visit_` plus the node's class name - `visit_Call`, `visit_FunctionDef`, `visit_Constant` - and calls it. If no such method exists, `generic_visit` runs instead, and `generic_visit` is the part that actually walks into the node's children. `ast.NodeTransformer` subclasses `ast.NodeVisitor`; it overrides `generic_visit` so that the children it walks are also *replaced* by whatever the child visits return. So the mechanism is the same and the **contract on the return value** is what differs. ## NodeVisitor: read-only, accumulate on self With a plain visitor the return value goes nowhere useful - the traversal ignores it. The idiom is therefore to accumulate state on the instance: ```python class CallCounter(ast.NodeVisitor): def __init__(self): self.calls = [] def visit_Call(self, node): if isinstance(node.func, ast.Name): self.calls.append((node.func.id, node.lineno)) self.generic_visit(node) ``` That is the shape of essentially every hand-written linter rule: match a node type, decide, record a finding with its position, keep walking. ## NodeTransformer: the return value is the edit With a transformer, whatever a `visit_X` method returns takes the old node's place in its parent: - **return `node`** - keep it (possibly after mutating its fields in place); - **return a different node** - substitute it; - **return `None`** - delete the node from its parent's list; - **return a list of nodes** - splice them in, but only where the parent field holds a *list of statements*. Returning a list where a single expression is expected produces a broken tree, not a syntax error, and the failure surfaces later at `compile()`. Deletion is the sharp edge: `None` deletes silently, so a transformer that forgets to return the node on its non-matching branch quietly strips code. Make the last line of every `visit_` method an explicit `return node`. ## The generic_visit trap The single most common bug in both classes is forgetting `self.generic_visit(node)`. Once you define `visit_FunctionDef`, *your method owns that subtree*: nothing below it is visited unless you descend yourself. A rule that flags nested calls will silently report only the outermost one; a transformer will rewrite the top of a nested structure and leave the inside untouched. In a transformer, call `self.generic_visit(node)` **before** you build the replacement if you want the children rewritten first (bottom-up), and remember it returns the node with its children already processed. Related: visiting is single-pass, so an edit that creates a node your own visitor would have matched is *not* revisited unless you run the transformer again over the result. Codemods that must reach a fixed point loop until the tree stops changing. ## Choosing between them Use a visitor when the output is information - findings, a symbol table, a call graph, metrics. Use a transformer when the output is a tree you will hand to `compile()` or `ast.unparse()`. Wanting both usually means two passes: collect first, decide, then rewrite, which is easier to reason about than deciding and editing in one traversal. A detail worth carrying: a rewritten tree is not ready to compile. New nodes you construct have no position fields, and `compile()` rejects that with a `TypeError` until you run `ast.fix_missing_locations()` or `ast.copy_location()` over the result. ## Node types are grammar, and grammar changes Because dispatch is by class name, a visitor method whose name no longer matches any node class simply never fires - it does not raise. Every literal has been an `ast.Constant` since Python 3.8, so handlers named for the old per-literal node classes are dead code on a modern interpreter. In the other direction, `visit_Match` is meaningless before 3.10 and essential after it if your rule must see pattern-matching code. Test that a visitor actually fires; a silent zero is indistinguishable from a clean file. ## Testing a pass Because both classes fail silently - a handler that never fires, a subtree never entered, a node quietly deleted - the test for an AST pass is not "it did not crash". Assert on the collected findings for a fixture that contains a nested, an aliased and a negative case, and for a transformer assert on `ast.unparse()` of the result so the shape of the rewrite is visible in the test. A pass that returns zero findings should be indistinguishable in your suite from one that was never called; if it is not, you have no evidence the rule works.

  • What happens if a visit_ method on an ast.NodeTransformer forgets to return the node?
    It returns `None`, and the transformer takes that as "delete this node", removing the statement or expression from its parent. Nothing warns you: the tree is simply smaller. That is why every `visit_` method should end with an explicit `return node`, and why round-tripping the result through `ast.unparse()` in a test catches the mistake early.
  • Why does a rule that flags calls sometimes report only the outermost call in nested code?
    Because the `visit_Call` method never called `self.generic_visit(node)`. Dispatching to a `visit_` method replaces the default traversal of that node, so children are not visited unless the method descends explicitly. The subtree is skipped silently, which reads as a clean file rather than as an error.
  • How would you replace one statement with several using ast.NodeTransformer?
    Return a list of statement nodes from the `visit_` method. The transformer splices them into the parent's body in place of the original. This only works where the parent field is a list of statements - returning a list where a single expression node is expected builds a structurally invalid tree that fails later at `compile()`.

saying these in an interview costs you the question

  • Thinking a visitor can rewrite the tree by returning a node
  • Believing children are visited automatically inside visit_X
  • Forgetting that returning None deletes the node
  • Assuming a transformer re-visits nodes it just created
  • Naming visit_ methods for node classes that no longer exist

context

open as a page

Why does a Python code-checking tool use ast.parse() instead of regular expressions over the source?

level: juniorimportance: should knowfreq 40%

basics

~20 s

ast.parse() turns source text into a tree that mirrors Python's own grammar, so a tool matches real structure - a call, an assignment, a function - instead of characters. Regexes cannot see nesting, strings or comments.

open as a page

Why does compile() reject a tree edited with ast.NodeTransformer until you fix its locations?

level: middleimportance: should knowfreq 32%

basics

~10 s

Nodes you construct yourself have no lineno or col_offset, and compile() requires them on every node, raising TypeError: required field "lineno" missing. ast.fix_missing_locations() copies positions down from each node's parent.

open as a page

Why is an ast.parse() and ast.unparse() round trip a poor basis for a codemod on a real repository?

level: seniorimportance: should knowfreq 28%

basics

~10 s

The tree is an abstraction: comments, blank lines, quote style and line breaks are never in it, so ast.unparse() regenerates the whole file in its own style. Every touched file becomes a total-rewrite diff.

open as a page