Project Setup

Distributing CLIs as Standalone Binaries

Ship a Python CLI to users without Python - compare PyInstaller, shiv zipapps, CI release matrices, and Homebrew or Scoop packaging, with the trade-offs.

Updated

pipx install mytool is the right answer for almost every Python CLI, and it stops being the right answer at exactly one point: when your users do not have Python, cannot install it, or should not have to think about it. Ops engineers pulling a binary onto a locked-down host, designers running a build tool, a CI image that should stay small — those are the cases this section is about.

TL;DR

  • Try hard to stay on pipx or uv tool install; every route below is a permanent maintenance commitment.
  • Zipapps (shiv) are one file and still need a Python on the machine.
  • Frozen binaries (PyInstaller) bundle the interpreter: no Python required, at the cost of size and start-up time.
  • Binaries cannot be cross-compiled — build each one on the platform it targets, in CI.
  • Publish checksums, and sign anything users download directly on macOS or Windows.
Four ways to ship without a pip install The distribution routes covered in this section: a frozen executable, a zipapp, cross-platform release binaries built in CI, and platform package managers. Four ways to ship without a pip install A user with no Python or no willingness to manage one Frozen binary PyInstaller — bundles the interpreter Zipapp shiv or zipapp — needs a Python CI release matrix one artifact per platform Homebrew / Scoop the package manager they already use all four start from the same wheel you already publish Reach for these only when pipx is genuinely unavailable to your audience.

Choosing a route

What each route costs A comparison of pipx, zipapp, frozen binary and platform package manager distribution across size, start-up time, whether Python is required and maintenance effort. What each route costs Route Needs Python? Size Start-up pipx / uv tool yes a few MB fastest Zipapp (shiv) yes 5–40 MB first run slower Frozen (PyInstaller) no 15–80 MB slowest Homebrew / Scoop managed for you small fastest Freezing removes the Python requirement and pays for it in size and start-up on every run.

The honest ordering is: pipx, then a package manager the audience already uses, then a zipapp, then freezing. Each step down that list adds artifacts to build, verify, sign and publish on every release — and adds a class of bug that only appears in the packaged form.

A useful test for whether you need to go further than pipx: can you write install instructions that a target user would follow without asking a question? "Install pipx, then run pipx install mytool" is fine for developers and a barrier for everyone else. If your audience is the second group, read on.

What the artifact actually weighs A bar chart comparing the size of a wheel, a zipapp and a frozen one-file binary for the same command line tool. What the artifact actually weighs wheel 1 MB zipapp (shiv) 12 MB frozen, one file 38 MB Indicative sizes for a CLI with Typer, Rich and httpx; a scientific stack multiplies all three. The interpreter and standard library are most of the difference between the last two.

Size is the visible cost; start-up is the one people feel. A frozen one-file build re-extracts its bundle on every invocation, which can add hundreds of milliseconds to a tool that was previously instant — and that is charged on --help and on every tab completion.

Zipapps: one file, still Python

A zipapp is a zip archive of your application and its dependencies with a shebang line prepended, so the operating system runs it with the interpreter named there.

pip install shiv
shiv -c mytool -o dist/mytool.pyz .        # -c names the console script to run
./dist/mytool.pyz --help

One file, no installation, and it works anywhere a compatible Python exists. The catch is the first run: shiv unpacks the dependency tree into a cache directory before it can import anything.

Subsequent runs are fast, but that first invocation can take several seconds for a large dependency tree, and it happens at exactly the wrong moment — when someone is trying the tool for the first time. Warm the cache in your installer if you have one, or say so in the output.

The standard library's own zipapp module produces something similar without the caching layer, which is fine for a tool whose dependencies are small or absent.

Frozen binaries: no Python required

PyInstaller (and Nuitka, and PyOxidizer) go further: they bundle the interpreter itself, so the result runs on a machine with no Python at all.

pip install pyinstaller
pyinstaller --onefile --name mytool src/mytool/__main__.py
./dist/mytool --version

The mechanism that makes this work — static analysis of your imports — is also its main limitation. Anything imported dynamically is invisible to it.

That includes the entry-point plugin discovery described in plugin architectures, anything loaded with importlib.import_module, and the lazy command loading that keeps startup fast. Each has to be declared:

# mytool.spec
a = Analysis(
    ["src/mytool/__main__.py"],
    hiddenimports=["mytool.commands.sync", "mytool.commands.report"],
    datas=[("src/mytool/templates", "mytool/templates")],
)

The second gotcha is paths. Inside a frozen build your package has been extracted to a temporary directory, so anything computed from __file__ points somewhere that did not exist a second ago.

