You've built a PyQt5 application. It works perfectly on your machine. Now you need to ship it to users who don't have Python installed, don't know what pip is, and just want to double-click an icon.

Welcome to the most underrated pain point in Python development. Here's everything I've learned from packaging and shipping multiple desktop apps to non-technical users.

The Baseline: PyInstaller

PyInstaller analyzes your Python script, finds all imports, and bundles everything - Python interpreter, dependencies, and your code - into a standalone executable.

pip install pyinstaller
pyinstaller --onefile --windowed app.py
  • --onefile packs everything into a single .exe (or equivalent)
  • --windowed suppresses the console window on Windows (essential for GUI apps)

The output lands in dist/app.exe. Double-click it - it works. Except when it doesn't, which is most of the time on the first try.

Hidden Imports: The Silent Killer

PyInstaller traces import statements statically. But Python is dynamic - modules imported with importlib, __import__(), or inside try/except blocks are invisible to the analyzer.

PyQt5 is full of these. You'll get errors like:

ModuleNotFoundError: No module named 'PyQt5.sip'

The fix: explicitly tell PyInstaller about hidden dependencies:

pyinstaller --onefile --windowed \
    --hidden-import=PyQt5.sip \
    --hidden-import=PyQt5.QtPrintSupport \
    app.py

Or use a .spec file for complex configurations:

# app.spec
a = Analysis(
    ['app.py'],
    hiddenimports=[
        'PyQt5.sip',
        'PyQt5.QtPrintSupport',
        'PyQt5.QtSvg',
    ],
    # ...
)

My approach: run the built executable, note the missing module error, add it to hiddenimports, rebuild. Repeat until it works. It's tedious but reliable.

Bundling Assets (Icons, Images, QML Files)

Your app has images, stylesheets, font files, or QML files. PyInstaller won't find these automatically - it only traces Python imports.

Step 1: Add Data Files to the Spec

# app.spec
a = Analysis(['app.py'], ...)

a.datas += [
    ('assets/icon.png', 'assets'),
    ('assets/style.qss', 'assets'),
    ('assets/fonts/*.ttf', 'assets/fonts'),
]

Step 2: Fix Your File Paths

When running from the bundled executable, your working directory isn't what you expect. PyInstaller extracts files to a temp directory and sets sys._MEIPASS to point there:

import sys
import os

def resource_path(relative_path):
    """Get absolute path to resource, works for dev and for PyInstaller."""
    if hasattr(sys, '_MEIPASS'):
        # Running as bundled executable
        return os.path.join(sys._MEIPASS, relative_path)
    # Running in development
    return os.path.join(os.path.abspath('.'), relative_path)

# Usage
icon_path = resource_path('assets/icon.png')

Every file path in your app needs to go through this function. Miss one, and the bundled app breaks while the development version works fine.

The --onefile Trap

--onefile is convenient - one file to distribute. But it has a cost:

  1. Slow startup - The executable extracts everything to a temp directory on launch. For a PyQt5 app with assets, this adds 3-10 seconds of startup time
  2. Antivirus flags - Self-extracting executables are a common malware pattern. Windows Defender and other AVs frequently flag --onefile builds
  3. Disk space - The temp extraction means your app uses disk space twice (the exe + the extracted files)

For internal tools, --onefile is fine. For apps shipping to real users, I prefer --onedir:

pyinstaller --onedir --windowed app.py

This creates a folder with the executable and all dependencies alongside it. Wrap it in an installer (NSIS, Inno Setup) for a clean user experience.

Binary Size: Taming the Bloat

A basic PyQt5 app bundles to 80-150 MB. That's because PyInstaller includes all of Qt - every module, every plugin, every translation file - even if you only use QtWidgets.

Exclude Unused Qt Modules

# app.spec
a = Analysis(['app.py'], ...)

# Remove Qt modules you don't use
exclude_modules = [
    'PyQt5.QtWebEngine', 'PyQt5.QtWebEngineWidgets',
    'PyQt5.QtMultimedia', 'PyQt5.QtMultimediaWidgets',
    'PyQt5.QtBluetooth', 'PyQt5.QtNfc',
    'PyQt5.QtPositioning', 'PyQt5.QtLocation',
    'PyQt5.QtQuick', 'PyQt5.QtQml',
    'PyQt5.Qt3DCore', 'PyQt5.Qt3DRender',
]

