tree-sitter
This package is the Python binding for Tree-sitter, the C parsing library behind syntax highlighting and code navigation in editors like Neovim, Zed, and GitHub's code view. You feed it source code as bytes and it hands back a concrete syntax tree you can walk, index by field name, or search with a small pattern language called tree queries. Two properties make it useful where a regular compiler front end is not: it recovers from syntax errors instead of giving up, so a half-typed file still produces a usable tree, and it re-parses incrementally, so applying an edit and parsing again touches only the changed region. The package itself contains no grammars. Each language you want to parse is a separate pip install, like tree-sitter-python or tree-sitter-rust.
For multi-language, error-tolerant, incremental parsing there is nothing else in Python at this level, and the wheels make it painless to install. Accept that you are signing up to pin and bump a grammar package per language, and to rewrite query code every couple of minor releases.
Use it if
- You need to analyze code in several languages with one API, for example an indexer, a code search tool, or a chunker that splits repositories into function-sized pieces for a retrieval pipeline
- You are parsing files that may be broken or half-written, such as editor buffers or diffs, and you still need structure out of them; the tree marks ERROR and MISSING nodes rather than raising
- You re-parse the same file over and over as it changes and the cost matters; Tree.edit plus passing the old tree back into Parser.parse re-uses everything that did not move
- You want to express what you are looking for as a pattern rather than a visitor, for instance capturing every function name and its body in a handful of lines of query syntax
- Python is the only language you parse. The stdlib ast module is already there, and libcst keeps comments and formatting; both give you Python-shaped nodes instead of a generic grammar tree, and neither makes you manage grammar packages
- You expect semantic information. There is no name resolution, no type inference, no cross-file linking, no import following. It is syntax, and everything above that you build yourself
- You dislike version churn. Despite living at 0.x, minor releases delete public API: 0.23 removed Node.sexp() and Parser.set_language(), 0.25 moved query execution to a new QueryCursor class, and 0.26 removed Language.query(), Language.version, and both timeout_micros properties
- You cannot pin a matrix of packages. Every grammar is versioned independently of the binding, and a grammar wheel built against a different ABI fails at Language() construction; upgrading the core usually means bumping every tree-sitter-* package with it
- Your data is text, not code. Byte offsets everywhere are UTF-8 byte offsets, so mapping nodes back to Python string indices means encoding and decoding at every boundary
Setup reality
pip install tree-sitter pulls a pre-compiled wheel for every major platform and has no library dependencies, so there is no C toolchain step for the binding itself. The work starts after that. You install a grammar package per language, and the versions have to line up: a tree-sitter-python built for an older ABI raises at Language() with a version mismatch, and the fix is finding the grammar release that matches your binding rather than anything you can configure. Python 3.10 or newer is required as of 0.26. If you are following a tutorial written before mid-2025 it will call language.query(...) and iterate captures as a list of tuples; both of those are gone, so budget time for rewriting query code against Query plus QueryCursor and the dict-shaped captures result.
Patterns
Install a grammar and parse a fileload-language-and-parse
# pip install tree-sitter tree-sitter-python
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
PY = Language(tspython.language())
parser = Parser(PY)
source = open("app.py", "rb").read() # bytes, not str
tree = parser.parse(source)
print(tree.root_node.type) # "module"parse() takes bytes. Passing a str raises a TypeError. If Language() itself raises, the grammar wheel was built against a different ABI than your tree-sitter version.
Reach a child by field name instead of indexnavigate-by-field
func = tree.root_node.children[0]
assert func.type == "function_definition"
name = func.child_by_field_name("name")
body = func.child_by_field_name("body")
params = func.child_by_field_name("parameters")
print(name.text.decode())Field names are stable across grammar updates; child indexes are not, because punctuation and comments occupy slots too. child_by_field_name returns None when the field is absent, so guard before dereferencing.
Get the source text a node coversextract-node-text
snippet = node.text.decode("utf8")
# equivalent, if you kept the buffer around
snippet = source[node.start_byte:node.end_byte].decode("utf8")
row, col = node.start_point # 0-indexed line, UTF-8 columnnode.text is bytes or None. Offsets count UTF-8 bytes, so a file with emoji or accented characters will not line up with Python str indexes; slice the original bytes and decode at the end.
Traverse every node without building listswalk-with-cursor
cursor = tree.walk()
visited_children = False
while True:
if not visited_children:
yield cursor.node
if not cursor.goto_first_child():
visited_children = True
elif cursor.goto_next_sibling():
visited_children = False
elif not cursor.goto_parent():
breakTreeCursor is far cheaper than reading .children recursively, which allocates a new list of Node objects at every level. The cursor cannot climb above the node it started from.
Find nodes with a tree queryquery-captures
from tree_sitter import Query, QueryCursor
query = Query(PY, """
(function_definition
name: (identifier) @func.name
body: (block) @func.body)
""")
captures = QueryCursor(query).captures(tree.root_node)
for node in captures.get("func.name", []):
print(node.text.decode())This is the 0.25+ shape. Language.query() was deprecated in 0.25 and removed in 0.26, and captures now returns a dict of capture name to node list rather than a list of tuples.
Keep captures grouped per matchquery-matches
from tree_sitter import Query, QueryCursor
cursor = QueryCursor(query)
for pattern_index, caps in cursor.matches(tree.root_node):
name = caps["func.name"][0]
body = caps["func.body"][0]
print(name.text.decode(), body.start_point)Use matches when captures in one pattern relate to each other. captures() flattens everything and loses which name went with which body.
Filter query results with a custom predicatequery-predicates
def only_dunder(predicate, args, pattern_index, captures):
if predicate != "is-dunder?":
return True
node = captures[args[0][0]][0]
return node.text.decode().startswith("__")
caps = QueryCursor(query).captures(tree.root_node, only_dunder)The built-in #eq? and #match? assertions are handled for you; anything else you write as a callable with this signature. Return True to keep the match.
Run a query over part of a file onlylimit-query-range
cursor = QueryCursor(query)
cursor.set_byte_range(start_byte, end_byte)
caps = cursor.captures(tree.root_node)
# or by line/column
cursor.set_point_range((10, 0), (40, 0))Useful for editor features that only care about the visible viewport. Ranges are sticky on the cursor, so create a fresh QueryCursor or reset the range before the next full-file run.
Tell broken code from good codedetect-syntax-errors
tree = parser.parse(b"def foo(:\n pass\n")
if tree.root_node.has_error:
for node in walk(tree): # cursor walk from above
if node.is_error:
print("unparsable at", node.start_point)
elif node.is_missing:
print("expected", node.type, "at", node.start_point)Parsing never raises on bad syntax. has_error is the only signal, and it is worth checking before you trust query results, since error recovery can attach nodes in surprising places.
Re-parse after an edit without starting overincremental-reparse
new_src = src[:5] + b"XY" + src[5:]
tree.edit(
start_byte=5, old_end_byte=5, new_end_byte=7,
start_point=(0, 5), old_end_point=(0, 5), new_end_point=(0, 7),
)
new_tree = parser.parse(new_src, tree)
for r in tree.changed_ranges(new_tree):
print(r.start_point, r.end_point)You must call tree.edit with byte and point coordinates that match the edit exactly, before re-parsing. Get them wrong and the new tree is silently corrupt rather than an error. changed_ranges is called on the old tree.
Bail out of a query that runs too longcancel-long-query
deadline = time.monotonic() + 0.5
def should_stop(offset: int) -> bool:
return time.monotonic() > deadline
caps = QueryCursor(query).captures(tree.root_node, None, should_stop)QueryCursor.timeout_micros was removed in 0.26 in favour of this callback; return True to cancel. Parser.parse takes a progress_callback too, but only on the read-callable overload, not when you hand it bytes.
Reuse one parser across languagesparse-multiple-languages
import tree_sitter_javascript as tsjs
import tree_sitter_python as tspy
from tree_sitter import Language, Parser
LANGS = {".py": Language(tspy.language()), ".js": Language(tsjs.language())}
parser = Parser()
for path, data in files:
lang = LANGS.get(Path(path).suffix)
if lang is None:
continue
parser.language = lang
tree = parser.parse(data)Building a Language is the expensive part, so cache them. Switching parser.language discards any incremental state, so keep one parser per file if you rely on re-parsing with an old tree.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| libcst | PyPI | Python-only work where you need a lossless tree that round-trips comments and whitespace for automated refactoring. |
| parso | PyPI | Error-tolerant parsing of Python alone, with support for parsing code written for other Python versions. |
| pygments | PyPI | You only need tokens for highlighting or rough language detection across many languages and no tree at all. |