Project Setup

Shipping a CLI as a Zipapp with shiv

Build a single-file Python CLI with shiv - how zipapps work, first-run caching, pinning the interpreter, and when a .pyz beats a pipx install.

Updated

A zipapp is a zip archive of your application and its dependencies with a shebang line on the front. Copy the file, mark it executable, run it. There is no install step and no virtual environment — but there is still a Python interpreter, which is exactly the trade that makes zipapps interesting for locked-down hosts and scratch containers.

TL;DR

  • shiv -c mytool -o dist/mytool.pyz . produces one runnable file.
  • The machine still needs a compatible Python; the zipapp bundles your dependencies, not the interpreter.
  • The first run unpacks into a cache directory and is slow; later runs are fast.
  • Pin --python to the shebang you want, and build on the oldest interpreter you support.
  • Prefer a wheel and pipx whenever the user can run an installer.

Prerequisites

A project that builds a wheel and declares a console script in [project.scripts], plus shiv:

uv tool install shiv        # or: pipx install shiv

How a zipapp actually works

What is inside a zipapp A zipapp is a zip archive containing the application and its dependencies, prefixed with a shebang line so the operating system runs it with Python. What is inside a zipapp mytool.pyz a zip with a shebang #!/usr/bin/env python3 the first line, not zipped __main__.py the entry point mytool/ + dependencies the whole tree One file, no install step Still needs a Python on the machine shiv caches the unpacked tree after first run The shebang is why a .pyz can be marked executable and run directly.

Python has been able to execute a zip archive containing __main__.py since 3.5, and zipapp prepends a shebang so the file can be executed directly. shiv builds on that: it installs your project and its dependencies into a directory, zips the result, and adds a bootstrap __main__.py that puts the bundled site-packages on sys.path before importing your entry point.

shiv -c mytool -o dist/mytool.pyz .
chmod +x dist/mytool.pyz
./dist/mytool.pyz --help

-c mytool names the console script from your [project.scripts] table — shiv looks it up in the installed metadata, so the entry point you already ship is what runs. Building from a requirements file or a published package works too:

shiv -c mytool -o dist/mytool.pyz mytool==1.4.0        # from an index
shiv -c mytool -o dist/mytool.pyz -r requirements.txt  # a pinned set

The first-run cache

Why the first shiv run is slower On first execution a shiv zipapp unpacks its dependencies into a cache directory; subsequent runs reuse that cache and start quickly. Why the first shiv run is slower User mytool.pyz Cache dir App first run unpack site-packages import and run later runs: straight from the cache Warm the cache in your installer or document the first-run delay; otherwise it reads as a hang.

Python cannot import compiled extensions from inside a zip, so shiv extracts the bundled site-packages into a cache directory the first time the zipapp runs — by default under ~/.shiv/. That first invocation can take several seconds for a large dependency tree; every subsequent one reuses the cache and starts quickly.

Two knobs matter in practice:

SHIV_ROOT=/opt/mytool/cache ./mytool.pyz --version     # choose where the cache lives
shiv --compressed -c mytool -o dist/mytool.pyz .       # smaller file, slightly slower unpack

Setting SHIV_ROOT explicitly is worth doing in images and on shared hosts, where a per-user home directory may be read-only or wiped between runs. A zipapp that re-extracts on every invocation because its cache directory vanished is the slowest possible version of this idea.

If the first-run delay is unacceptable — a health check with a two-second timeout, say — warm it during installation:

install -m 0755 mytool.pyz /usr/local/bin/mytool
/usr/local/bin/mytool --version >/dev/null    # unpack now, not on first real use

Pinning the interpreter

The shebang decides which Python runs your code, and the default is whatever python3 resolves to on the target. Be explicit:

shiv -c mytool -o dist/mytool.pyz --python "/usr/bin/env python3.11" .

Two rules keep this predictable. Build on the oldest interpreter you support, because bytecode and some wheels are forward-compatible but not backward. And if your dependencies include compiled extensions, the wheels bundled into the zipapp are platform-specific — a zipapp built on macOS arm64 will not run on Linux x86-64, even though the format itself is portable.

Zipapp or ordinary install? A decision diagram: ship a zipapp when users cannot run an installer but do have Python, otherwise publish a wheel and let pipx install it. Zipapp or ordinary install? Can the user run an install command on the target machine? Yes — a laptop, a dev box, anywhere pipx works Wheel + pipx faster and upgradable No — a locked host, a scratch container, a shared runner Zipapp copy one file and run it A zipapp trades upgradability for the ability to arrive as a single copied file.