a.excludes = exclude_modules

Strip Qt Translations and Plugins

# Remove translations (saves ~20MB)
a.datas = [d for d in a.datas if not d[0].startswith('PyQt5/Qt5/translations')]

# Remove unused Qt plugins
a.datas = [d for d in a.datas if not d[0].startswith('PyQt5/Qt5/plugins/sqldrivers')]
a.datas = [d for d in a.datas if not d[0].startswith('PyQt5/Qt5/plugins/mediaservice')]

These exclusions typically reduce the bundle from 120MB to 40-60MB.

UPX Compression

PyInstaller supports UPX compression:

pyinstaller --onedir --windowed --upx-dir=/path/to/upx app.py

UPX compresses the binary files inside the bundle, typically saving another 30-40%. The tradeoff is slightly slower startup as files are decompressed.

Cross-Platform Builds

PyInstaller can only build for the platform it runs on. You can't build a Windows .exe on macOS or vice versa. Options:

  1. Build on each platform - Maintain a build machine (or VM) for each target OS
  2. GitHub Actions - CI/CD with matrix builds across Windows, macOS, Linux
  3. Docker - For Linux builds from any platform

A practical GitHub Actions setup:

jobs:
  build:
    strategy:
      matrix:
        os: [windows-latest, macos-latest, ubuntu-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install pyinstaller pyqt5
      - run: pyinstaller --onedir --windowed app.py
      - uses: actions/upload-artifact@v4
        with:
          name: build-${{ matrix.os }}
          path: dist/

macOS Specifics

macOS apps need to be code-signed and notarized to avoid the "this app is from an unidentified developer" warning:

# Build the .app bundle
pyinstaller --onedir --windowed --osx-bundle-identifier=com.yourname.app app.py

# Sign it
codesign --deep --force --sign "Developer ID Application: Your Name" dist/app.app

# Notarize (requires Apple Developer account)
xcrun notarytool submit dist/app.zip --apple-id [email protected] --team-id TEAMID --password @keychain:notarize

Without notarization, macOS Gatekeeper will block your app on first launch. Users can override it, but it's a terrible first impression.

Common Pitfalls

1. Qt Platform Plugin Missing

This application failed to start because no Qt platform plugin could be initialized.

The most common PyInstaller + PyQt5 error. Fix:

# Add to your app before any Qt imports
import os
os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = resource_path('PyQt5/Qt5/plugins/platforms')

2. SSL Certificate Errors

Bundled apps can't find system SSL certificates:

import certifi
os.environ['SSL_CERT_FILE'] = certifi.where()

And add certifi to your hidden imports.

3. Multiprocessing Freeze Support

If your app uses multiprocessing, add this to the top of your entry point:

import multiprocessing
multiprocessing.freeze_support()

Without it, spawning child processes on Windows creates infinite copies of your app.

My Build Checklist

Before every release:

  1. Clean build: delete build/ and dist/ directories
  2. Build with --onedir for the final release
  3. Test on a clean machine (VM or fresh user account) without Python installed
  4. Check binary size - investigate anything over 60MB
  5. Test all file operations (save, load, export) - resource_path issues hide here
  6. On Windows, submit to VirusTotal to check for false positive AV detections
  7. Package with an installer (NSIS/Inno Setup on Windows, DMG on macOS)

Key Takeaways

  • PyInstaller is the best option for PyQt5 - alternatives like cx_Freeze and Nuitka exist but have more edge cases
  • Hidden imports are your biggest headache - keep a list and add to it every time you find one
  • Use resource_path() for every file access - hardcoded paths break in bundled apps
  • Prefer --onedir over --onefile for shipping to real users
  • Exclude unused Qt modules to cut binary size by 50%+
  • Test on a clean machine - "works on my machine" means nothing for packaged apps
  • Code signing and notarization are mandatory for macOS distribution

Packaging Python apps is unglamorous work, but it's the difference between a tool only you can run and software anyone can use.


These are patterns from shipping multiple PyQt5 apps. Your specific setup may need additional tweaks.