What does Python's zipapp module produce, and how does the interpreter run a .pyz archive?
answer
- Ship one file, not a directory
- A zip archive sitting on sys.path
- The archive root needs __main__.py
- No interpreter travels inside it
- zipimport cannot load binary modules
basics
~20 spython -m zipapp zips a source directory containing a main.py into one .pyz file. CPython imports out of a zip placed on sys.path, so running the archive executes that main.py. The host still needs its own Python.
solid answer
~40 s`python -m zipapp src -o app.pyz` writes the contents of a directory into a zip; `zipapp.create_archive` is the same operation from code. When you run `python app.pyz`, CPython puts the archive path at the front of `sys.path` and `zipimport` reads modules straight out of the zip, so the `__main__.py` at the archive root runs as the program. Passing `-p` writes a shebang line into the file and marks it executable, so it can be launched directly on Unix. Two limits shape when it is usable: the archive carries **no interpreter**, so the target machine needs a compatible Python already, and `zipimport` cannot load compiled extension modules, so any dependency with a binary module has to live outside the archive. Pure-Python dependencies are vendored into the source directory first, typically with `pip install --target`.
code
python · 15 linesimport subprocess
import sys
import tempfile
import zipapp
from pathlib import Path
src = Path(tempfile.mkdtemp()) / "app"
src.mkdir()
(src / "__main__.py").write_text("print('hello from the archive')\n")
archive = src.parent / "app.pyz"
zipapp.create_archive(src, archive)
result = subprocess.run([sys.executable, str(archive)], capture_output=True, text=True)
print(result.stdout.strip())go deeper
Recall the shape: a .pyz is a zip with a main.py at its root that Python can run directly. Be able to say that it holds your code, not a Python interpreter.
Explain the two mechanics that make it work — a zip on sys.path served by zipimport, and the rule that running a directory or archive executes its main.py — and name the vendoring step that gets dependencies inside.
Show judgment about when the shape is wrong: binary dependencies, resource files read through paths, unclear interpreter guarantees on the target. Be ready to describe the build pipeline that produces the archive reproducibly.
Own the tradeoff against heavier artefacts. A one-file archive is cheap and atomic but pins nothing about the interpreter, so argue for where in the estate it is an acceptable default and where the interpreter must travel with the code.
## The one fact everything rests on CPython's import system can read from a zip archive. If an entry on `sys.path` names a `.zip` file rather than a directory, `zipimport` opens it and searches inside for modules and packages exactly as the filesystem finder would search a directory. That capability is old and unglamorous, and `zipapp` is a thin standard-library wrapper over it. The second fact is the one that makes an archive *runnable*. When you hand the interpreter a path on the command line that is a directory or a zip file rather than a `.py` file, CPython prepends that path to `sys.path` and then executes `__main__.py` from it. So `python app.pyz` and `python ./somedir/` work by the same rule. The archive root must therefore contain a `__main__.py`; a package `__init__.py` at the root is not a substitute, and neither is a `pyproject.toml`, a `setup.py`, or an entry-point declaration. ## Building one ```console python -m zipapp build/app -o dist/app.pyz -p "/usr/bin/env python3" ``` `build/app` is an ordinary directory; every file under it is copied into the zip. `-m "myapp.cli:main"` asks `zipapp` to generate the `__main__.py` for you (it writes a tiny module that imports `myapp.cli` and calls `main()`), which is convenient when your code is already a package with a callable entry point. `-p` records an interpreter as a shebang line at the front of the file and sets the executable bit, so `./dist/app.pyz` runs on Unix without naming `python`; `python -m zipapp --info dist/app.pyz` reads that shebang back. `-c` compresses members, trading a little startup CPU for a smaller file. From code the same thing is `zipapp.create_archive(source, target, interpreter=..., main=..., compressed=...)`, and `zipapp.get_interpreter` reads the shebang of an existing archive. Note what `zipapp` does *not* do: it does not resolve dependencies, download anything, or consult your project metadata. It zips a directory. Getting your dependencies into that directory is a separate step — typically `pip install --target build/app -r requirements.txt` followed by copying your own package in beside them, so that the archive root ends up holding your package, the vendored packages, and `__main__.py` side by side. ## What you inherit from zipimport Several real constraints follow from the fact that the code lives in a zip and not on the filesystem. **Compiled extension modules cannot be imported from the archive.** Loading one means giving the platform's dynamic loader a real file path, and a member of a zip has no path a loader can open. Any dependency that ships a binary module must be extracted to a directory at startup and that directory added to `sys.path`, which usually means a zipapp is the wrong shape for that program. **Filesystem access to your own files breaks.** `__file__` for a module inside the archive is a path *through* the archive, and `open()` on it fails. Data shipped alongside the code has to be read through the resource-reading API in `importlib.resources` rather than by building a path from `__file__`. **Bytecode is not cached back.** `zipimport` will happily use a `.pyc` that sits beside the `.py` inside the archive, but it cannot write a `__pycache__` into a read-only zip, so an archive of pure sources recompiles on every cold start. Running `python -m compileall -b` over the build directory before zipping puts legacy-layout `.pyc` files next to the sources so the archive ships bytecode. **`sys.path[0]` is the archive itself**, which is what lets the vendored packages inside it be found, and also means a stray same-named module inside the archive shadows the host's installed one. ## When to reach for it A `.pyz` is the smallest useful deployment artefact Python has: one file, `scp` it and run it, no install step, no virtual environment on the target, trivially atomic to replace and roll back. That makes it a good fit for an internal command-line tool, an operations script with a couple of pure-Python dependencies, or a job that runs on hosts your organisation already guarantees carry a particular Python. It is a bad fit as soon as the dependency set includes binary wheels, as soon as the target might not have a compatible interpreter, or as soon as you need the artefact to pin the interpreter version itself. Those are the cases the other shapes on this leaf exist for: a bundle that carries the interpreter with the code, or an image that carries the whole installed environment. The decision is not about elegance — it is about what the target machine is guaranteed to already have.
- Why can a dependency that ships a compiled extension module not be imported from inside a .pyz archive?`zipimport` can only read source and bytecode out of the archive. Loading a compiled extension means handing a real filesystem path to the platform's dynamic loader, and a zip member has no such path. The workarounds are to extract those packages to a real directory at startup and add it to `sys.path`, or to pick a different delivery shape — a bundle that unpacks itself, or an image carrying an installed environment — which is usually the honest answer.
- How would you stop a .pyz archive from recompiling its sources on every cold start?Ship bytecode inside the archive. `zipimport` will use a `.pyc` that sits beside the `.py` in the zip, but it cannot write a `__pycache__` back into a read-only archive. Run `python -m compileall -b` over the build directory first — `-b` writes the `.pyc` next to the source in the legacy layout rather than into `__pycache__` — and then build the archive. Compile with the same interpreter version the target will run.
A .pyz is a self-contained document, not a self-contained appliance: it holds everything you wrote, but it still needs the reader — a compatible Python — to already be installed on the machine.
saying these in an interview costs you the question
- Claims a .pyz bundles the Python interpreter with the code
- Thinks any wheel can simply be dropped into the archive
- Confuses a .pyz with a wheel or an sdist
- Believes zipapp resolves and installs dependencies for you
- Expects open(__file__) to work for files inside the archive