A "hello world" Typer CLI frozen with PyInstaller's --onefile comes out at about 15 MB. That surprises people who expect a few hundred kilobytes, and it matters: binaries are downloaded on every CI run that installs the tool, stored in every release, and unpacked into a temporary directory on every invocation of a one-file build. Much of the advice for shrinking them is folklore — "exclude these twenty modules", "always use UPX" — and some of it produces a smaller binary that crashes the first time it prints help. This guide measures first, then applies the reductions that are safe, with real numbers from a small Typer app, and shows how to test that the slimmer binary still works. It belongs to the standalone binaries topic, and builds on bundling a Python CLI with PyInstaller.
Prerequisites
- PyInstaller 6.x in the project's build environment (
uv add --group build pyinstaller). - A CLI that already builds and runs as a binary.
- The figures below were measured on Linux x86_64 with Python 3.13 and PyInstaller 6.22 for a one-command Typer app; your numbers will differ, but the proportions are typical.
Measure before cutting
A one-file binary is a bootloader plus an archive. PyInstaller ships a reader for that archive, so a few lines of Python show where the bytes go:
# scripts/binary_report.py
import collections
import sys
from PyInstaller.archive.readers import CArchiveReader
archive = CArchiveReader(sys.argv[1])
top = collections.Counter()
for name, (_offset, compressed, *_rest) in archive.toc.items():
top[name.split("/")[0]] += compressed
for name, size in top.most_common(8):
print(f"{size / 1e6:7.2f} MB {name}")
pyz = archive.open_embedded_archive("PYZ.pyz")
modules = collections.Counter()
for name, entry in pyz.toc.items():
modules[name.split(".")[0]] += entry[2] if len(entry) > 2 else 0
print("\nlargest Python packages in PYZ:")
for name, size in modules.most_common(8):
print(f"{size / 1e3:7.0f} kB {name}")
For the example app the report says:
10.27 MB libpython3.13.so.1.0
4.71 MB PYZ.pyz
0.42 MB base_library.zip
largest Python packages in PYZ:
1789 kB pygments
493 kB rich
215 kB typer
176 kB email
163 kB pydoc_data
Two-thirds of the binary is the Python interpreter's shared library, and the largest Python package is Pygments — pulled in by Rich, which Typer uses for help and error formatting. That profile is common, and it tells you where effort pays off: the interpreter library first, heavy dependencies second, standard-library trimming a distant third.
The recipe
1. Strip symbols: --strip
pyinstaller --onefile --strip --name mytool src/mytool/__main__.py
--strip removes debug symbols from the bundled shared libraries, including libpython. Interpreters from python-build-standalone (which uv installs) and some distributions ship with symbols, so this alone took the example from 15.5 MB to 13.1 MB. It is safe for release builds; keep an unstripped build around if you ever need to debug a native crash. On Windows, --strip has no effect — symbols live in separate .pdb files there.
2. Compile with optimisation: --optimize 2
pyinstaller --onefile --strip --optimize 2 --name mytool src/mytool/__main__.py
Bytecode optimisation level 2 removes assert statements and docstrings from the bundled modules. That shrank the example to 12.6 MB. Check two things before adopting it: that your code does not rely on assert for real validation (it should not), and that nothing reads docstrings at runtime. Typer and Click build help text from your command functions' docstrings — in this test help output still worked, but verify it for your app, because a framework that reads __doc__ lazily at runtime would lose your help text.
3. Exclude only what you can prove is unused
pyinstaller --onefile --strip --optimize 2 \
--exclude-module tkinter --exclude-module unittest --exclude-module pydoc_data \
--name mytool src/mytool/__main__.py
Excluding modules that are genuinely never imported is safe and saves little: these three took the example to 12.4 MB. Excluding modules that are imported, even indirectly, breaks the binary in ways that only show up on specific code paths. The tempting target here is Pygments, the largest package — and excluding it produced a 10.8 MB binary whose --help crashed with ModuleNotFoundError: No module named 'pygments', because Typer's help renderer imports Rich's Markdown support, which imports rich.syntax, which imports Pygments. Ordinary commands worked; help and error messages did not. That is exactly the failure a quick manual check misses.
4. Shrink dependencies at the source
The large wins are in your dependency choices, which also improve startup time for non-frozen installs. If Rich-formatted help is not essential, Click instead of Typer avoids Rich and Pygments entirely. Optional features with heavy dependencies can move into extras and stay out of the default binary. Reducing CLI dependency weight covers the analysis. Build the binary from a clean environment created from the lock file with --no-dev, so test and lint tools never sneak into the analysis.
What about UPX?
PyInstaller can compress binaries with UPX if it is installed. It reduces download size, but packed executables are flagged far more often by antivirus heuristics on Windows, UPX can corrupt some shared libraries (PyInstaller excludes known-bad ones, not all), and every start pays the decompression cost. For a CLI distributed to companies, the antivirus false positives alone usually outweigh the savings; leave it off and compress the release archive instead.
Onefile or onedir?
Size is only half the trade-off. A --onefile binary extracts its archive into a temporary directory on every run; --onedir ships the files unpacked.
In the example, the stripped one-file binary started in about 130 ms; the equivalent one-directory build, 25 MB on disk, started in about 45 ms. For a command people run hundreds of times a day, or that shell completion calls on every Tab, that difference is noticeable. A common compromise: distribute --onedir builds inside a .tar.gz or .zip — the archive is about as small as the one-file binary — and have the package manager or installer place a launcher on PATH, as Homebrew and Scoop do in Homebrew and Scoop packaging for Python CLIs.
UX considerations
- Optimise for the download, not the disk. Users notice a 40 MB download more than 40 MB on disk; compressed archives and good caching in CI matter more than the last megabyte.
- Keep error output working. Help and error messages are the code paths least likely to be exercised in a quick check and most likely to be needed by a confused user. Never trade them for size.
- Publish the size in release notes if it changed significantly, especially for tools used in CI where download time is billed.
- Do not chase parity with Go binaries. A frozen Python CLI includes an interpreter; 10–20 MB is normal. If size is critical, consider whether a zipapp with shiv and a system Python is acceptable for your users.
Testing the behaviour
A size reduction is only safe if the slim binary still does everything. Smoke-test the built binary — not the source — on the code paths that are easy to break: help, version, a usage error, a normal command and a command that renders rich output:
# tests/test_binary.py
import os
import subprocess
from pathlib import Path
import pytest
BINARY = Path(os.environ.get("MYTOOL_BINARY", "dist/mytool"))
pytestmark = pytest.mark.skipif(not BINARY.exists(), reason="binary not built")
def run(*args):
return subprocess.run([str(BINARY), *args], capture_output=True, text=True, timeout=30)
def test_help_renders():
result = run("--help")
assert result.returncode == 0
assert "Usage" in result.stdout
def test_usage_error_is_reported_not_crashed():
result = run("--definitely-not-an-option")
assert result.returncode == 2
assert "Traceback" not in result.stderr
def test_normal_command():
result = run("--name", "ci")
assert result.returncode == 0 and "hello ci" in result.stdout
def test_size_budget():
assert BINARY.stat().st_size < 14_000_000, "binary grew past its size budget"
The usage-error test is the one that would have caught the Pygments exclusion. The size budget turns an accidental new heavy dependency into a failing test with a clear message. Run this suite in the same CI matrix that builds the binaries, as in building cross-platform release binaries in CI.
Conclusion
Measure the archive before cutting anything: for most CLIs the interpreter library dominates and the biggest Python package arrives through a framework you chose. --strip and --optimize 2 are the safe, worthwhile flags; module exclusions should be limited to modules you can prove are never imported; real savings come from lighter dependencies. Consider --onedir in an archive when startup time matters, skip UPX for tools that run on corporate machines, and protect every change with a smoke test of help, errors and a size budget.
Frequently asked questions
Why is libpython so big?
It contains the whole interpreter and, in many builds, debug symbols. --strip removes the symbols on Linux and macOS. The remaining size is the interpreter itself, which every frozen Python program needs.
Does Nuitka produce smaller binaries?
Sometimes — Nuitka compiles Python to C and can leave out unused parts more aggressively — but results vary with the dependencies, and builds take much longer. Nuitka vs PyInstaller for Python CLIs compares them.
Can I exclude encodings or other standard-library modules?
Avoid it. PyInstaller already bundles only what its analysis finds, and the standard library modules it includes are usually there for a reason — encodings, for instance, is needed at interpreter start-up. The savings are small and the failures are confusing.
Does --optimize 2 make the CLI faster?
Marginally — fewer bytes to unmarshal and no assert checks — but not noticeably for most CLIs. Treat it as a size reduction, and verify that help text is unaffected.