How to Decode a Python Marshal Loader and Recover the Source
A Python "marshal loader" — the exec(marshal.loads(base64.b64decode(...))) shape — isn't encrypted. Marshal is a binary encoding, and the loader carries everything it needs to run, so it decodes with no key: base64-decode, decompress (zlib/zstd) if used, then marshal.loads returns the original code object. You can disassemble that, and on Python 3.12 and older a decompiler rebuilds readable source.
Every so often you meet a Python file that's just one long line: exec(marshal.loads(zlib.decompress(base64.b64decode("…")))). It looks locked down, and tools that produce it often call it "marshal encryption." It isn't encrypted at all — and this post shows exactly why, with the real output at each step.
I built the Marshal Decrypt tool to do this in the browser, and everything below is the actual behaviour I verified while building it: the decode chain, the recovered bytecode, real recovered source, and precisely where source recovery stops working.
Marshal isn't encryption — that's why it decodes with no key
The word "encryption" implies a secret key. A marshal loader has none. marshal.dumps() serialises a compiled code object to bytes; base64 makes those bytes printable; zlib or zstd optionally shrink them. The loader simply reverses that chain at import time and hands the code object to exec(). Anything needed to run the code has to ship inside the file — so anyone can run the same reversal without a password.
This is the fundamental limit of any client-side "protection": the interpreter has to be able to run your code, so the key can't be a secret. Encoding raises the effort bar; it doesn't keep a secret.
The three-step decode
A loader is a nested set of calls you unwrap from the inside out. The shape is always some combination of these:
import marshal, zlib, base64exec(marshal.loads(zlib.decompress(base64.b64decode("<base64 payload>"))))
- base64-decode the string inside
b64decode(...)back to raw bytes. - decompress those bytes if the loader wraps them in
zlib.decompress(...)orzstd.decompress(...). (zstd only exists in the stdlib on Python 3.14+.) - `marshal.loads` the result to get the original code object back — no key, nothing to guess.
The tool reads the actual call chain rather than guessing, so it handles aliased imports (from base64 import b64decode as d) and won't be fooled by a comment that claims a different codec. It never runs your code — it stops at marshal.loads, which deserialises without executing.
From code object to readable source
Once you have the code object, dis shows the bytecode. For a tiny greet() module the disassembly starts like this — the function and the string constants are already in plain view:
RESUME 0LOAD_CONST 0 (<code object greet ...>)MAKE_FUNCTIONSTORE_NAME 0 (greet)LOAD_NAME 1 (print)PUSH_NULLLOAD_NAME 0 (greet)LOAD_CONST 1 ('pyobfuscate')CALL 1
To go all the way back to source, rebuild a .pyc from the code object and run it through a decompiler (the tool uses pycdc, compiled to WebAssembly). How well that works depends entirely on the Python version, because a code object's format changes between releases:
| Python version | Source recovery (pycdc) |
|---|---|
| 3.12 and older | Clean, readable source |
| 3.13 | Partial — the newest opcodes can't always be rebuilt |
| 3.14 | Not yet — pycdc rejects the bytecode ("Bad MAGIC"); you get the disassembly + a .pyc to try elsewhere |
Pick the version the loader was built for. Bytecode is version-locked, so disassembling or decompiling on the wrong version gives wrong output — or fails to unmarshal entirely.
A real example: the secret comes straight back
Here's why "marshal encryption" is a poor place to hide anything. Decode a loader whose code checks a licence, decompile it, and the hard-coded key is right there in the recovered source — this is genuine pycdc output from a compiled sample:
import sysAPI_KEY = 'sk-9f3a-PRO-2026'VALID_CODES = {'PRO-2026-XYZ', 'TRIAL-7788'}def main():if len(sys.argv) < 2:print('Usage: app.py <license-code>')sys.exit(1)code = sys.argv[1]if code in VALID_CODES:print(f'Licensed. key={API_KEY}')else:print('Invalid license key')sys.exit(1)
The API key, the valid codes, and the exact check all came back. Marshalling changed how the file *looked*, not what it *contained*.
What to do if you actually need protection
Marshalling is a light deterrent — fine for keeping casual eyes off a script, useless against anyone who knows the three steps above. If you're shipping Python you care about, layer real measures instead of relying on encoding:
- Obfuscate the source first (rename identifiers, encrypt strings, hide imports) so even recovered bytecode is hard to read — see the Python obfuscator.
- Move any licence or trial check off the client. A check baked into the code can be found and patched once decoded; a server-side check with signed, device-bound responses survives being unmarshalled. That's what a system like Licers does.
To try the decode yourself, paste a loader into the Marshal Decrypt tool — it runs entirely in your browser. To go the other way, the Marshal Encryptor builds these loaders, and how Python marshal works covers the format in depth.
Decode a marshal loader now
Paste a loader and recover the code object, disassembly and source — in your browser.
Open Marshal DecryptFree tools mentioned here
Related guides
Frequently asked questions
Is a marshal loader encrypted?
No. "Marshal encryption" is a misnomer — marshal is a binary encoding of a compiled code object, not encryption. There's no key, so it decodes straight back with base64-decode, optional decompression, and marshal.loads.
Do I need the original password or key to decode it?
No. A loader has to contain everything needed to run, so there's nothing secret to supply. If a tool called it "encrypted," it still decodes without a key.
Can I recover the original source, not just bytecode?
Often, yes. Rebuild a .pyc from the recovered code object and run it through a decompiler (pycdc). It rebuilds clean source through Python 3.12, partial source on 3.13, and can't read 3.14 bytecode yet — for 3.14 you get the full disassembly instead.
Which Python version should I decode on?
The one the loader was built for. Bytecode is version-locked: the opcodes and the .pyc magic number change between minor versions, so decoding on the wrong version produces wrong output or fails to unmarshal.
Is decoding a marshal loader safe to run?
The Marshal Decrypt tool never executes the payload — it stops at marshal.loads, which deserialises without running the code, and disassembly is static. It all runs in your browser.