mrkeyoor.com_
Thu 06 Aug 07:43 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability2/5Three of the last four minor releases removed public API. 0.25 introduced QueryCursor and deprecated Language.query, 0.26 deleted it along with timeout_micros, and Point changed from a namedtuple to a tuple subclass.
Docs4/5The Sphinx site documents every class, the README covers parsing, cursors, editing, and queries with runnable code, and examples/usage.py exists. Release notes list breaking changes explicitly, which softens the churn.
Maintenance5/5Pushed in August 2026 with 11 open issues (13 issues and PRs), releases roughly quarterly, and the core C library it wraps is bumped continuously by CI. Same organization maintains the grammars.
Ecosystem4/5About 18.4M weekly downloads, official grammar wheels for most mainstream languages, and it is the parsing layer under a lot of code-indexing and retrieval tooling. Grammar quality outside the popular languages varies.

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
Skip it if

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 column

node.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():
        break

TreeCursor 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

PackageRegistryPick it when
libcstPyPIPython-only work where you need a lossless tree that round-trips comments and whitespace for automated refactoring.
parsoPyPIError-tolerant parsing of Python alone, with support for parsing code written for other Python versions.
pygmentsPyPIYou only need tokens for highlighting or rough language detection across many languages and no tree at all.