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.
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.
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
- You only need to read the code, never write it. The stdlib ast is already installed, parses faster, and has a flatter node model; every LibCST tutorial that starts with 'find all the function names' is a job ast does in ten lines
- You want speed on a big tree. Preserving whitespace means many more nodes than an ast, and the pure-Python visitor layer walks all of them; parsing and transforming a large repository is minutes, not seconds. The Rust parser helps with parsing, not with your visitor
- The refactor you want already exists. ruff has hundreds of autofixable rules and pyupgrade-style rewrites built in and runs orders of magnitude faster; writing a LibCST codemod to do something ruff --fix already does is a bad trade
- The learning curve is a problem for your team. Node types mirror the grammar closely, so a function call is a Call with args of Arg, each Arg carrying its own comma and whitespace, and you will spend your first day discovering that adding an argument means also constructing the comma. MaybeSentinel, RemovalSentinel and FlattenSentinel are three separate concepts you must learn before you can delete a statement cleanly
- You need to parse code that does not parse. LibCST wants syntactically valid Python; for editor tooling over half-typed files you want an error-tolerant parser like parso instead
- You need more than Python. LibCST is Python-only by design, so a polyglot codebase means a second tool anyway
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)")))
PYYou 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 updatedThree 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 expradd_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
| Package | Registry | Pick it when |
|---|---|---|
| ruff | PyPI | The rewrite you want is a common one, since hundreds of fixes already ship and run far faster than any codemod you would write |
| parso | PyPI | You need to parse code that is currently invalid, such as a file being typed in an editor, and recover a partial tree |
| tree-sitter | PyPI | You have to handle several languages with one incremental parser and can live without Python-shaped node names |
| rope | PyPI | You want ready-made refactorings such as rename, extract method and move symbol rather than building the transform yourself |