Read bundled files with importlib.resources and the problem disappears — which is a good reason to use it from the start, long before anyone mentions freezing.

Building for every platform

There is no cross-compilation. A macOS binary is built on macOS, a Windows binary on Windows, and a Linux binary on a distribution old enough that its libc works everywhere you care about.

The pipeline shape that works: one job builds the wheel, a matrix of jobs each produce and verify a binary on their own platform, and a final job attaches everything to the release with checksums. Verification inside the matrix is the part people skip, and it is what stops a broken macOS artifact shipping alongside three good ones.

Package managers users already have

For developer tools, a Homebrew tap or a Scoop bucket is often a better investment than a raw download link: the manager handles trust, upgrades and uninstalls, and users already know the commands.

Platform package managers at a glance A comparison of Homebrew, Scoop, apt and the Python-native route across audience, what you maintain and how updates reach users. Platform package managers at a glance Channel Audience You maintain Homebrew tap macOS and Linux developers a formula per release Scoop bucket Windows developers a manifest per release apt / rpm servers and images packaging per distribution pipx / uv tool anyone with Python nothing extra Each extra channel is a release step you now own forever; add them for real demand, not for completeness.

The maintenance question is the important one. Every channel is a step in your release process forever, and a tap that lags two versions behind is worse than no tap at all. Automate the version and hash bump from the release workflow, or do not offer the channel.

Preparing a codebase for bundling

Most of the pain in freezing a Python application comes from three habits that are harmless in a normal install and fatal in a bundle. Fixing them costs nothing and pays off long before you freeze anything.

Read data through importlib.resources. Anything computed from __file__ assumes your source tree exists on disk in the layout you wrote it in, which stops being true inside a bundle, a zipapp or a wheel installed as a zip:

from importlib.resources import files

# works installed, frozen, zipped
template = files("mytool.templates").joinpath("report.html").read_text(encoding="utf-8")

# works only in development
template = (Path(__file__).parent / "templates" / "report.html").read_text()   # avoid

Keep dynamic imports declarable. Lazy command loading and entry-point plugins are both good patterns, and both are invisible to static analysis. Keep the registry of dotted paths in one module so that generating the hiddenimports list is mechanical rather than archaeological:

COMMANDS = {
    "sync": "mytool.commands.sync:sync",
    "report": "mytool.commands.report:report",
}

# in the spec file
hiddenimports = [target.split(":")[0] for target in COMMANDS.values()]

Detect the bundled case explicitly wherever behaviour must differ. PyInstaller sets sys.frozen, and the unpack directory is sys._MEIPASS:

import sys
from pathlib import Path

def bundle_root() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys._MEIPASS)        # the temporary extraction directory
    return Path(__file__).resolve().parent

Use it sparingly. Every branch keyed on sys.frozen is a path your ordinary tests never take, so the fewer of them the better — which is another argument for importlib.resources, since it needs no branch at all.

Verifying an artifact before anyone downloads it

A bundled build can succeed and still be broken, because the failure is a missing import that only occurs on a code path the build never exercised. The verification that catches this is a real invocation, on a clean machine, of the paths users take.

# in the release job, on the platform that produced the artifact
./dist/mytool --version
./dist/mytool --help
./dist/mytool sync --dry-run ./fixtures       # a command that touches the plugin machinery
echo '{"replicas": 2}' | ./dist/mytool apply -

Four invocations, a few seconds, and between them they exercise metadata reading, command registration, at least one dynamic import and stdin handling. Run them on a runner that has never had your project installed — a job that inherits a populated virtual environment will happily import from it and tell you nothing.

For a frozen binary specifically, add one check that the bundle is self-contained:

env -i ./dist/mytool --version        # empty environment: no PATH, no PYTHONPATH

If that fails, something in the bundle is still reaching outside it.

Keeping the release honest

Whatever routes you offer, three properties make the difference between a distribution users trust and one they work around.

One version, everywhere. The wheel, the binary, the tap formula and the Scoop manifest should all report the same number, derived from the same tag. That falls out naturally if the version is single-sourced, as described in managing CLI versioning — and drifts immediately if any channel is updated by hand.

Checksums published alongside every artifact. A binary downloaded over HTTPS from a release page is still an opaque blob; a published checksums.txt is what lets a security-conscious user adopt it, and what lets a package manager verify what it fetched.

A documented uninstall. Every route needs one and they all differ: pipx uninstall mytool, brew uninstall mytool, rm ~/.local/bin/mytool for a raw binary, and for shiv also the cache directory. Users notice tools that cannot be removed cleanly.

Finally, be deliberate about how many channels you take on. Each one is a step in every future release, a set of instructions to keep accurate, and a support surface. Two well-maintained routes beat five that lag behind — and the two that serve most audiences are pipx for people who have Python and one platform package manager for the people who do not.

