Skip to main content
Reverse Engineering

How to Decompile a PyInstaller EXE Back to Python (Tested)

By Mithun··9 min read
Quick Answer

A PyInstaller .exe isn't compiled machine code — it's your Python bundled with an interpreter. You recover the source in two moves: unpack the archive with pyinstxtractor to pull out the .pyc files, then decompile the entry .pyc back to .py (with pycdc / our in-browser .pyc Decompiler). I did exactly that below on a real 8 MB exe — the hard-coded API key and license code came out in plain text before I even decompiled.

People wrap a Python script into a single .exe with PyInstaller and assume the source is now safe — it's a binary, right? It isn't. PyInstaller doesn't compile your code to machine instructions; it bundles your .pyc bytecode together with a Python interpreter and unpacks it at runtime. Anyone can reverse that bundle.

To show exactly how little it hides, I built a real one-file .exe from a small 'licensed' tool, then walked it all the way back to source — unpack, inspect, decompile — with the actual commands and output at each step. Here's the script I compiled (it has the two things people hope an .exe hides: a secret and a license check):

The test: a real PyInstaller build

"""InvoiceGuard - a (pretend) paid CLI tool, shipped as a Windows .exe."""
import sys
API_KEY = "sk-invoiceguard-9f83b21c7e5d4a06" # a secret we "shipped"
LICENSE_CODE = "IG-PRO-2026-7742" # the license gate
def check_license(code: str) -> bool:
return code == LICENSE_CODE
def invoice_total(items):
subtotal = sum(qty * price for qty, price in items)
tax = round(subtotal * 0.0725, 2)
return round(subtotal + tax, 2)
def main():
print("InvoiceGuard 1.0")
code = sys.argv[1] if len(sys.argv) > 1 else ""
if not check_license(code):
print("Unlicensed copy. Pass your license code as the first argument.")
return
cart = [(2, 14.99), (1, 3.50)]
print("license: valid")
print("invoice-total:", invoice_total(cart))
if __name__ == "__main__":
main()
invoice_tool.py — 921 bytes, the script I compiled
pyinstaller --onefile invoice_tool.py
Build it into a one-file exe

That produced dist/invoice_tool.exe8,346,433 bytes (~8 MB, most of which is the bundled CPython 3.11.5 runtime), built with PyInstaller 5.9.0. It runs like any Windows program. Now let's take it apart.

Step 1 — does the raw .exe leak the secret? (partly)

The first thing everyone tries is running strings (or grep) on the .exe. On a one-file PyInstaller build, that under-delivers, because PyInstaller zlib-compresses the bundled code. Searching the raw exe for either secret found nothing:

$ grep -c "IG-PRO-2026-7742" invoice_tool.exe
0
$ grep -c "sk-invoiceguard" invoice_tool.exe
0
grep the raw .exe — the secrets are compressed, so: nothing

But it's not opaque either — the loader's fingerprints are right there in plaintext, which is how you *know* it's a PyInstaller bundle and what the entry module is called:

$ strings invoice_tool.exe | grep -E "_MEIPASS|invoice_tool"
_MEIPASS
invoice_tool
the giveaways that survive in the raw exe

_MEIPASS is the temp folder PyInstaller unpacks into at runtime — seeing it is the tell that you're looking at a PyInstaller executable. The compression is why you need to *unpack* the archive rather than just grep it.

Step 2 — unpack the exe with pyinstxtractor

The standard tool is pyinstxtractor (or the maintained pyinstxtractor-ng). It reads PyInstaller's archive format, decompresses every bundled entry, and writes them to a folder. Run it with the same major.minor Python the exe was built with (3.11 here) so it can tag the .pyc headers correctly:

[+] Processing invoice_tool.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.11
[+] Length of package: 8026433 bytes
[+] Found 61 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: invoice_tool.pyc
[+] Found 103 files in PYZ archive
[+] Successfully extracted pyinstaller archive: invoice_tool.exe
pip install pyinstxtractor-ng && python -m pyinstxtractor_ng invoice_tool.exe

