Python PyInstaller Packaging — Deep Dive
How the bootloader works
When you run a PyInstaller-produced executable, you are actually running a small C program called the bootloader. Its job:
- Extract (one-file mode) or locate (one-folder mode) the bundled Python runtime and modules.
- Initialize the embedded Python interpreter.
- Set up
sys.pathto point to the extracted/bundled files. - Execute your entry-point script.
In one-file mode, extraction goes to sys._MEIPASS — a temporary directory that is cleaned up on exit. This is why one-file apps have a startup delay proportional to bundle size.
The bootloader is platform-specific and compiled ahead of time. PyInstaller ships pre-built bootloaders for Windows (32/64-bit), macOS (x86_64/arm64), and Linux (x86_64). For unusual platforms, you can rebuild the bootloader from source.
Writing custom hooks
When PyInstaller misses a dependency, write a hook:
# hooks/hook-my_library.py
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
# Include all data files shipped with the package
datas = collect_data_files('my_library')
# Include all submodules (catches dynamic imports)
hiddenimports = collect_submodules('my_library')
# Include specific binary extensions
binaries = [('/usr/lib/libcustom.so', '.')]
Place custom hooks in a directory and reference it:
pyinstaller --additional-hooks-dir=hooks main.py
Hook utilities
| Function | Purpose |
|---|---|
collect_data_files(pkg) | Gather non-Python files from a package |
collect_submodules(pkg) | Find all submodules for dynamic import scenarios |
collect_dynamic_libs(pkg) | Find shared libraries (.so/.dll/.dylib) |
copy_metadata(pkg) | Include METADATA/PKG-INFO for packages that read their own metadata |
is_module_satisfies(req) | Conditionally include based on version |
Size optimization
A default PyInstaller build can be 50-200+ MB. Reduction strategies:
1. Virtual environment isolation
Build from a clean venv with only required packages:
python -m venv build_env
source build_env/bin/activate
pip install -r requirements.txt
pip install pyinstaller
pyinstaller main.py
This prevents accidentally bundling development tools, test frameworks, and Jupyter.
2. Explicit exclusions
pyinstaller --exclude-module=matplotlib \
--exclude-module=numpy.testing \
--exclude-module=pytest \
main.py
Or in the spec file:
a = Analysis(
['main.py'],
excludes=['matplotlib', 'numpy.testing', 'pytest', 'tkinter'],
)
3. UPX compression
PyInstaller integrates with UPX to compress binaries:
pyinstaller --upx-dir=/path/to/upx main.py
UPX typically reduces size by 30-50%. On macOS, UPX can break code signing — exclude problematic files:
# in spec file
exe = EXE(pyz, a.scripts, [],
upx_exclude=['libpython3.11.dylib'])
4. Strip debug symbols (Linux/macOS)
pyinstaller --strip main.py
Removes debug symbols from shared libraries, saving 10-30%.
Code signing
Unsigned executables trigger security warnings on all major platforms.
Windows (Authenticode)
# Sign with a certificate from a CA
signtool sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 /a dist\MyApp\MyApp.exe
macOS (codesign + notarization)
codesign --deep --force --options runtime \
--sign "Developer ID Application: Your Name (TEAM_ID)" \
dist/MyApp.app
# Notarize for Gatekeeper
xcrun notarytool submit dist/MyApp.dmg \
--apple-id you@example.com \
--team-id TEAM_ID \
--password @keychain:notary-password \
--wait
Without notarization, macOS Catalina+ blocks the app by default.
Multi-platform CI/CD pipeline
# .github/workflows/release.yml
name: Build Releases
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: |
pip install -r requirements.txt
pip install pyinstaller
- run: pyinstaller --onefile --windowed --name MyApp main.py
- uses: actions/upload-artifact@v4
with:
name: MyApp-${{ matrix.os }}
path: dist/
This builds native executables for all three platforms on every tagged release.
Splash screen (Windows)
PyInstaller 5+ supports a splash screen during one-file extraction:
# in spec file
splash = Splash('splash.png',
binaries=a.binaries,
datas=a.datas,
text_pos=(10, 50),
text_size=12,
text_color='white')
In your code, close the splash when ready:
try:
import pyi_splash
pyi_splash.close()
except ImportError:
pass
Debugging frozen applications
When the packaged app fails but the script works in development:
- Build without
--windowedto see console output and tracebacks. - Enable verbose boot logging:
pyinstaller --debug=all main.py - Check
warn-main.txtin the build directory — it lists modules PyInstaller could not find. - Test in one-folder mode first — it is easier to inspect the bundled files.
- Compare
sys.pathbetween dev and frozen mode to catch path resolution bugs.
Alternatives comparison
| Tool | Approach | Pros | Cons |
|---|---|---|---|
| PyInstaller | Bundle interpreter + deps | Wide compatibility, active community | Not a compiler, large output |
| Nuitka | Compile to C then binary | Real compilation, smaller output | Slower build, some compatibility gaps |
| cx_Freeze | Bundle (similar to PyInstaller) | Cross-platform, MSI support | Smaller community |
| Briefcase | Native packaging per platform | Native installers, app store ready | Requires BeeWare ecosystem |
| Shiv/PEX | Zipapp with deps | Lightweight, requires Python on target | Not standalone |
Security considerations
- The bundled Python bytecode is not encrypted by default. Users can extract
.pycfiles from the bundle and decompile them. - PyInstaller’s
--keyoption encrypts bytecode with AES, but determined attackers can extract the key from the bootloader binary. - For serious IP protection, combine PyInstaller with Cython compilation of sensitive modules (compile
.pyto.so/.pydbefore bundling).
One thing to remember: PyInstaller’s real-world challenge is not the basic pyinstaller main.py command — it is handling hidden imports, data files, code signing, and platform-specific CI pipelines that turn a working script into a professional, distributable desktop application.
See Also
- Python Appimage Distribution An AppImage is like a portable app on a USB stick — download one file, double-click it, and your Python program runs on any Linux computer without installing anything.
- Python Briefcase Native Apps Imagine a travel agent who repacks your suitcase for each country's customs — Briefcase converts your Python app into proper native packages for every platform.
- Python Flatpak Packaging Flatpak wraps your Python app in a safe bubble that works on every Linux system — like a snow globe that keeps your program perfect inside.
- Python Mypyc Compilation Your type hints are not just for documentation — mypyc turns them into speed boosts by compiling typed Python into fast C extensions.
- Python Nuitka Compilation What if your Python code could run as fast as a race car instead of a bicycle? Nuitka translates Python into C to make that happen.