Shipping compiled Python — PyInstaller, Nuitka, and wheels

Single-file executables, Nuitka's compile-to-C path, and building manylinux wheels for extension modules.

🚚 Module 9 9 min read Not started

Why this matters

Sometimes you need to ship Python to users who don't have Python — desktop tools, internal CLIs for non-developers, kiosks, embedded devices, plug-ins. PyInstaller / Briefcase / Nuitka pack a Python runtime + your code into a single executable (or .app / .exe / .dmg).

Learning objectives

  1. Build a standalone CLI exe with PyInstaller.
  2. Build a desktop app installer with Briefcase.
  3. Build a (possibly faster) standalone exe with Nuitka.
  4. Sign / notarise for macOS and Windows.
  5. Handle data files, plugins, and hidden imports.

1. When to compile / bundle

NeedTool
Single-file CLI for non-Python usersPyInstaller
Cross-platform desktop app installerBriefcase
Maximum runtime speed (C compilation)Nuitka
Lambda-ready zipzipapp / shiv / pex
AWS Glue / Spark UDFspex
Python-in-the-browserPyodide / PyScript
Mobile (iOS / Android)BeeWare/Briefcase + Python 3.13's mobile support

2. PyInstaller — quick standalone exe

powershell
uv add --dev pyinstaller
powershell
uv run pyinstaller --onefile --name mytool src/my_package/cli.py
# Output: dist/mytool.exe (Windows) or dist/mytool (Linux/macOS)

That's it. The exe contains:

  • The Python interpreter.
  • All your modules + dependencies.
  • A bootloader that extracts to a temp folder and runs.

Common flags

  • --onefile: single executable (slower startup; extracts on launch).
  • --onedir: folder of files (faster startup; ship a zip).
  • --name: output name.
  • --icon=path/to/icon.ico.
  • --windowed: hide console (for GUI apps).
  • --add-data "data/templates;templates": bundle extra files.
  • --hidden-import package.name: include modules PyInstaller can't auto-detect.
  • --collect-all package: include every file from a package.

Spec files

For complex apps, generate a .spec once and edit it:

powershell
uv run pyi-makespec --onefile src/my_package/cli.py
# Edit mytool.spec, then:
uv run pyinstaller mytool.spec

Catch hidden imports

PyInstaller walks import statements but can't see dynamic imports. Common offenders: tiktoken, transformers, pydantic plugins, anything using importlib.

Use --collect-all or add to spec:

python
hiddenimports = ['tiktoken_ext.openai_public', 'tiktoken_ext']

Test thoroughly. Distribute → user → "ModuleNotFoundError" is the classic PyInstaller bug.

Size

PyInstaller bundles the full Python + every dep, including ones you don't need. Typical sizes:

  • Hello-world CLI: ~15 MB.
  • With numpy + pandas: ~150 MB.
  • With torch: ~1.5 GB.

To shrink: exclude unused modules in the spec, use --exclude-module.


3. Briefcase — installers, not just exes

BeeWare's Briefcase builds native installers (MSI on Windows, DMG/PKG on macOS, AppImage/Flatpak on Linux, APK/IPA on mobile).

powershell
uv tool install briefcase
briefcase new                      # interactive scaffold
cd my-project
briefcase create                   # generate platform-specific project
briefcase build                    # build native artifacts
briefcase package                  # produce installer
briefcase run                      # test locally

Output: MyApp.msi, MyApp.dmg, etc. Suitable for end-user distribution (double-click installer).

