libcst review
LibCST 1.9.0 parses Python into an immutable concrete syntax tree that keeps comments, spaces, commas, parentheses, and newline choices. A transformer can therefore replace one call or import without reformatting untouched code. Visitors inspect nodes, matchers describe shapes, and metadata providers attach positions, scopes, or qualified names. Version 1.9.0 adds Python 3.15 grammar support and codemod import helpers, fixes Python 3.14 configuration recognition, and accepts the legal trailing comma after a class-pattern double-star capture.
LibCST 1.9.0 installed in 0.4 seconds and occupied 13 MB across two packages in our sandbox, with zero audit findings. Install it for source-preserving Python rewrites; use ast or an existing Ruff fix when exact formatting is not part of the requirement.
We installed it
| Install | ✓ · 0.4s | 2 packages on disk · 13 MB |
| Import | ✓ | import libcst in 0.62s · compiled extensions · py.typed · requires Python >=3.9 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does libcst install cleanly?
Yes. In a fresh container with an empty cache, pip install libcst finished in 0.4s, leaving 2 packages and 13 MB on disk. pip-audit reported no known vulnerabilities.
What does libcst need to run?
Python >=3.9, and a platform wheel with compiled extensions. In our run import libcst succeeded in 0.62s, and the package ships py.typed for type checkers.
libcst or bowler: which should you use?
bowler: Use it for an older query-oriented codemod interface built around Python source transformations. LibCST 1.9.0 installed in 0.4 seconds and occupied 13 MB across two packages in our sandbox, with zero audit findings.
When should you not use libcst?
The task only reads valid syntax. Python's ast module is smaller and avoids carrying formatting nodes.
Use it if
- A repository-wide Python edit must leave all unrelated formatting and comments byte-for-byte familiar.
- An autofix needs source positions, scope assignments, or qualified names before changing a syntax node.
- A tested transform should run over many files through the bundled codemod command.
- New Python grammar must parse while the output remains ordinary valid Python source.
- The task only reads valid syntax. Python's ast module is smaller and avoids carrying formatting nodes.
- Ruff already ships the desired fix. A maintained lint rule costs less than a custom transformer and fixture suite.
- Editor buffers may contain incomplete code. LibCST expects valid syntax, while Parso is designed for error recovery.
- One parser must cover several languages. LibCST models Python only; Tree-sitter is a better cross-language base.
- The target platform has no compatible wheel and cannot compile Rust. Source installation needs a recent Rust toolchain.
Setup reality
We installed LibCST 1.9.0 in an unprivileged Python 3.12 Bookworm sandbox in 0.4 seconds. Two packages used 13 MB on disk. Our package inspection found four direct dependencies, compiled .so files, and a py.typed marker. import libcst succeeded in 0.62 seconds. pip-audit reported zero known vulnerabilities. The declared Python floor is 3.9.
Common Linux, macOS, and Windows targets receive wheels. A less common platform falls back to building the native parser and therefore needs current Rust tooling. Licensing is file-specific: most contributions use MIT, derived parser and tokenizer files retain PSF terms, and libcst/_add_slots.py uses Apache 2.0. Keep all notices in a license inventory.
No account or secret is involved. Repository codemods need importable command modules and may use .libcst.codemod.yaml for search paths. Parse the original module before constructing edits so its indentation and newline defaults are available. Nodes never mutate; leave methods must return updated_node or a changed copy. Returning original_node can erase modifications that child visits already produced.
MetadataWrapper copies a module by default and each provider adds whole-tree work. Declare only position, scope, qualified-name, or other providers the transform actually reads. Parallel codemod workers process files separately, so cross-file facts belong in an earlier analysis step. Syntax preservation does not resolve dynamic getattr calls, generated code, strings, or runtime import tricks; conservative matches and exact-output fixtures still decide whether a migration is safe.
Patterns
Prove a no-op parse preserves the file roundtrip-source
source = Path('service.py').read_text(encoding='utf-8')
module = cst.parse_module(source)
assert module.code == sourceKeep this assertion beside transform fixtures so input decoding or parse setup cannot silently rewrite an untouched file.
Collect function definitions visit-functions
class Functions(cst.CSTVisitor):
def __init__(self): self.names = []
def visit_FunctionDef(self, node): self.names.append(node.name.value)
collector = Functions()
module.visit(collector)CSTVisitor reads only. Return false from a visit method when traversal should skip that node's children.
Change one name through a transformer rename-name
class Rename(cst.CSTTransformer):
def leave_Name(self, original_node, updated_node):
return updated_node.with_changes(value='new_api') if updated_node.value == 'old_api' else updated_node
changed = module.visit(Rename())Build on updated_node because it already contains changes returned from visited children.
Remove a matched import statement remove-statement
class Drop(cst.CSTTransformer):
def leave_Import(self, original_node, updated_node):
if m.matches(updated_node, m.Import(names=[m.ImportAlias(name=m.Name('six'))])):
return cst.RemoveFromParent()
return updated_nodeRemoving the last statement from an indented suite can make invalid Python; replace it with Pass when needed.
Find a call with one named argument match-call-shape
pattern = 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 call in m.findall(module, pattern): print(call)This is a syntax match. A local object called requests also qualifies until metadata proves its imported name.
Resolve source lines and columns read-positions
class Calls(cst.CSTVisitor):
METADATA_DEPENDENCIES = (PositionProvider,)
def visit_Call(self, node):
pos = self.get_metadata(PositionProvider, node)
print(pos.start.line, pos.start.column)
MetadataWrapper(module).visit(Calls())Metadata access requires visiting through MetadataWrapper and declaring every provider dependency.
Queue import edits in a codemod manage-imports
class Modernize(CodemodCommand):
def transform_module_impl(self, tree):
self.add_needed_import('pathlib', 'Path')
self.remove_unused_import('os.path')
return treeVersion 1.9 command helpers defer placement and duplicate handling to the codemod post-processing pass.
Assert the exact rewritten source test-transform
class ReplaceTests(CodemodTest):
TRANSFORM = ReplaceOldCall
def test_comment_survives(self):
self.assertCodemod('value = old_api(x) # keep\n', 'value = new_api(x) # keep\n')Add no-op and lookalike cases beside the successful edit; exact comparison protects comments and spacing.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| bowler | PyPI | Use it for an older query-oriented codemod interface built around Python source transformations. |
| redbaron | PyPI | Use it when a mutable full-syntax-tree API is preferable to LibCST's immutable node model. |
| astor | PyPI | Use it for AST-to-source work where preserving the original formatting is unnecessary. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

