mrkeyoor.com_
Thu 06 Aug 15:39 UTC
PyPIUtilsupdated 06 Aug 2026

libcst

LibCST parses Python source into a tree that still contains every comment, blank line, trailing comma and set of redundant parentheses. Python's built-in ast module throws all of that away, which is fine for analysis and useless for rewriting: unparse an ast and you get back reformatted code with the comments gone. LibCST keeps the formatting attached to the nodes, so parse_module(src).code returns the original file byte for byte, and a tree you edited returns the original file with only your edit in it. On top of that it adds a matcher language for describing node shapes without ten lines of isinstance checks, metadata providers for positions, scopes and qualified names, and a codemod framework with a CLI that runs your transform across a directory in parallel. It came out of Instagram, where it drives automated refactors across a very large Python codebase, and the parser underneath is written in Rust.

Verdict

For rewriting Python source without destroying its formatting, LibCST is the serious option and the metadata providers are what put it ahead of everything else. Reach for it only after checking that ast or ruff will not do, because the node model costs real time to learn.

API stability4/5Core names such as parse_module, CSTTransformer, with_changes, matchers and the metadata providers have not moved through the 1.x line, and new Python grammar arrives as new node types rather than reshaped ones. Point releases do occasionally tighten node validation, which surfaces as a codemod that used to build a slightly malformed tree suddenly raising.
Docs4/5libcst.readthedocs.io has a tutorial, a runnable Binder notebook, and full node-by-node API reference plus separate chapters on matchers, metadata and the codemod framework. Where it thins out is the middle: the gap between the tutorial and writing a real transform with scope analysis is filled mostly by reading the source of the bundled visitors.
Maintenance4/51.9.0 released 29 July 2026 with the repo pushed the same day and support already declared for Python 3.15 and free-threaded builds. Meta backs it, releases are regular, and 131 of the 173 open GitHub items are issues rather than PRs. It is maintained rather than actively expanded: the README still lists whole-repository fact providers as future work.
Ecosystem4/5Used as the rewriting engine by Fixit, monkeytype's annotation application, Bowler's successors and a long tail of internal codemod suites, and the ApplyTypeAnnotationsVisitor is how several type-inference tools write annotations back to source. Smaller community than the linter ecosystem around it, so most matchers you need you will write yourself.

Use it if

  • You are writing a codemod that has to land in a real repository: renaming an API, adding a required argument, migrating a decorator, where a diff full of reformatted untouched lines would be rejected in review
  • You need to know what a name refers to, not just what it is called: ScopeProvider and QualifiedNameProvider resolve assignments, imports and accesses so you can rewrite requests.get without also rewriting mymodule.requests.get
  • You want to match structure declaratively, for example every call to a function inside a with block that has a specific decorator, instead of hand-rolling a visitor with nested type checks
  • You are building a linter with autofix and need the fix to be a precise source edit rather than a whole-file reprint
Skip it if

Setup reality

pip install libcst pulls prebuilt wheels for CPython 3.9 through 3.15 on Linux, macOS and Windows across x86_64 and arm64, plus free-threaded builds, so most people never see the native side. If your platform is not covered, the sdist compiles a Rust extension and you need a current toolchain from rustup on the machine, which is the failure mode people hit inside slim Alpine images and on unusual architectures. The only Python dependency is PyYAML, needed by the codemod config loader, plus typing-extensions below 3.10. The real setup cost is the codemod runner: python -m libcst.tool initialize . writes a .libcst.codemod.yaml that lists the module paths it will search for commands, and until that file points at your package, python -m libcst.tool list shows nothing and the runner cannot find your class. Also note that parse_module infers the file's default indentation and newline style from the source, so a tree built from scratch with cst.Module([]) uses four spaces and \n regardless of what the surrounding project does.

Patterns

Prove the tree is lossless before you trust itparse-and-roundtrip

import libcst as cst

source = open("app/service.py", encoding="utf-8").read()
module = cst.parse_module(source)

assert module.code == source   # comments, blank lines, trailing commas intact
print(repr(module.default_indent), repr(module.default_newline))

This assert is the whole reason to use LibCST over ast, and it is worth keeping in your test suite. parse_module infers default_indent and default_newline from the file, so a tree you build from scratch with cst.Module([]) will not match a CRLF, tab-indented project.

See what the nodes are actually calledinspect-tree-cli