That's 61 files from the outer CArchive and 103 more from the inner PYZ-00.pyz (the compressed module library). The one that matters is flagged for us: `invoice_tool.pyc` — the entry point, i.e. our script compiled to bytecode. Its name matches the module because PyInstaller preserves it.

The extracted folder (invoice_tool.exe_extracted/) contains every third-party module the app bundled too. If a program hides logic in a helper module, it's in there as its own .pyc — same technique recovers all of them.

Step 3 — the .pyc leaks the secrets before you even decompile

Here's the part that should worry anyone shipping secrets in a compiled app. The extracted invoice_tool.pyc is just 1,902 bytes, and its string constants are stored in the clear. The same grep that found nothing in the exe finds both secrets instantly now:

$ grep -c "IG-PRO-2026-7742" invoice_tool.exe_extracted/invoice_tool.pyc
1
$ strings invoice_tool.exe_extracted/invoice_tool.pyc | grep -E "sk-invoiceguard|IG-PRO"
sk-invoiceguard-9f83b21c7e5d4a06
IG-PRO-2026-7742
the extracted .pyc — secrets in plain text

No decompiler required — the API key and the license code are readable straight out of the bytecode. The first four bytes of the file (a7 0d 0d 0a) are the CPython 3.11 magic number, which also tells you exactly which decompiler to reach for next. If all you wanted was the hard-coded key, you're already done.

Step 4 — decompile the .pyc back to readable source

To get actual .py back (not just the strings), decompile the entry .pyc. Since it's Python 3.11 bytecode, I dropped it straight into our own free .pyc Decompiler, which runs pycdc (Decompyle++) compiled to WebAssembly — so it decompiles locally in the browser, nothing uploaded. It finished in 22.6 ms. This is the real, unedited output:

# Source Generated with Decompyle++
# File: input.pyc (Python 3.11)
'''InvoiceGuard - a (pretend) paid CLI tool, shipped as a Windows .exe.'''
import sys
API_KEY = 'sk-invoiceguard-9f83b21c7e5d4a06'
LICENSE_CODE = 'IG-PRO-2026-7742'
def check_license(code = None):
return code == LICENSE_CODE
def main():
print('InvoiceGuard 1.0')
code = sys.argv[1] if len(sys.argv) > 1 else ''
if not check_license(code):
print('Unlicensed copy. Pass your license code as the first argument.')
return None
cart = [
None,
(1, 3.5)]
print('license: valid')
print('invoice-total:', invoice_total(cart))
if __name__ == '__main__':
main()
pycdc output on invoice_tool.pyc — the actual result

The docstring, the imports, both secrets, the check_license comparison, and the entire main() control flow — license gate, argument handling, print statements — all came back cleanly. An attacker now knows precisely how the license check works (code == LICENSE_CODE) and can flip or patch it.

I'm keeping the output honest, though: it isn't byte-perfect. pycdc choked on one construct — the generator expression inside invoice_total (sum(qty * price for qty, price in items)) — and emitted a warning instead of reconstructing it:

Unsupported opcode: RETURN_GENERATOR (109)
# ...and in the output, the genexpr came back as:
subtotal = (lambda .0: pass# WARNING: Decompyle incomplete
)(items())
the one thing it could not reconstruct

It also mangled the cart list's first tuple to None. That's the reality of decompilation on newer bytecode: you get most of the program back perfectly and a couple of rough edges around comprehensions and generators. For understanding logic or lifting secrets, it's more than enough; the recovered structure tells you exactly where to look. If you only need the logic (not clean source), the Bytecode Disassembler reads the dis listing of the same .pyc with no reconstruction guesswork at all.

When a step fails — version and magic numbers