Writing install instructions for several channels

Once a tool has more than one route, the README becomes the place where users decide whether to bother. The arrangement that works is one recommended path and a short list of alternatives, in descending order of how much the reader is expected to already have.

## Install

    pipx install mytool          # recommended, needs Python 3.11+

Other options:

    brew install you/tap/mytool  # macOS and Linux
    scoop install mytool         # Windows
    curl -sSL https://example.com/mytool/latest/mytool-linux-x86_64.tar.gz | tar xz

Verify a downloaded archive against the published checksums:

    sha256sum -c checksums.txt --ignore-missing

Three details are worth copying. The recommended route is first and named as such, so a reader who has Python does not go hunting through binary options. The direct download shows the verification command immediately after, rather than in a separate security section nobody reads. And each line says who it is for, so nobody installs Homebrew in order to run a Python tool on a server.

Keep the URLs stable across releases if you can — a latest path that redirects to the current version means documentation and scripts do not need updating every time you ship.

The cost side, honestly

It is worth being explicit about what taking on binary distribution means over a year, because the decision is usually made once and paid for repeatedly.

Every release grows. A wheel-only release is one job. Adding four platforms means five build jobs, four verification steps, a checksum step, a release-assets step and two package-manager bumps. Each is small; together they are the difference between a release taking two minutes of attention and twenty.

Failures become platform-specific. A bug report that says "the macOS binary crashes on start" cannot be reproduced on your Linux machine, and the frozen build is the least debuggable form of your program. Keeping a --debug path that prints the resolved bundle root and the loaded plugin list turns those reports from mysteries into one-line answers.

Security expectations rise. A binary people download is something they are trusting more directly than a package from an index with a lockfile. Checksums are the minimum; signing is expected on macOS; and a supply-chain question about how the artifact was built is entirely reasonable, which is an argument for building only in CI, from a tag, with a workflow anyone can read.

None of that is a reason to avoid binaries when your audience needs them. It is a reason to be sure they do, and to automate everything the moment you commit — because the alternative is a release process that quietly depends on one person remembering four manual steps.

Where to go next

Key takeaways

  • Standalone distribution is a response to a real constraint, not an upgrade over pipx.
  • A zipapp removes the install step; only freezing removes the Python requirement.
  • Dynamic imports and __file__ paths are the two things that break when a tool is bundled.
  • Build and verify each artifact on its own platform, in CI, on a tag.
  • Publish checksums, sign direct downloads, and automate any package-manager channel you offer.

Frequently asked questions

Is a frozen binary faster than an installed package?

No — usually slower. Freezing does not remove interpreter start-up or your imports, and a one-file build adds extraction on every run. It solves distribution to machines without Python, which is a different problem from latency. If start-up is your concern, the startup performance guide is the right place.

Will a binary built on Ubuntu run on other Linux distributions?

Only if the glibc on the target is at least as new as the one it was built against. The convention is to build on the oldest distribution you intend to support, or inside a manylinux container. Alpine and other musl-based systems need their own build entirely.

How large should I expect the binary to be?

For a typical CLI with Typer, Rich and an HTTP client, 30–50 MB frozen. Add a scientific stack and it grows quickly — NumPy and pandas alone can push it past 150 MB. Excluding unused modules in the spec file helps at the margin; it does not change the order of magnitude.

Can I ship a single binary that works on every platform?

No. Each platform needs its own artifact, which is why the release matrix exists. What you can share across all of them is the wheel — every route in this section builds from the same package, so the code itself never forks.

Should I still publish to PyPI if I ship binaries?

Yes. PyPI is how other Python projects depend on you, how pipx and uv tool install you, and how anyone builds from source. Binaries are an additional channel for a specific audience, not a replacement for the package.

What about Nuitka or PyOxidizer instead of PyInstaller?

Both are credible. Nuitka compiles to C and can produce faster, smaller results at the cost of a longer, more fragile build; PyOxidizer embeds the interpreter differently and has its own configuration model. PyInstaller remains the most widely used and best documented, which matters a great deal when a bundling problem needs debugging.

Can I ship a Docker image instead of a binary?

For server and CI audiences it is often the better answer: docker run yourorg/mytool needs no Python, no install and no platform matrix, and the image is versioned like any other artifact. It is a poor fit for a tool people run interactively against local files, where volume mounts and user permissions turn a one-word command into a paragraph.

How do I keep the frozen build from breaking silently between releases?

Run the same verification invocations in CI on every tag, not just when the bundling configuration changes. A dependency upgrade is the usual cause of a newly missing hidden import, and it arrives in a release that touched nothing about packaging at all.