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
--pythonto 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
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
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.
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.