Two things trip people up, and both come down to the Python version the exe was built with:

  • pyinstxtractor recovers the `.pyc` but the header is wrong. Older PyInstaller strips the .pyc header; pyinstxtractor-ng rebuilds it, but only if you run it under a matching Python version. Run it with the same major.minor (3.11 exe → Python 3.11) and it tags the magic for you.
  • The decompiler says `Bad MAGIC!` or returns a partial result. Each decompiler supports a range of bytecode versions. pycdc cleanly handles roughly Python 2.x–3.12; on 3.13 it goes partial and 3.14 isn't supported yet. When the decompiler can't keep up with a brand-new Python, fall back to the standard library — marshal + dis read the code object and its string constants on any version. Full walkthrough in how to decompile a .pyc, every method.

"My Python version isn't supported by the decompiler yet" is a delay, not a defense. The strings and control flow are still readable straight from the extracted .pyc with two standard-library calls — see decompiling a .pyc.

How to actually protect a PyInstaller app

The exercise above recovers either your source or your secrets from an ordinary PyInstaller build, so --onefile is packaging, not protection. What actually raises the bar is changing what gets bundled in the first place:

  1. Obfuscate the source before you build. A decompiler can only return the source that was compiled — so if you rename every identifier and encrypt the string literals first, the recovered output is that mangled, encrypted version, not your real code. Our free Python Obfuscator does this at the AST level; run it, then feed the result to PyInstaller.
  2. Keep real secrets off the client. As Step 3 showed, a hard-coded key survives every layer and comes out in plaintext. Load keys from the environment or fetch them from your own backend at runtime — see how to protect API keys in Python.
  3. Don't rely on a client-side license check. check_license decompiled perfectly; anyone can invert code == LICENSE_CODE. Validate licenses server-side, or bind them to hardware with signed responses a fake server can't forge.
  4. For the strongest bar, compile to a native module. A Cython .pyd/.so or a Nuitka binary ships real machine code with no .pyc to extract at all. See Python to EXE for where that fits.

The bigger picture — what each protection layer does and doesn't stop — is in is Python obfuscation secure? and does PyInstaller protect Python source code?.

Decompile a .pyc in your browser — free

Unpacked a PyInstaller exe and got a .pyc? Drop it into our in-browser .pyc Decompiler (pycdc via WebAssembly) — nothing is uploaded.

Open the .pyc Decompiler

Free tools mentioned here

Related guides

Frequently asked questions

Can you decompile a PyInstaller exe?

Yes. PyInstaller doesn't compile Python to machine code — it bundles your .pyc bytecode with an interpreter. Unpack the bundle with pyinstxtractor to pull out the .pyc files, then decompile the entry .pyc back to source with a tool like pycdc. In my test on a real 8 MB one-file exe, the docstring, imports, hard-coded API key, license code and the full main() flow all came back.

How do I unpack a PyInstaller exe?

Use pyinstxtractor (or the maintained pyinstxtractor-ng). Install it, then run it on the .exe with the same major.minor Python version the exe was built with so it can tag the .pyc headers correctly. It decompresses PyInstaller's archive and writes every bundled file — including the entry-point .pyc named after your script — into an _extracted folder.

Does compiling to an exe with PyInstaller protect my source code?

No. A PyInstaller exe is packaging, not protection. The bundle is zlib-compressed, so the raw exe doesn't reveal strings to a naive grep, but unpacking it with pyinstxtractor exposes the .pyc files — and their string constants (API keys, license codes) are stored in plaintext even before decompiling. To actually protect code, obfuscate the source before building and keep secrets off the client.

What is _MEIPASS in a PyInstaller exe?

_MEIPASS is the temporary folder a one-file PyInstaller app unpacks itself into at runtime. Because the string appears in plaintext inside the exe, spotting _MEIPASS is the quickest way to confirm a Windows executable was built with PyInstaller — which tells you the source can be recovered by unpacking the bundle.

The decompiler says the Python version is not supported — now what?

Decompilers like pycdc, uncompyle6 and decompyle3 each support a range of bytecode versions and lag the newest Python releases. If yours is too new, you can still read everything that matters — string literals and control flow — straight from the extracted .pyc using the standard library's marshal and dis modules, which work on any version.

Keep reading