Native UI requires a UI framework — pick one:

  • Toga (BeeWare's): cross-platform native widgets.
  • PySide6 / PyQt6: full Qt; mature.
  • Tkinter: stdlib; ugly but works.
  • Flet: Flutter-powered.
  • Web frontend + pywebview: HTML/JS frontend in an OS webview.

4. Nuitka — compile Python to C

Nuitka translates Python to C and compiles. Result: standalone binary that's often 2-5× faster than CPython.

powershell
uv add --dev nuitka

# Standalone binary (folder)
uv run python -m nuitka --standalone --onefile src/my_package/cli.py

Pros:

  • Faster runtime than PyInstaller (real compilation, not bytecode in a zip).
  • Hides source code (binary, not zipped .pyc).
  • Smaller than PyInstaller for some workloads.

Cons:

  • Slower build (minutes for big apps).
  • Build dependencies (C compiler).
  • Trickier hidden-imports / dynamic-import handling.
  • Some packages need explicit --include-package.

Use Nuitka when you've already tried PyInstaller and need more performance / smaller binaries.


5. zipapp, shiv, pex — zipped Python apps

Build a single .pyz that any Python interpreter can run:

powershell
# stdlib zipapp
uv run python -m zipapp src/my_package -o tool.pyz -p "/usr/bin/env python3"

# shiv — bundles dependencies too
uv add --dev shiv
uv run shiv -c my-cli -o tool.pyz my-package

# pex — Twitter-grade reproducible builds
uv add --dev pex
uv run pex my-package -c my-cli -o tool.pex

.pyz / .pex files include your code + (optionally) deps; require Python on the target machine.

Use case: shipping CLIs to a fleet of dev / build / data-eng machines that have Python. Smaller than PyInstaller; one file.


6. AWS Lambda packaging

Lambda has size limits and a specific Linux environment.

dockerfile
FROM public.ecr.aws/lambda/python:3.12

COPY pyproject.toml uv.lock ./
RUN pip install uv && uv export --frozen --no-dev -o requirements.txt && \
    pip install -r requirements.txt -t ${LAMBDA_TASK_ROOT}

COPY src/my_handler/ ${LAMBDA_TASK_ROOT}/my_handler/

CMD ["my_handler.handler"]

Push to ECR; reference from Lambda. Up to 10 GB. Modern default.

Zip package (legacy)

powershell
uv pip install -r requirements.txt --target ./package
Copy-Item src/my_handler ./package -Recurse
Compress-Archive ./package/* lambda.zip

50 MB unzipped limit (250 MB with layers). Tighter constraints than containers.


7. Signing and notarisation (you can't skip this)

Windows

Unsigned exes show "Unknown publisher" warnings (or SmartScreen blocks). Sign with a code-signing certificate:

powershell
signtool sign /fd SHA256 /a /tr http://timestamp.digicert.com /td SHA256 mytool.exe

EV certificates ($400/year) start with trust; regular certs need reputation built up over time.

macOS

Unsigned apps: "App is damaged" Gatekeeper message.

  1. Get an Apple Developer ID ($99/year).
  2. Sign:
bash
codesign --force --options runtime --sign "Developer ID Application: ..." MyApp.app
  1. Notarise:
bash
xcrun notarytool submit MyApp.dmg --apple-id ... --team-id ... --password ... --wait
xcrun stapler staple MyApp.dmg

Briefcase automates this.

Linux

No mandatory signing. AppImages can be verified by checksums; Flatpak / Snap have their own mechanisms.


8. Reducing bundle size

For PyInstaller / Nuitka:

python
# In .spec
excludes = ['tkinter', 'matplotlib.tests', 'numpy.tests', 'scipy.tests', 'IPython']

Strip:

  • Test directories of your dependencies.
  • Tkinter (huge; only needed for GUI).
  • Matplotlib data (mpl-data/sample_data, mpl-data/fonts/ttf partially).
  • *.pyc rebuilding (COLLECT(strip=True)).
  • Locale files for languages you don't ship.

Use UPX:

powershell
pyinstaller --upx-dir=path/to/upx --onefile cli.py

UPX compresses the binary 30-70% (slower startup; some antivirus flag it).


9. Cross-compilation

PyInstaller / Nuitka don't cross-compile. To build for Windows + macOS + Linux:

  • Build on each platform (GitHub Actions matrix).
  • Or use Docker for Linux builds + native runners for Win/Mac.

GitHub Actions example:

yaml
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-python@v5
    with: { python-version: '3.12' }
  - run: pip install uv && uv sync --frozen
  - run: uv run pyinstaller --onefile src/my_package/cli.py
  - uses: actions/upload-artifact@v4
    with:
      name: mytool-${{ runner.os }}
      path: dist/*

For mobile: Briefcase + Apple silicon Mac for iOS; Briefcase + Linux/Mac for Android.


10. Worked example: a polished CLI installer

powershell
# 1. Build CLI
uv add typer rich
# write src/mycli/main.py with `typer` CLI

# 2. PyInstaller spec (one-time)
uv run pyi-makespec --onefile --name mycli \
    --add-data "src/mycli/data:data" \
    --icon assets/icon.ico \
    src/mycli/main.py

# 3. Build
uv run pyinstaller mycli.spec

# 4. Test on a clean machine (or container)
docker run --rm -v ./dist:/dist debian:12-slim /dist/mycli --help

# 5. Sign (Windows / macOS); upload to GitHub release

Now curl -L https://github.com/me/mycli/releases/.../mycli.exe -o mycli.exe && mycli --help is one step for users — no Python install needed.


11. Pyodide / PyScript — Python in the browser

For demos, dashboards, education: run Python in WebAssembly:

html
<py-script>
import numpy as np
print(np.arange(10))
</py-script>

Pyodide ships CPython + NumPy + pandas + scikit-learn as WebAssembly. PyScript wraps it for HTML pages.

Not for everything (~10 MB initial download, no real threads, limited C extensions). Great for interactive demos.


12. Comparison summary

ToolBest forSpeedSizeComplexity
pip installOther Python devsn/atinyminimal
zipapp / shiv / pexCLIs to machines with Pythonnormalsmalllow
PyInstallerStandalone exe for end usersnormalmedium-largemedium
BriefcaseNative installers for end usersnormallargemedium-high
NuitkaMaximum performance binariesfastermediummedium-high
DockerServersnormalmedium-largelow
PyodideBrowser demosslowern/alow

Hands-on lab (2 hours)

  1. Build a 2-command CLI with typer; run via uv run.
  2. PyInstaller it (--onefile); verify on a clean machine.
  3. Add a data file (a JSON config); bundle with --add-data.
  4. Reduce the size by excluding unused modules; measure.
  5. (Optional) Nuitka the same; compare size + runtime.
  6. (Optional) Briefcase scaffold; build a tiny Toga app; package an installer.
  7. Bonus: GitHub Actions matrix that builds for 3 OSes and attaches to a release.

Common pitfalls

  1. PyInstaller working in dev but missing modules in dist/ — add to hiddenimports.
  2. Massive binaries due to bundling unused libs.
  3. Forgetting code signing — users get scary warnings.
  4. Hardcoded paths that work in the source tree but break in the bundled exe (use sys._MEIPASS for PyInstaller bundle root).
  5. Trying to cross-compile; you can't — use CI matrix builds.
  6. Shipping a debug build (Nuitka without --lto=yes).

sys._MEIPASS for bundled data

python
import sys, os
def resource_path(rel):
    base = getattr(sys, "_MEIPASS", os.path.dirname(__file__))
    return os.path.join(base, rel)

Use this for any file you --add-data'd.


Self-check

  1. When use PyInstaller vs Docker vs Briefcase?
  2. What does Nuitka do differently from PyInstaller?
  3. What's a hidden import?
  4. Why are macOS apps "damaged" without notarisation?
  5. When use Pyodide?

References

Sign in to save your progress and earn badges.