For marshaled Python bytecode, zlib (max level) and zstd (level 19) both shrink the blob roughly 40–55%. On real modules, high-level zstd comes out ~3–9% smaller than max zlib, and the gap widens with file size — about 2% on a 2 KB module, 9% on a 130 KB one. Two catches: zstd's *default* level actually loses to max zlib, and compression.zstd is standard-library only on Python 3.14+, so a zstd loader runs only on 3.14+ while zlib runs everywhere. Rule of thumb: zlib for portability, zstd (level 19) for the smallest 3.14 output on larger code. Try both in our Marshal Encryptor.
If you ship Python as marshaled bytecode, the blob is bigger than you'd expect — and base64-encoding it for a loader inflates it another third. So people compress it. The classic choice is zlib, but Python 3.14 added compression.zstd (PEP 784) to the standard library, which raises an obvious question: is zstd actually smaller?
I didn't want to guess, so I marshaled a range of real standard-library modules — from a 2 KB helper to a 135 KB monster — and compressed each with zlib and zstd at multiple levels. Here are the real numbers, the honest crossover, and the one catch that decides it for most people. Everything was run on Python 3.14.2.
Why compress marshaled bytecode at all?
A .pyc-style marshal blob is raw bytecode plus all your string and numeric constants — it isn't small, and to put it in a self-contained loader you base64-encode it, which adds ~33%. Compression claws that back: bytecode has a lot of structural repetition, so it compresses well. You get a smaller artifact to ship, and the blob stops being trivially greppable for string constants (a minor, *incidental* obfuscation — not real protection).
Two standard-library options can do the compressing, and the loader just reverses it at import time:
- `zlib` — in every CPython since forever. Maximum portability; the loader runs anywhere your target Python does.
- `compression.zstd` — new in Python 3.14 (PEP 784). Zstandard's algorithm, wrapped in a stdlib module — but *only* stdlib on 3.14+.
The test: marshal real modules, compress both ways
I compiled and marshaled six real stdlib modules of increasing size, then measured the base64-encoded size of each compressed payload — because that's what actually ships in the loader. zstd was measured at both its default level and level 19 (near-max):
import marshal, zlib, base64, importlib.utilfrom compression import zstddef measure(module):src = open(importlib.util.find_spec(module).origin, encoding="utf-8").read()raw = marshal.dumps(compile(src, module, "exec"))b64 = lambda data: len(base64.b64encode(data))return {"raw": len(raw),"zlib": b64(zlib.compress(raw, 9)),"zstd": b64(zstd.compress(raw)), # default level (~3)"zstd19": b64(zstd.compress(raw, level=19)),}print(measure("argparse"))# {'raw': 113557, 'zlib': 61868, 'zstd': 63740, 'zstd19': 57288}
The results
Sizes are bytes. raw marshal is the uncompressed bytecode; the three compression columns are the base64 sizes as they'd appear in a loader. The last column is level-19 zstd versus max zlib — the apples-to-apples comparison:
| Module | raw marshal | zlib -9 | zstd (default) | zstd -19 | zstd19 vs zlib |
|---|---|---|---|---|---|
json.scanner | 3,503 | 2,516 | 2,580 | 2,448 | -2.7% |
base64 | 26,571 | 15,552 | 16,376 | 14,872 | -4.4% |
asyncio.tasks | 45,681 | 26,188 | 27,848 | 25,132 | -4.0% |
argparse | 113,557 | 61,868 | 63,740 | 57,288 | -7.4% |
pydoc | 145,684 | 83,560 | 84,520 | 77,188 | -7.6% |
typing | 169,219 | 89,204 | 90,616 | 80,872 | -9.3% |
Two things jump out. First, zstd only wins at a high level. Its *default* level (around 3) is consistently *worse* than max zlib — look at argparse: default zstd is 63,740 bytes versus zlib's 61,868. If you call zstd.compress(raw) without a level, you've made the file bigger, not smaller.
Second, at level 19 zstd wins, and the margin grows with size — from a barely-there 2.7% on the 2 KB json.scanner to 9.3% on the 135 KB typing. That's the nature of the algorithm: zstd's larger window and better entropy coding need volume to pay off, so on a tiny script it's a wash, and on a big module it's a real few-kilobyte saving.
If you switch to zstd, always pass a high level= (say 19). Default-level zstd loses to zlib.compress(raw, 9) on every size I tested.
The catch: zstd needs Python 3.14+
Here's what decides it for most people. A compressed loader has to *decompress* at runtime, so whatever module you used must exist on the machine that runs it. zlib always does. compression.zstd is standard-library only on Python 3.14+ — on 3.13 and earlier the import fails outright:
# Python 3.14+from compression import zstd # works# Python 3.13 and earlierfrom compression import zstd# ModuleNotFoundError: No module named 'compression'
This dovetails with marshal's own rule: marshaled bytecode is version-locked — a blob built for 3.14 only loads on 3.14 anyway. So if you're targeting 3.14, a zstd loader is fully consistent (both halves need 3.14+). If you need your loader to run on 3.10–3.13, you're on zlib regardless — there's no stdlib zstd there to decompress it.
import marshal, base64from compression import zstdexec(marshal.loads(zstd.decompress(base64.b64decode("<blob>"))))
Compression is not protection. Whether you use zlib or zstd, the loader decompresses to plain marshaled bytecode that decompiles back to near-original source. To actually protect code, obfuscate first with the Python Obfuscator, then marshal and compress the result.
Which should you use?
| If you… | Use | Why |
|---|---|---|
| Need the loader to run on 3.10–3.13 | zlib (level 9) | zstd has no stdlib to decompress it there |
| Target Python 3.14 and ship large modules | zstd (level 19) | A few percent smaller, growing with size |
| Ship tiny scripts | Either (zlib) | The difference is negligible at small sizes |
Called zstd.compress() with no level | Add level=19 | Default zstd loses to max zlib |
In our free Marshal Encryptor you can pick None, zlib, or zstd and watch the size stats update — selecting zstd targets Python 3.14 automatically, since that's where compression.zstd lives. Want to see what's inside the blob it produces? Disassemble a snippet in the bytecode disassembler to confirm marshaled bytecode hides nothing.
Marshal + compress your bytecode — free
Compile to version-locked bytecode and compress it with zlib or zstd in your browser. Nothing uploaded; the size stats update live.
Open the Marshal EncryptorFree tools mentioned here
Related guides
Frequently asked questions
Is zstd smaller than zlib for Python bytecode?
At a high level (e.g. level 19), yes — on real marshaled modules zstd came out about 3–9% smaller than max zlib, and the advantage grew with file size. But zstd's default level is actually larger than zlib -9, so you must pass a high level= to benefit. On tiny scripts the two are effectively equal.
What is compression.zstd in Python?
compression.zstd is a standard-library module added in Python 3.14 (PEP 784) that provides Zstandard compression via compress(), decompress(), ZstdFile and streaming classes. Before 3.14 you needed a third-party package like zstandard or pyzstd; now it ships with CPython — but only from 3.14 onward.
Can I use zstd to compress bytecode for Python 3.12 or 3.13?
Not with the standard library — compression.zstd doesn't exist before Python 3.14, so a loader that does from compression import zstd raises ModuleNotFoundError on 3.13 and earlier. For those versions use zlib (always available), or ship a third-party zstd package with your app.
Does compressing marshaled bytecode protect my code?
No. Compression only makes the blob smaller (and slightly less greppable). The loader decompresses to ordinary marshaled bytecode, which decompiles back to near-original source with free tools. For real protection, obfuscate the source first, then marshal and compress the result.
What zstd level should I use for the smallest output?
A high level such as 19 gives near-maximum ratio for a reasonable time; zstd supports up to 22. The default (~3) favours speed and, in testing, produced larger output than zlib -9 on Python bytecode. Since marshaling is a one-time build step, use level=19 for the smallest loader.