python -m libcst.tool print app/service.py

# or in a REPL, on a fragment
python - <<'PY'
import libcst as cst
from libcst.tool import dump
print(dump(cst.parse_expression("foo(bar, baz=1)")))
PY

You will use this constantly. The default dump hides whitespace nodes for readability, which is a trap when the thing you are debugging is whitespace; pass show_whitespace=True to dump() to see the SimpleWhitespace and comma nodes you actually have to construct.

Read the tree without changing itcollect-with-visitor

import libcst as cst

class FindTests(cst.CSTVisitor):
    def __init__(self):
        self.names: list[str] = []

    def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
        if node.name.value.startswith("test_"):
            self.names.append(node.name.value)
        return False   # do not descend into the body

v = FindTests()
cst.parse_module(source).visit(v)
print(v.names)

Returning False from a visit_ method skips the entire subtree, which is both an optimisation and a correctness tool for avoiding nested definitions. Returning None means descend. A CSTVisitor cannot modify anything; use CSTTransformer for that.

Change a node and print the file backrewrite-with-transformer

import libcst as cst

class RenameParam(cst.CSTTransformer):
    def leave_Param(self, original: cst.Param, updated: cst.Param) -> cst.Param:
        if updated.name.value == "timeout_secs":
            return updated.with_changes(name=cst.Name("timeout"))
        return updated

module = cst.parse_module(source)
new_module = module.visit(RenameParam())
if new_module.code != source:
    open(path, "w", encoding="utf-8").write(new_module.code)

Nodes are immutable, so with_changes returns a copy and there is no in-place edit. Always operate on updated, not original: original is the node as parsed, updated already contains changes your visitor made to its children, and mixing them silently discards nested edits.

Delete a statement, or replace one with severalremove-and-insert-statements

import libcst as cst

class Rewrite(cst.CSTTransformer):
    def leave_SimpleStatementLine(self, original, updated):
        code = cst.Module([]).code_for_node(updated).strip()

        if code == "import six":
            return cst.RemoveFromParent()          # drop this line

        if code == "setup()":
            return cst.FlattenSentinel([           # one line becomes two
                cst.parse_statement("configure()"),
                cst.parse_statement("setup()"),
            ])
        return updated

Three different return values do three different things: a node replaces, RemoveFromParent() deletes, FlattenSentinel splices a sequence in place. Removing the only statement in a body produces an invalid tree, so check for that and substitute a Pass. code_for_node needs a Module to know the indent and newline conventions, hence the empty cst.Module([]).

Construct new code without assembling nodes by handbuild-nodes-by-parsing

import libcst as cst

expr = cst.parse_expression("json.dumps(payload, default=str)")
stmt = cst.parse_statement("logger.info('done')")
block = cst.parse_module(
    "if x is None:\n    raise ValueError('x')\n"
).body[0]

Hand-building a Call means also building each Arg with its Comma and whitespace, which is where most first codemods go wrong. Parsing a string and grafting the result is almost always correct and far shorter. parse_statement returns a statement line, parse_expression returns a bare expression, and mixing the two up gives a confusing type error deep inside validation.

Describe the node you want instead of type-checking itmatch-node-shapes

import libcst as cst
import libcst.matchers as m

call = m.Call(
    func=m.Attribute(value=m.Name("requests"), attr=m.Name("get")),
    args=[m.ZeroOrMore(), m.Arg(keyword=m.Name("verify")), m.ZeroOrMore()],
)

for node in m.findall(module, call):
    print("requests.get with verify= at", node)

ZeroOrMore is what lets you say 'a verify= keyword somewhere in the argument list' without caring about position. Matchers work on syntax only: this also matches a local variable you happened to name requests, which is what QualifiedNameProvider is for.

Only transform inside a specific contextmatcher-decorators

import libcst as cst
import libcst.matchers as m

class DropAsserts(m.MatcherDecoratableTransformer):
    @m.call_if_inside(m.FunctionDef(name=m.Name(value=m.MatchIfTrue(lambda n: n.startswith("test_")))))
    @m.leave(m.Assert())
    def _drop(self, original: cst.Assert, updated: cst.Assert):
        return cst.RemoveFromParent()

MatcherDecoratableTransformer is a different base class from CSTTransformer; the decorators do nothing on the plain one. call_if_inside is evaluated against the ancestor chain during the visit, so it is cheaper and clearer than tracking a self._in_test flag by hand.

