Build a Desktop App with PySide6 #7: Packaging and Distribution — Building Executables with PyInstaller

7 min read

As long as the app is launched with python main.py, it remains a developer’s tool. Handing it to someone else leaves one final gate: the receiving computer has neither Python nor PySide6 installed. This post covers bundling the interpreter and the libraries together into a distributable that runs on double-click. It is the final part of the series.

It runs in seven parts.

  • #1 What PySide6 is — desktop apps with Qt and Python
  • #2 Widgets and layouts — assembling the screen
  • #3 Signals and slots — the core of event handling
  • #4 Qt Designer and UI files — draw the screen, then load it
  • #5 Model/View — connecting data to lists and tables
  • #6 Threads and timers — keeping the UI responsive
  • #7 Packaging and distribution — building executables with PyInstaller ← this post

What has to go into the bundle #

A distributable needs three layers packed together: your code, the PySide6 and Qt libraries, and the Python interpreter itself. The tool that bundles all three into a single executable (or folder) is PyInstaller. It is the most widely used option with the most reference material, which makes it the right first packaging tool.

installing PyInstaller
uv add --dev pyinstaller

A packaging tool is not a runtime dependency of the app, so it goes into the dev dependencies with --dev.

First build — onedir by default #

Start with a single line from the project root.

basic build
pyinstaller --windowed --name TodoApp main.py
  • --windowed — do not open a black console window at launch. Essential for GUI apps.
  • --name — sets the name of the output.

When the build finishes, a dist/TodoApp/ folder appears containing the executable together with the library files. That whole folder is the unit of distribution: zip it up, send it, and the recipient unzips and double-clicks the executable. This is the default onedir mode.

If you want a single file instead, add --onefile. The difference between the two modes is a trade between distribution convenience and startup speed.

Aspectonedir (default)onefile
Outputone folder (many files)one executable
Startup speedfastslow (unpacks to a temp folder on every launch)
Ease of handoffzip and senda single file, simple
Troubleshootingeasy (contents are visible)hard

People often pick onefile because handing over one file is simple, but since the whole bundle is unpacked into a temp folder on every launch, apps with large libraries such as Qt start noticeably slower. If you can deliver through an installer or an archive, onedir is the sensible default.

Including data files — .spec and –add-data #

PyInstaller collects what to bundle by following your Python imports. Files that are not code — the .ui file we made in #4, or icon images — do not get picked up automatically. Specify them with --add-data.

including data files (colon on macOS/Linux, semicolon on Windows)
# macOS / Linux
pyinstaller --windowed --name TodoApp --add-data "ui/main.ui:ui" main.py

# Windows
pyinstaller --windowed --name TodoApp --add-data "ui/main.ui;ui" main.py

The left side of the separator is the source path and the right side is the folder inside the bundle. Once options start piling up, editing the TodoApp.spec file generated by the first build beats appending flags to the command line each time. The spec is a Python file holding the build configuration, and afterwards pyinstaller TodoApp.spec reproduces the same setup.

Tip
If your code opens data files with plain relative paths, the distributed app can fail to find them. Inside a PyInstaller runtime the unpack location is stored in sys._MEIPASS, so the standard practice is a small helper function that resolves paths against the source folder during development and against sys._MEIPASS in the distributed build.

Platform-specific caveats #

PyInstaller does not cross-compile. Build the Windows distributable on Windows and the macOS distributable on macOS, each on its own machine. With CI, the usual setup is building in parallel on per-OS runners.

  • Windows — forget --windowed and a console window opens behind the app. Set the icon with --icon app.ico; it must be in .ico format.
  • macOS — with --windowed, a .app bundle is produced in dist/. To run on other people’s machines without warnings, the app must be signed with an Apple Developer certificate (codesign) and notarized. Gatekeeper blocks unsigned apps and forces a bypass procedure. At the personal-distribution stage, starting with a note that explains this to users is acceptable.
  • Linux — the build runs well on distributions of the same family and a similar version as the build machine. For wider compatibility, the convention is to build on an older distribution.

It helps to calibrate expectations on size in advance. Because the Qt libraries ship inside, even a simple app produces a distributable in the tens of megabytes. That is the normal range for PySide6 deployment, and excluding unused Qt modules can trim part of it.

Antivirus false positives #

On Windows, PyInstaller output occasionally gets flagged by antivirus software. The structure — an executable that unpacks and runs itself — resembles some malware. The fundamental fix is signing the executable with a code-signing certificate; if signing is out of reach for now, switching from onefile to onedir alone often reduces false positives. Scan your own output with an antivirus before shipping and keep a short notice ready — it makes support requests much easier to handle.

Pre-release checklist #

A successful build and an app that runs on someone else’s computer are two different things. Your development machine already has Python and assorted libraries installed, so a file missing from the bundle may show no symptom at all. Before handing the app over, check at least the following.

  • Run it in a clean environment — launch the distributable on a virtual machine or another computer without Python installed. This single test catches most distribution accidents.
  • Verify data files — open the dist/ folder and confirm with your own eyes that .ui files, icons, and config templates actually made it in.
  • Console build for errors — when the distributable exits for no visible reason, temporarily rebuild without --windowed; errors print to the console and the cause is easy to find.
  • Show a version — put the version in the window title or an about dialog, so when a user reports a problem you can identify the exact build immediately.

The official alternative — pyside6-deploy #

PySide6 also ships an official deployment tool, pyside6-deploy. Internally it uses Nuitka, which compiles Python code by translating it to C, and it works from a config file (pysidedeploy.spec). It has advantages in startup speed and obfuscation, but builds take longer and troubleshooting material is not as abundant as PyInstaller’s. Learn the deployment flow with PyInstaller first, and keep this one in mind as the alternative to evaluate when the need arises.

Series recap #

One line for each of the seven parts.

  • #1 PySide6 is the official Python binding for Qt, and its LGPL license reaches all the way to commercial apps.
  • #2 Screens are assembled by placing widgets into layouts; the layout computes positions instead of coordinates.
  • #3 Widgets announce events as signals, and connecting signals to slots is the whole of event handling.
  • #4 Draw the screen in Qt Designer and load the .ui file to separate screen structure from logic.
  • #5 When data grows, stop stuffing widgets directly and separate into model and view.
  • #6 Move heavy work onto a Worker thread and receive results through signals, keeping the UI responsive.
  • #7 Bundle the interpreter with PyInstaller and the app runs on computers without Python.

Wrapping up #

If you followed along this far, you have completed one full cycle: opening a window, assembling a screen, handling events, connecting data, keeping the UI responsive, and producing a distributable. Next steps from here include QML and Qt Quick for declarative UI, deeper Model/View work (sort and filter proxy models), and internationalization with Qt Linguist. You now have the foundation to open the official documentation at the right chapter when the need appears.

This concludes the Build a Desktop App with PySide6 series.

X