You need to give a Python CLI to people who do not have Python — or who should not have to care which Python they have. A standalone binary solves it: one file (or one folder) that runs on a machine with no interpreter installed. Two tools dominate. PyInstaller bundles your bytecode, its dependencies and a copy of the Python interpreter into an executable. Nuitka compiles your Python to C, builds it with a C compiler, and links it against the Python runtime. Both produce binaries that run without Python; they differ in build time and toolchain requirements, in how easily the result can be inspected, and sometimes in startup performance. This guide compares them for command-line tools specifically, shows a working build with each, and gives a way to decide based on measurements rather than folklore. It belongs to the distributing CLIs as standalone binaries topic.
Prerequisites
- A CLI with a single entry function, packaged normally (see writing pyproject.toml metadata for a CLI).
- For Nuitka, a C compiler: gcc or clang on Linux and macOS, MSVC (or MinGW, which Nuitka can download) on Windows.
- Builds happen per platform — neither tool cross-compiles — so you need a Linux, macOS and Windows machine or CI runner for each target. The CI matrix is in building cross-platform release binaries in CI.
How each tool works
PyInstaller analyses your imports, collects the bytecode of every module it finds plus the shared libraries they need, and packs them with a bootloader and a copy of the Python runtime. In one-folder mode the result is a directory with an executable and its libraries; in one-file mode that directory is appended to the executable and extracted to a temporary location on each run. The detailed walkthrough is in bundling a Python CLI with PyInstaller.
Nuitka translates each Python module into C code that performs the same operations through the CPython API, compiles that C with a real compiler, and links it into an executable. Standard-library and third-party modules are compiled too (or included as bytecode where compilation is not possible). Standalone mode produces a folder; onefile mode packs that folder into a single self-extracting executable, similar in shape to PyInstaller's.
Side by side
A few points deserve expansion.
Build time and toolchain. PyInstaller builds a typical CLI in well under a minute with nothing but Python. Nuitka builds take several minutes — sometimes much longer for large dependency trees — and need a working C compiler on every build machine. In CI that is manageable; on developers' laptops, especially Windows ones, it is a real cost.
Startup. Both onefile modes pay to unpack their payload to a temporary directory on every start, which dominates startup for small tools. Nuitka can cache the unpacked files between runs (--onefile-tempdir-spec pointing at a cache directory), which removes most of that cost; PyInstaller's one-folder mode avoids unpacking entirely. For compiled code itself, Nuitka is often somewhat faster, but CLIs usually spend their time importing and waiting on I/O, so measure your own tool rather than assuming.
Inspectability. A PyInstaller binary contains bytecode that tools can extract and decompile with little effort. Nuitka's output is compiled C, which is considerably harder to reverse-engineer. Neither is protection for secrets — never embed credentials in any binary — but if you distribute a commercial tool and would rather its source not be trivially readable, Nuitka raises the bar.
Compatibility. Both handle most pure-Python dependencies automatically, and both need hints for code that imports modules dynamically — plugin systems via entry points, lazy-loaded subcommands, packages that locate data files at runtime. The hints differ in syntax but not in kind.
The recipe: the same CLI, both ways
A tiny launcher module gives both tools a script to start from, so the build does not depend on how your package is installed:
# packaging/launcher.py
from mytool.cli import app
if __name__ == "__main__":
app()
PyInstaller:
uv run --with pyinstaller pyinstaller packaging/launcher.py \
--name mytool --onefile --console \
--collect-data mytool \
--hidden-import mytool.commands.deploy \
--hidden-import mytool.commands.report
./dist/mytool --version
Nuitka:
uv run --with nuitka python -m nuitka packaging/launcher.py \
--onefile --output-filename=mytool --output-dir=build \
--include-package=mytool \
--include-package-data=mytool \
--onefile-tempdir-spec="{CACHE_DIR}/mytool/{VERSION}" \
--assume-yes-for-downloads
./build/mytool --version
The flags map onto each other: --collect-data / --include-package-data bundle templates and other package files loaded with importlib.resources; --hidden-import / --include-package make sure lazily imported command modules are included even though no static import reaches them. Nuitka's --onefile-tempdir-spec with a cache path keeps the unpacked files between runs, trading a little disk space for much faster starts.
Plugins discovered through entry points need extra care with both tools: the plugin packages must be bundled explicitly, and their distribution metadata (which holds the entry points) must be included — --copy-metadata in PyInstaller, --include-distribution-metadata in Nuitka.
Choosing
Start with PyInstaller. It is quick to adopt, builds fast, needs no compiler, and has years of accumulated hooks for popular libraries. Move to Nuitka when you have a concrete reason: measured startup or runtime gains that matter to your users, a wish to make the shipped code harder to inspect, or trouble with a library that Nuitka happens to handle better. Keep the decision reversible by keeping build configuration in one script per tool and the launcher module shared between them.
Also consider whether you need a binary at all. If your users have Python, installing with pipx or uv tool is simpler for everyone, and a zipapp built with shiv gives single-file distribution without freezing the interpreter.
UX considerations
- Ship one-folder builds where you can. Package managers (Homebrew, Scoop,
.deb) install folders happily, and one-folder builds start fastest because nothing is unpacked. Keep onefile for "download and run" distribution. - Keep
--versionanddoctorworking in the binary. They are how users and you confirm what they are running; see thedoctorcommand in smoke-testing the built wheel in CI. - Sign and notarise on macOS and Windows. Unsigned binaries trigger Gatekeeper and SmartScreen warnings that make users distrust a perfectly good tool. Both tools produce binaries that can be signed with the platform's standard tooling.
- Watch temp directories. Onefile binaries extract to temporary space; systems that mount
/tmpwithnoexecbreak them. Document the environment variable or option that moves extraction elsewhere.
Testing the behaviour
Test the binary, not the source, on a machine or runner without Python, and measure startup so the comparison between tools is grounded:
# Functional smoke test of the frozen binary
./dist/mytool --version
./dist/mytool doctor # imports every lazy command, loads every resource
./dist/mytool --help > /dev/null
# Startup, measured rather than guessed (install hyperfine separately)
hyperfine --warmup 3 './dist/mytool --version' './build/mytool --version'
The doctor run is what catches missing hidden imports and data files — the failures unique to frozen builds, which appear only when a user reaches the command that needed the missing module. Run it in CI on each platform after building, and fail the release if it fails. For startup comparisons, run both binaries on the same machine several times; single measurements of onefile executables vary a lot with disk cache state.
Conclusion
PyInstaller and Nuitka both turn a Python CLI into something users can run without Python. PyInstaller bundles bytecode and an interpreter quickly with no compiler; Nuitka compiles to C, taking longer and needing a toolchain, in exchange for output that is harder to inspect and sometimes faster. For most CLIs PyInstaller is the right default; switch when a measurement or a requirement says so. Either way, include lazy imports and package data explicitly, prefer one-folder builds for package managers, sign what you ship, and smoke-test the binary itself on every platform.
Frequently asked questions
Can I build a Linux binary on macOS, or a Windows binary on Linux?
No — neither tool cross-compiles. Build on each target platform, typically with a CI matrix of runners. For Linux, build on the oldest distribution you support, because binaries link against the system's C library and run on that version or newer.
Why is my onefile binary slow to start?
It extracts its contents to a temporary directory on every run. Use one-folder mode, or with Nuitka point --onefile-tempdir-spec at a cache directory so extraction happens once per version.
Do antivirus tools flag these binaries?
Occasionally, especially PyInstaller onefile builds, because the bootloader pattern resembles some malware packers. Code signing reduces false positives substantially; submitting false positives to vendors fixes individual cases.
What about PyOxidizer or other tools?
PyOxidizer is no longer actively developed. Briefcase targets application installers more than CLIs. For command-line tools today, PyInstaller and Nuitka are the practical choices, with shiv or plain zipapps when a Python interpreter is available.