
Decompile and disassemble Python bytecode (.pyc/.pyo) back to readable source. Supports Python 2.0 - 3.15 (CPython, PyPy and other implementations). Written in Rust.
English | 简体中文
Decompile and disassemble Python bytecode (.pyc) from Python 2.0 through 3.15 back into readable source — written in Rust, three dependencies, no Python required at runtime.
pycdc program.pyc # decompile to stdout
pycdas program.pyc # disassemble (exception tables, line numbers, nested code)
--opcodesHomebrew (macOS & Linux):
brew install ejfkdev/tap/pycdc
cargo, from crates.io:
cargo install pycdc
Prebuilt binaries: raw executables (no archive wrapper) on the Releases page — Linux / Windows x86_64 + arm64, macOS arm64 + x86_64; Linux and Windows builds are UPX-compressed, macOS ships uncompressed.
From source:
cargo build --release # -> target/release/pycdc + pycdas
cargo install --path . # or install both binaries from a checkout
Every method provides both pycdc and pycdas.
# single file -> stdout, a file, or a directory
pycdc program.pyc > program.py
pycdc program.pyc -o program.py
pycdc program.pyc -o ./outdir/
# stdin (pipelines): - reads the pyc from stdin
cat program.pyc | pycdc -
# batch: mirror a directory tree (parallel by default, -j N to control,
# -q suppresses the per-file src -> dst lines)
pycdc ./pyc-corpus -o ./src-out
pycdc ./pyc-corpus # -> ./pyc-corpus-decompiled/
pycdc a.pyc b.pyc # -> a.py, b.py next to the inputs
pycdc ./pyc-corpus -o ./src-out -q -j 8
# raw marshal (no pyc header) with an explicit version
pycdc -v 3.8 payload.marshal
# disassembly — same CLI surface (files, dirs, stdin, -o/-j/-q);
# also reachable as the `pycdc dis` subcommand (identical output)
pycdas program.pyc
pycdc dis program.pyc
pycdas ./pyc-corpus -o ./dis-out # -> mirrored .dis files
pycdc --help, pycdc dis --help and pycdas --help document every
flag. Exit codes: 0 success, 1 a file failed to process, 2 usage
error.
Apple Silicon, 526 real .pyc files (~9.5 MB, Python 2.6–3.14 stdlib):
| Scenario | Time | Peak RSS |
|---|---|---|
| Batch, parallel (default) | ≈0.05 s | ≈25 MB |
Batch, serial (-j 1) | ≈0.34 s | ≈13 MB |
| Largest single file (74 KB, incl. startup) | ≈5.5 ms | ≈4.4 MB |
| Process startup | ≈2 ms | — |
tools/fetch_realworld.sh sparse-checks-out one directory from each of
seven projects at its latest release tag, compiles every .py to
.pyc (compileall -b), and lands the tree in tests/realworld/
(git-ignored — the script is the way to recreate it):
| Project (tag) | modules | serial -j 1 | parallel (default) | recompiles | AST-identical¹ |
|---|---|---|---|---|---|
| yt-dlp (2026.08.19) | 1045 | 0.46 s | 0.08 s | 1045/1045 | 501/1045 |
| matplotlib (v3.11.2) | 253 | 0.33 s | 0.06 s | 253/253 | 60/253 |
| pandas (v3.0.5) | 1420 | 0.90 s | 0.14 s | 1420/1420 | 584/1420 |
| django (6.1.1) | 907 | 0.38 s | 0.10 s | 907/907 | 468/907 |
| sympy (1.14.0) | 1532 | 3.03 s | 1.63 s | 1532/1532 | 479/1532 |
| scikit-learn (1.9.1) | 671 | 0.48 s | 0.07 s | 671/671 | 216/671 |
| ansible (v2.21.4) | 583 | 0.27 s | 0.05 s | 583/583 | 185/583 |
| total | 6411 | 5.85 s | 2.13 s | 6411/6411 (100 %) | 2493/6411 |
¹ strict: docstring-stripped AST equal to the original source —
equivalent normalizations (an if/else instead of a ternary, a redundant
*()/**{} around an unpack, reordered keyword arguments) count as
differences, so treat it as a shape-fidelity floor rather than an error
rate. The parse/recompile column is the acceptance bar.
One pycdc process per project: ~0.9 ms per module serial, peak RSS
≈81 MB for the whole 6.4 k-module batch. All 6411 modules recompile;
the last family (a dict comprehension nested inside a generator
expression's element, call arguments included) was closed by the
community PR batch. Raw data: benchmarks/realworld.json,
harness: tools/bench_realworld.py.
Three independent harnesses, all runnable locally (need pyenv/uv interpreters 2.6–3.15):
| Harness | What it proves | Current |
|---|---|---|
tools/verify_corpus.py | 528 stdlib modules: decompile → recompile with the same interpreter → compare bytecode signatures, fall back to normalized-AST diff | 528/528 semantic (406 signature-exact, 0 syntax errors, 0 placeholders) |
tools/run_behavior.py | 72 behavior cases × 14 interpreters: run original vs decompiled, compare stdout + exit code | 663/663 = 100% (2.6–3.15) |
tests/roundtrip.py | fixture matrix: compile → decompile → recompile → strict bytecode compare | 49/54 strict (exceptions fixtures differ structurally, semantically equivalent) |
The 3.15 result reflects CPython 3.15.0/3.15.1, whose inline-comprehension,
call and import encodings changed; its opcode table is generated from the
live interpreter. All behavior cases pass and pandas+scikit-learn recompile
2091/2091 under 3.15; the remaining with-body-return edge is documented
in docs/LIMITATIONS.md.