Where zipapps genuinely win

Three situations, all with the same shape: the machine has Python but you cannot run an installer on it.

Locked-down hosts. No network access to an index, no permission to create virtual environments, but scp works and python3 exists. A .pyz arrives as one file.

Ephemeral containers and CI steps. Copying a single artifact into an image layer is cheaper and more cacheable than a pip install that resolves dependencies on every build.

Internal tools with no index. Publishing to a private PyPI is real infrastructure; putting a .pyz in object storage with a version in the filename is not.

Where they lose is upgrades. There is no pipx upgrade equivalent — the user replaces the file — so a zipapp is best for tools distributed by a system that already manages files, such as configuration management or an image build.

UX considerations

Name the file with its version. mytool-1.4.0.pyz in a shared location makes it obvious what is running and lets both versions coexist during a rollout. A bare mytool.pyz that changes under people is difficult to reason about.

Report that it is a zipapp. Frozen and zipped builds get copied around and lose provenance, so include the form in --version output. sys.argv[0] ends in .pyz when running this way, which is enough to detect.

Document the cache. The first-run delay and the SHIV_ROOT variable belong in your install instructions; without them, a user whose first invocation takes eight seconds concludes the tool is broken.

Testing the behaviour

Verify the artifact by running it somewhere your project is not installed:

shiv -c mytool -o dist/mytool.pyz .

env -i PATH=/usr/bin:/bin SHIV_ROOT=/tmp/shiv ./dist/mytool.pyz --version
env -i PATH=/usr/bin:/bin SHIV_ROOT=/tmp/shiv ./dist/mytool.pyz report --period 7d

The empty environment is the important part: it proves the zipapp is self-contained rather than importing your project from the working directory. Running a lazily loaded command exercises the dynamic-import path, which is the one thing likely to be missing.

In CI, add the same steps to the release job and upload the .pyz as a release asset alongside the wheel.

Conclusion

A zipapp is the smallest possible distribution that still carries your dependencies: one file, no installer, no environment. It does not remove the Python requirement — only freezing does that — but for hosts that have Python and will not run pip, it is the least machinery that works.

Frequently asked questions

Does a zipapp include the Python interpreter?

No. It bundles your code and its dependencies; the shebang points at an interpreter that must already exist on the machine. If your users have no Python at all, you need a frozen binary instead.

Can I run a zipapp on Windows?

Yes, with python mytool.pyz, since Windows does not use shebangs. The Python launcher associates .pyz files by default on most installations, so double-clicking often works too — but for a CLI the documented form should be the explicit one.

What is the difference between shiv and the stdlib zipapp module?

zipapp produces the archive; it does not install dependencies for you or handle extension modules, so it suits a tool with no third-party requirements. shiv adds dependency installation, the first-run cache and interpreter pinning, which is what makes it practical for real projects.

Why is my zipapp huge?

Because it contains every dependency, including any compiled wheels. Check what went in with unzip -l mytool.pyz; the usual surprise is a transitive dependency pulling in a scientific stack. --compressed helps the download size at a small cost in unpack time.

Can I ship several commands in one zipapp?

Yes — the .pyz runs one console script, but that script can be your root command group with all its subcommands. If you genuinely need two separate executables, build two zipapps from the same project, each with a different -c.

How do I verify a zipapp is self-contained?

Run it with an empty environment and a scratch cache: env -i PATH=/usr/bin:/bin SHIV_ROOT=/tmp/x ./mytool.pyz --version. If that works from a directory that contains none of your source, nothing is leaking in from the build machine. It is the same check worth running in CI on the artifact you are about to publish.

Can a zipapp read files next to itself?

Yes, but compute the location from sys.argv[0] rather than __file__, which points inside the archive. In general prefer explicit paths supplied by the user or a documented configuration directory — a tool that expects files beside its own binary is awkward to place on a PATH.

How do zipapps interact with lazy command loading?

Well, and for the same reason they interact badly with freezing: nothing is analysed statically. shiv bundles whatever the install produced, so a module resolved by name at run time is present as long as it was part of the package. That makes a zipapp a gentler distribution format for a plugin-friendly CLI than a frozen binary, which needs every dynamic import declared.

Should I publish the zipapp alongside the wheel on every release?

If the audience that needs it is real, yes — build it in the same release job, name it with the version, and attach it to the release. Producing it only on request is how it drifts: the first person who asks discovers that the format broke three versions ago.