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:

  1. Extract (one-file mode) or locate (one-folder mode) the bundled Python runtime and modules.
  2. Initialize the embedded Python interpreter.
  3. Set up sys.path to point to the extracted/bundled files.
  4. 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

FunctionPurpose
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:

  1. Build without --windowed to see console output and tracebacks.
  2. Enable verbose boot logging: pyinstaller --debug=all main.py
  3. Check warn-main.txt in the build directory — it lists modules PyInstaller could not find.
  4. Test in one-folder mode first — it is easier to inspect the bundled files.
  5. Compare sys.path between dev and frozen mode to catch path resolution bugs.

Alternatives comparison

ToolApproachProsCons
PyInstallerBundle interpreter + depsWide compatibility, active communityNot a compiler, large output
NuitkaCompile to C then binaryReal compilation, smaller outputSlower build, some compatibility gaps
cx_FreezeBundle (similar to PyInstaller)Cross-platform, MSI supportSmaller community
BriefcaseNative packaging per platformNative installers, app store readyRequires BeeWare ecosystem
Shiv/PEXZipapp with depsLightweight, requires Python on targetNot standalone

Security considerations

  • The bundled Python bytecode is not encrypted by default. Users can extract .pyc files from the bundle and decompile them.
  • PyInstaller’s --key option 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 .py to .so/.pyd before 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.

pythonpyinstallerpackagingdistributiondesktop

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.