Report line and column numbersread-positions

import libcst as cst
from libcst.metadata import MetadataWrapper, PositionProvider

class Report(cst.CSTVisitor):
    METADATA_DEPENDENCIES = (PositionProvider,)

    def visit_Call(self, node: cst.Call) -> None:
        pos = self.get_metadata(PositionProvider, node)
        print(f"{pos.start.line}:{pos.start.column} call")

wrapper = MetadataWrapper(cst.parse_module(source))
wrapper.visit(Report())

get_metadata raises KeyError unless the provider is declared in METADATA_DEPENDENCIES, and it only works when the visit went through MetadataWrapper rather than module.visit(). MetadataWrapper deep-copies the module by default; pass unsafe_skip_copy=True if you already own the tree and the copy is showing up in profiles.

Find imports nobody usesresolve-scopes

from libcst.metadata import MetadataWrapper, ScopeProvider
import libcst as cst

wrapper = MetadataWrapper(cst.parse_module(source))
scopes = set(wrapper.resolve(ScopeProvider).values())

unused = []
for scope in scopes:
    for assignment in scope.assignments:
        node = assignment.node
        if isinstance(node, (cst.Import, cst.ImportFrom)) and not assignment.references:
            unused.append(assignment.name)
print(unused)

This is the analysis that separates a real codemod from find-and-replace. Beware the honest limits: names used only inside string annotations, __all__, or reached through globals() do not create references, so deleting on this signal alone will break code. LibCST ships GatherUnusedImportsVisitor which already handles several of those cases.

Package a transform so the CLI can run itcodemod-command

# mypkg/codemods/use_pathlib.py
import libcst as cst
from libcst.codemod import VisitorBasedCodemodCommand, CodemodContext
from libcst.codemod.visitors import AddImportsVisitor, RemoveImportsVisitor

class UsePathlib(VisitorBasedCodemodCommand):
    DESCRIPTION = "Replace os.path.join(a, b) with Path(a) / b"

    def __init__(self, context: CodemodContext) -> None:
        super().__init__(context)

    def leave_Call(self, original: cst.Call, updated: cst.Call) -> cst.BaseExpression:
        if cst.Module([]).code_for_node(updated.func) != "os.path.join":
            return updated
        AddImportsVisitor.add_needed_import(self.context, "pathlib", "Path")
        RemoveImportsVisitor.remove_unused_import(self.context, "os.path")
        head, *rest = [a.value for a in updated.args]
        expr = cst.Call(func=cst.Name("Path"), args=[cst.Arg(head)])
        for part in rest:
            expr = cst.BinaryOperation(left=expr, operator=cst.Divide(), right=part)
        return expr

add_needed_import records the import on the shared context and a post-pass inserts it in the right place, deduplicated, so you never hand-edit the import block. Run it with python -m libcst.tool initialize . once, then python -m libcst.tool codemod mypkg.codemods.use_pathlib.UsePathlib src/, which parallelises across files and prints a summary of successes, failures and skips.

Assert before and after source in a unit testtest-a-codemod

from libcst.codemod import CodemodTest
from mypkg.codemods.use_pathlib import UsePathlib

class TestUsePathlib(CodemodTest):
    TRANSFORM = UsePathlib

    def test_two_args(self) -> None:
        before = "import os.path\np = os.path.join(root, name)\n"
        after = "from pathlib import Path\np = Path(root) / name\n"
        self.assertCodemod(before, after)

    def test_leaves_other_calls_alone(self) -> None:
        src = "p = shutil.copy(a, b)  # keep this comment\n"
        self.assertCodemod(src, src)

assertCodemod compares exact source text, which is what you want: a passing test proves the comment and the blank lines survived. Include at least one no-op case, because the most common codemod bug is matching too broadly rather than transforming incorrectly.

Alternatives

PackageRegistryPick it when
ruffPyPIThe rewrite you want is a common one, since hundreds of fixes already ship and run far faster than any codemod you would write
parsoPyPIYou need to parse code that is currently invalid, such as a file being typed in an editor, and recover a partial tree
tree-sitterPyPIYou have to handle several languages with one incremental parser and can live without Python-shaped node names
ropePyPIYou want ready-made refactorings such as rename, extract method and move symbol rather than building the transform yourself