Tools that rewrite Python source often need to answer a deceptively simple question: did this transformation preserve the syntax tree that matters?
Before Python 3.14, a common solution was to serialize both trees with ast.dump() and compare the resulting strings. That works in small tests, but it turns a structural question into a formatting contract. Python 3.14 adds ast.compare(), a recursive AST comparison helper that expresses the intent directly.
This is especially useful for formatters, codemods, linters, source generators, refactoring tools, and tests that round-trip Python code.
The important distinction is that structural AST equality is not the same as textual equality, and neither one proves behavioral equivalence.
Start with structural comparison
Parse two snippets and compare their trees:
import ast
left = ast.parse("total = price + tax\n")
right = ast.parse("total=price+tax\n")
assert ast.compare(left, right)The source strings differ, but the parsed syntax has the same structure. That is exactly the kind of distinction a formatter test usually wants.
ast.compare(a, b) recursively compares two ASTs. By default it ignores AST attributes such as source locations.
That makes the API more precise than this older pattern:
assert ast.dump(left) == ast.dump(right)ast.dump() is excellent for inspection and diagnostics. It does not need to be your equality protocol anymore.
Know what counts as structure
Whitespace that does not affect parsing usually disappears from the AST:
import ast
compact = ast.parse("answer=x*2")
spaced = ast.parse("answer = x * 2")
assert ast.compare(compact, spaced)A semantic syntax change does not disappear:
multiply = ast.parse("answer = x * 2")
add = ast.parse("answer = x + 2")
assert not ast.compare(multiply, add)The operator node is part of the tree, so Mult and Add do not compare equal.
Names, constants, argument order, keyword arguments, statement order, contexts such as Load and Store, and other AST fields likewise participate in structural comparison.
This makes ast.compare() a good fit when the contract is about parsed Python structure rather than exact source spelling.
Choose deliberately whether locations matter
AST nodes can carry attributes including line numbers and column offsets. The default comparison ignores those attributes:
import ast
first = ast.parse("value = 42\n")
second = ast.parse("\n\nvalue = 42\n")
assert ast.compare(first, second)The assignment appears at different source locations, but the syntax tree structure is the same.
If location metadata is part of your contract, opt into attribute comparison:
assert not ast.compare(
first,
second,
compare_attributes=True,
)This distinction matters for tools that synthesize or transform nodes. A compiler-oriented transformation may care only that fields are structurally correct. A source-mapping tool may also require accurate lineno, col_offset, end_lineno, and end_col_offset metadata.
Do not turn on attribute comparison merely because it seems stricter. Strictness is useful only when it checks something your tool promises to preserve.
Replace brittle dump-string assertions
Suppose a codemod rewrites deprecated calls:
import ast
class RewriteLegacyCall(ast.NodeTransformer):
def visit_Call(self, node: ast.Call):
self.generic_visit(node)
if isinstance(node.func, ast.Name) and node.func.id == "legacy":
node.func = ast.Name(id="modern", ctx=ast.Load())
return nodeA test can state the expected transformation directly:
source = "result = legacy(item)"
expected = "result = modern(item)"
actual_tree = RewriteLegacyCall().visit(ast.parse(source))
expected_tree = ast.parse(expected)
assert ast.compare(actual_tree, expected_tree)This avoids coupling the assertion to the textual representation produced by ast.dump().
If the assertion fails, ast.dump() remains useful as a diagnostic:
if not ast.compare(actual_tree, expected_tree):
print("actual:")
print(ast.dump(actual_tree, indent=2))
print("expected:")
print(ast.dump(expected_tree, indent=2))Use comparison for the verdict and serialization for human-readable debugging. Those are separate jobs.
Test source round trips without requiring identical formatting
A source transformation often follows this pipeline:
- parse source,
- transform the AST,
- generate source,
- parse the generated source again.
When exact formatting is not the contract, compare the trees at the boundaries:
import ast
def assert_same_syntax(before: str, after: str) -> None:
before_tree = ast.parse(before)
after_tree = ast.parse(after)
if not ast.compare(before_tree, after_tree):
raise AssertionError("generated source changed the syntax tree")This is useful for pretty-printers and source generators whose output may normalize whitespace, parentheses, or layout while preserving the same parsed structure.
There is an important limit: AST equality is still only syntactic structural equality. It does not establish that two different trees have equivalent runtime behavior.
For example:
x = 1 + 2and:
x = 3may produce the same value in this simple context, but their ASTs are intentionally different. ast.compare() should return False because it is not an optimizer-equivalence prover.
Separate structural, textual, and behavioral contracts
Tests become clearer when they identify which layer matters.
For exact source output, compare strings:
assert generated == "value = compute(item)\n"For parsed structure, compare ASTs:
assert ast.compare(ast.parse(generated), ast.parse(expected))For runtime behavior, execute through an appropriate test boundary and assert observable results.
A formatter may need both textual snapshot tests and structural-preservation tests. A codemod may need structural tests plus targeted runtime tests. A static analyzer may never execute the code at all.
Do not use one kind of equality as a substitute for another.
Compare expressions in the correct parse mode
ast.parse() can produce different root node types depending on its mode. If your tool works with expressions, parse both sides consistently:
import ast
left = ast.parse("items[0]", mode="eval")
right = ast.parse("items [ 0 ]", mode="eval")
assert ast.compare(left, right)Comparing a module tree with an expression tree should fail because their root structures differ.
A reusable helper can make the intended mode explicit:
import ast
def expressions_match(left: str, right: str) -> bool:
return ast.compare(
ast.parse(left, mode="eval"),
ast.parse(right, mode="eval"),
)If a project supports multiple parsing modes, include the mode in the test case rather than relying on an implicit default.
Be careful after mutating AST nodes
AST transformers frequently preserve source-location attributes from old nodes or create new nodes without complete location information.
For compilation, ast.fix_missing_locations() can fill missing location metadata from parent nodes where possible:
transformed = RewriteLegacyCall().visit(ast.parse(source))
transformed = ast.fix_missing_locations(transformed)Whether that belongs before comparison depends on the contract.
If you compare only structure, fixing locations may be irrelevant. If you use compare_attributes=True, the timing of location repair becomes observable and should be intentional.
A useful testing pattern is to keep two assertions when both concerns matter:
assert ast.compare(actual, expected)
assert ast.compare(actual, expected, compare_attributes=True)The first failure says the syntax itself differs. The second can isolate a metadata-preservation problem.
In larger suites, separate these into differently named tests so failures explain which guarantee broke.
Do not confuse comments with AST structure
Ordinary comments are not represented as normal nodes in Python’s AST. A comparison can therefore succeed even when comments differ or disappear.
That is acceptable for a tool whose contract is only executable syntax. It is insufficient for a source-to-source refactoring tool that promises to preserve comments.
Similarly, formatting details are mostly outside the AST. If preservation of comments, blank lines, quoting style, or exact token spelling matters, combine AST checks with a concrete-syntax-tree, token, or source-level strategy.
ast.compare() is valuable partly because it has a narrow contract. Keep that contract narrow rather than expecting the AST to represent source information it was never designed to retain.
Use attribute comparison for source mapping tests
Tools that report diagnostics against original source positions often need stronger checks than codemods do.
Consider a helper that clones a parsed tree and expects positions to remain unchanged. In that case:
import ast
import copy
original = ast.parse("result = calculate(value)\n")
cloned = copy.deepcopy(original)
assert ast.compare(
original,
cloned,
compare_attributes=True,
)If a transformation intentionally moves code, exact attribute equality may be the wrong invariant. Instead, test the specific mapping rules your application provides.
The compare_attributes switch should not replace focused assertions about diagnostics. It is a convenient whole-tree equality check, not an explanation of why a particular source span is correct.
Build useful failure messages
A boolean comparison is easy to compose, but a failed boolean alone does not show where trees diverged.
Wrap it when tests need diagnostics:
import ast
def assert_ast_equal(
actual: ast.AST,
expected: ast.AST,
*,
compare_attributes: bool = False,
) -> None:
if ast.compare(
actual,
expected,
compare_attributes=compare_attributes,
):
return
actual_dump = ast.dump(
actual,
indent=2,
include_attributes=compare_attributes,
)
expected_dump = ast.dump(
expected,
indent=2,
include_attributes=compare_attributes,
)
raise AssertionError(
"ASTs differ\n"
f"actual:\n{actual_dump}\n"
f"expected:\n{expected_dump}"
)This keeps the equality rule on ast.compare() while preserving readable output when something breaks.
For very large trees, a full dump can overwhelm CI logs. Consider truncating diagnostics or implementing a separate tree-diff helper. Do not change the equality rule merely to get prettier failures.
Handle Python version compatibility explicitly
ast.compare() was added in Python 3.14. A library that still supports older interpreters cannot call it unconditionally.
If comparison is only used in tests, one option is to run those tests on Python 3.14 or newer. If it is part of runtime library behavior, isolate the compatibility layer:
import ast
def ast_equal(left: ast.AST, right: ast.AST) -> bool:
compare = getattr(ast, "compare", None)
if compare is not None:
return compare(left, right)
return ast.dump(left) == ast.dump(right)That fallback approximates the default structural comparison for many ordinary trees, but it should be treated as a compatibility implementation rather than proof that serialized dumps are a permanent public equality format.
If location attributes matter on older Python versions, make that policy explicit:
return ast.dump(
left,
include_attributes=True,
) == ast.dump(
right,
include_attributes=True,
)Pinning the minimum Python version is often cleaner than carrying a fallback indefinitely, especially for internal tooling.
Account for grammar differences across Python versions
The Python AST evolves with the language grammar. New syntax introduces new nodes or fields, and representation details can change between Python releases.
That means cross-version AST snapshots are fragile even when each interpreter is behaving correctly.
Prefer parsing and comparing both values under the same interpreter version:
expected = ast.parse(expected_source)
actual = ast.parse(actual_source)
assert ast.compare(actual, expected)For a tool that intentionally supports multiple Python grammars, run version-specific tests in each supported interpreter. Do not assume that an AST serialized on one Python release is a stable golden representation for another.
This is another reason to keep source fixtures when practical. Source communicates intent more naturally than a large serialized AST and can be parsed using the interpreter under test.
Test transformations with negative cases too
A comparison helper is most useful when tests demonstrate both what should be considered equal and what must remain different.
import ast
def tree(source: str) -> ast.AST:
return ast.parse(source)
def test_whitespace_is_irrelevant():
assert ast.compare(
tree("result=x+1"),
tree("result = x + 1"),
)
def test_operator_change_is_detected():
assert not ast.compare(
tree("result = x + 1"),
tree("result = x - 1"),
)
def test_statement_order_is_detected():
assert not ast.compare(
tree("a = 1\nb = 2\n"),
tree("b = 2\na = 1\n"),
)Negative cases protect the test helper itself from becoming too permissive. This is particularly important when a project later adds normalization before comparison.
Normalize only what the contract permits
Sometimes two trees differ in a way your application intentionally treats as irrelevant. It can be tempting to normalize aggressively until they compare equal.
Do that only with a written invariant.
For example, a domain-specific analyzer might ignore docstrings before comparison. That is a project-specific rule, not a property of ast.compare().
A safe pattern is:
normalized_actual = normalize_for_project(actual)
normalized_expected = normalize_for_project(expected)
assert ast.compare(normalized_actual, normalized_expected)Keeping normalization separate makes it visible in code review and test failures. It also avoids turning a general-purpose equality helper into an undocumented collection of exceptions.
Prefer AST fixtures that express intent
For many tests, expected Python source is easier to maintain than manually constructing a large tree:
expected = ast.parse(
"""
result = modern(item)
"""
)Manual AST construction remains useful when the test targets a specific node or metadata edge case:
expected_name = ast.Name(id="value", ctx=ast.Load())
actual_name = ast.Name(id="value", ctx=ast.Load())
assert ast.compare(actual_name, expected_name)Choose the representation that makes the invariant easiest to see.
A practical testing checklist
When introducing ast.compare() into a codebase, decide these points explicitly:
- Is the contract exact source text, AST structure, source metadata, runtime behavior, or a combination?
- Should line and column attributes participate in equality?
- Are comments or formatting details required to survive the transformation?
- Are both trees parsed using the same mode and Python version?
- Does the test include negative cases that prove meaningful changes are detected?
- Will a failed comparison produce enough diagnostic information in CI?
- Does the project support Python versions before 3.14, and if so, where does compatibility live?
These questions matter more than the one-line API call. The value of ast.compare() is that it lets tests state structural equality directly, leaving the rest of the policy visible around it.
Conclusion
Python 3.14’s ast.compare() gives AST-based tools a standard recursive equality operation. By default it compares tree structure while ignoring source-location attributes; compare_attributes=True makes those attributes part of the comparison when location fidelity is itself an invariant.
Use it to replace dump-string equality where the real contract is structural syntax. Keep ast.dump() for diagnostics, source comparisons for formatting contracts, and runtime tests for behavioral guarantees.
That separation produces tests that are less brittle and more precise: they fail when the syntax contract changes, rather than when an incidental representation changes.