Python .pyc Bytecode Inspector
A .pyc is the compiled form CPython writes beside your source in __pycache__, and it has exactly two parts. The first sixteen bytes are a header: a four-byte magic number naming the precise CPython release that wrote the file — not the Python you happen to have installed, the one that compiled it — followed by a flags word. Bit 0 of that word decides how the cache is invalidated. Cleared, the next eight bytes hold the source file’s modification time and its size, and the import system throws the cache away the moment either stops matching the .py on disk. Set, as PEP 552 allows, those eight bytes hold a hash of the source instead, and bit 1 says whether the runtime rechecks it on every import or trusts it blindly, which is what a reproducible build wants. Behind the header sits a single marshalled code object, and every function, lambda, comprehension and class body in the module is nested inside its constants as another code object — which is why the tree here runs several levels deep. For each one you get the names it can reach, the local, cell and free variables, the argument counts, the stack size, the decoded flag bits, every constant rendered the way repr() would render it, and a disassembly with EXTENDED_ARG folded into the instruction it belongs to and every jump resolved to an absolute offset. Opcode numbering is version-specific and shifts almost every release, so the table is picked from the magic number and named on screen: 3.7 through 3.13 are covered, and a file from anything else has its header read and its body left alone rather than decoded against the wrong table. What it will not do is hand back your source. This is not a decompiler: names, docstrings and constants survive in full, but the control flow you see is bytecode, not the loop you wrote, and comments and formatting were dropped by the compiler long before this file existed. The line-number table and, from 3.11, the exception table are stepped over rather than decoded.
How to use
- Drop in a .pyc from a __pycache__ directory — the name usually looks like module.cpython-311.pyc.
- Read "Written by" first: that is the CPython release that compiled the file, and an interpreter whose magic number differs will ignore the cache and recompile from source.
- Check the invalidation mode. A timestamp file carries the source’s modification time and size; a hash-based one carries a hash of the source, and the unchecked flavour is never compared against it again.
- Pick a code object from the tree. The module is at the top, and every function, class body, lambda and (before 3.12) comprehension hangs under whatever defines it.
- Read the disassembly of the selected object: the resolved column dereferences names, locals and constants, and >> marks an instruction that some jump lands on.
Frequently asked questions
- I edited the source but Python keeps running the old code. Why?
- A timestamp-invalidated .pyc records the source’s modification time to the second and its size in bytes, and CPython reuses the cache whenever both still match. Two situations defeat that. If an edit lands within the same second as the recorded time and leaves the file exactly the same length — a one-character change in a generated file, say — nothing looks different and the stale cache is used. And restoring files with a tool that preserves timestamps, or checking out a branch that rewinds them, can leave a .pyc that is newer than a source it no longer matches. Deleting the __pycache__ directory fixes both; running with -B or PYTHONDONTWRITEBYTECODE stops the files being written in the first place.
- What is the difference between a checked and an unchecked hash-based .pyc?
- PEP 552 replaced the timestamp with an eight-byte hash of the source, so a build no longer depends on file modification times — the same source produces a byte-identical .pyc on any machine, which is what reproducible builds need. Bit 1 of the flags word then chooses the behaviour: checked means the interpreter hashes the source on every import and recompiles when it differs, unchecked means it never looks at the source at all. Unchecked is for the case where something outside Python guarantees freshness, such as a container image built in one step, and it is a trap anywhere else, because editing the source then has no effect whatsoever until the file is regenerated by hand.
- Can I recover the original source from a .pyc?
- Not from this tool, and not exactly from any tool. The compiler keeps everything it needs to run the code — every name, every constant, docstrings, argument names, the first line number of each function — so a .pyc leaks far more than people expect and should never be treated as obfuscation. What it does not keep is the text: comments, blank lines, formatting and the exact expression shapes are gone. Decompilers such as uncompyle6 or decompyle3 reconstruct plausible source from the instruction stream, but they lag the language by years and largely stop working around 3.9, because the newer compilers restructure control flow in ways that no longer map back one-to-one.
- Why does the same source produce completely different bytecode on two Python versions?
- Because the instruction set is an implementation detail that CPython rewrites freely. 3.11 introduced inline caches — real bytes in the instruction stream, owned by the preceding instruction — and moved the frame handling into new opcodes such as RESUME and MAKE_CELL. 3.12 inlined list and set comprehensions, so they stopped being separate code objects. 3.13 renumbered nearly everything again. That is exactly why the magic number exists: a .pyc is only ever valid for the release that wrote it, and any other interpreter treats it as absent rather than trying to run it.
- Why do the offsets jump by more than two, and what is EXTENDED_ARG?
- Every instruction is two bytes — one opcode, one argument byte — so an argument larger than 255 is built up by one or more EXTENDED_ARG instructions in front of it, each contributing eight more bits. They are folded into the following instruction here, exactly as dis does, which is why you will see an argument in the thousands on a single row. From 3.11 the stream also contains inline cache slots that belong to the instruction before them and hold specialisation state at runtime; they are skipped rather than listed, so offsets advance by more than two. In a .pyc on disk those caches are always zero: specialisation happens in memory, and the quickened opcodes are never written to a file.
- What are cell and free variables, and why is a name in two lists?
- A closure needs a variable to outlive the frame that created it, so the compiler puts it in a cell: the defining function lists it in its cell variables, and each nested function that reads it lists the same name in its free variables. From 3.11 both live in one array with a kind byte per entry, which is why an argument that a nested function captures appears both as a local and as a cell variable — it really is both, and the function starts with a MAKE_CELL instruction that moves the argument into its cell.
Related tools
Python Pickle Inspector
Disassemble a .pkl file in the browser: every opcode, the memo, the value it rebuilds, and whether loading it would run code.
Java Class File Inspector
Read a compiled .class file in the browser: class file version and JDK release, access flags, constant pool, fields, methods and referenced classes.
NumPy .npy / .npz Inspector
Read a .npy or .npz header in the browser: dtype, shape, byte order, field layout and a value preview, with no NumPy and no upload.
ELF Binary Inspector
Open a Linux executable, .so or .o in your browser: architecture, shared libraries it needs, build ID, and whether it is PIE, NX and RELRO hardened.
File Type Detector (Magic Bytes)
Drop a file to identify its true format from its magic bytes — and catch files whose extension or declared MIME type lies.
SQLite Database Inspector
Open a .sqlite or .db file in your browser and read its structure: page size, encoding, journal mode, and every table with its real row count.