Intermediate

Sharing a Python script with a colleague is easy until it has dependencies. Suddenly you need to say “first install Python, then create a virtual environment, then pip install these five packages, then run the script.” Half the time something breaks on their machine because they are on a different OS version, have conflicting packages, or simply misread one of the steps. You end up spending more time on setup instructions than on the actual tool you built.

Python’s built-in zipapp module solves the distribution problem for CLI tools and internal utilities. It bundles your application code — and optionally its dependencies — into a single .pyz file that runs with python myapp.pyz on any machine that has Python installed. No virtual environment, no pip install, no path configuration. One file, one command.

In this article you will learn how to create a basic zipapp from the command line, bundle dependencies into the archive, set a custom entry point, add a shebang line for Unix direct execution, work around the limitations of compiled extensions, and finish with a real-life example: packaging a CSV-to-JSON converter as a portable .pyz file. By the end you will be distributing Python tools as single files the way Go developers take for granted.

zipapp Quick Example

Here is the minimal workflow — a Python script bundled into a .pyz archive that runs on any machine with Python 3:

# Step 1: create the source directory
# myapp/
#   __main__.py
#   utils.py

# myapp/__main__.py
def main():
    from myapp.utils import greet
    greet("World")

if __name__ == "__main__":
    main()
# myapp/utils.py
def greet(name: str) -> None:
    print(f"Hello, {name}! Running from a .pyz archive.")
# quick_build.py -- run this once to create the archive
import zipapp
zipapp.create_archive("myapp", target="myapp.pyz", interpreter="/usr/bin/env python3")
print("Created myapp.pyz")

Then run it:

$ python myapp.pyz
Hello, World! Running from a .pyz archive.

The .pyz file is a ZIP archive with a Python shebang prepended. Python’s import system knows how to find modules inside ZIP archives (PEP 302), so all imports work exactly as if the files were on disk. The __main__.py file is the entry point that runs when you execute the archive.

What Is zipapp and When Should You Use It?

A Python zip application (.pyz) is a ZIP file that starts with a shebang line (#!/usr/bin/env python3) and contains a __main__.py as its entry point. Python has supported executing ZIP archives since Python 2.6 (PEP 441). The zipapp module, added in Python 3.5, automates the creation process so you do not have to build the ZIP manually.

Distribution methodSingle fileDependencies bundledPython requiredCross-platform
zipapp (.pyz)YesYes (pure Python)YesYes
PyInstallerYesYes (incl. C extensions)NoNo (per-OS build)
pip packageNoVia dependenciesYesYes
Docker imageNoYes (everything)NoPartial

zipapp is the right choice for internal tools, CI scripts, and developer utilities where you know the target machine has Python and you want to share one file over Slack or email. It is not suitable for end-user consumer apps (use PyInstaller or packaging) or for tools that require C extension dependencies (use Docker).

Creating Archives from the Command Line

The zipapp module has a command-line interface that handles the most common cases without writing any Python:

# Command-line usage (run in your terminal, not Python)

# Basic: package a directory into a .pyz
$ python -m zipapp myapp -o myapp.pyz

# With a custom entry point (module:callable syntax)
$ python -m zipapp myapp -o myapp.pyz -m "myapp.cli:main"

# With a shebang for direct Unix execution (chmod +x myapp.pyz first)
$ python -m zipapp myapp -o myapp.pyz -p "/usr/bin/env python3" -m "myapp.cli:main"

# Inspect an existing archive's entry point
$ python -m zipapp --info myapp.pyz
Interpreter: /usr/bin/env python3
Main: myapp.cli:main

The -m "module:callable" flag writes a __main__.py shim that imports and calls the specified function. This means you do not need to write a __main__.py yourself — zipapp generates it. The generated __main__.py looks like:

# Generated __main__.py (created by -m flag)
import sys
sys.exit(__import__("myapp.cli").cli.main())

This is exactly what you would write manually, but you get it for free by specifying the entry point flag.

Bundling Dependencies into the Archive

To bundle third-party pure-Python packages, install them into a subdirectory, then include that directory when building the archive. The key is that everything inside the archive ZIP is available on the import path when the archive runs:

# Project layout for a CLI that uses 'requests'
myapp/
├── myapp/
│   ├── __init__.py
│   └── cli.py
└── deps/                # we'll install pure-Python deps here

# Install dependencies INTO the project, not site-packages
python -m pip install --target deps requests

# Copy our app into the same staging directory
cp -r myapp build/
cp -r deps/* build/

# Build the archive — 'build/' becomes the zipapp root
python -m zipapp build -p "/usr/bin/env python3" -m "myapp.cli:main" -o myapp.pyz

# Run it
./myapp.pyz

The trick is that --target deps installs packages as plain folders (no virtualenv wrapper), so they can be copied alongside your code into the staging directory. At runtime, Python’s import system finds them inside the ZIP because the archive itself is on sys.path.

Important caveat: this works for pure-Python packages only. Anything that ships a compiled C extension (numpy, lxml, psycopg2, Pillow, etc.) needs the binary .so/.pyd files on disk — they can’t be imported from inside a ZIP without extracting first. For those, you need a heavier tool like PyInstaller, or you bundle a stub that extracts to a temp dir on first run.

zipapp vs PyInstaller vs Other Single-File Tools

If you’re shipping Python to a machine, here’s how the major options stack up:

ToolOutputNeeds Python on target?Handles C extensions?Typical size
zipapp (stdlib)Single .pyz fileYesNo (pure Python only)10–500 KB
PyInstallerStandalone .exe / binaryNoYes10–80 MB
ShivSingle .pyz (better than zipapp)YesLimited (extracts on first run)1–20 MB
PEXSingle .pex (built on zipapp)YesYes (extracts on first run)1–50 MB
NuitkaCompiled C binaryNoYes20–100 MB

Rule of thumb: zipapp for internal tools and dev-environment scripts. PyInstaller or Nuitka when the target machine doesn’t have Python. Shiv when you want zipapp’s simplicity but need real dependency bundling with C extensions.

A Real Example: Shipping a Self-Contained CLI

Here’s an end-to-end build of a small command-line tool. The CLI fetches a URL and prints the response body length:

# File: myapp/cli.py
import sys
import urllib.request

def main():
    if len(sys.argv) != 2:
        print("Usage: myapp.pyz URL", file=sys.stderr)
        return 1
    url = sys.argv[1]
    with urllib.request.urlopen(url, timeout=10) as r:
        body = r.read()
    print(f"{len(body)} bytes from {url}")
    return 0

if __name__ == "__main__":
    sys.exit(main())
# Build script (build.sh)
#!/usr/bin/env bash
set -e

rm -rf build myapp.pyz
mkdir build
cp -r myapp build/

python -m zipapp build \
    -p "/usr/bin/env python3" \
    -m "myapp.cli:main" \
    -c \
    -o myapp.pyz

echo "Built myapp.pyz ($(stat -c%s myapp.pyz) bytes)"

# Test it
./myapp.pyz https://example.com

The -c flag enables compression — useful for shipping over slow networks. For internal use you can leave it off and gain a small speed boost since the archive doesn’t have to decompress at import time.

Common Pitfalls

  • Forgetting the shebang. Without -p "/usr/bin/env python3" the archive isn’t directly executable. You have to run it with python myapp.pyz, which defeats most of the point.
  • Trying to bundle compiled wheels. If you see ImportError: cannot import name '_speedups' at runtime, you have a C extension in your dependency tree. Switch to Shiv, PEX, or PyInstaller — zipapp can’t help.
  • Relative file paths inside the archive. Code that does open("data.txt") assumes a regular filesystem. Inside a zipapp, you need importlib.resources.files(__package__).joinpath("data.txt").read_text(), which works whether you’re zipped or not.
  • Confusing __file__. os.path.dirname(__file__) inside a zipapp returns a path that LOOKS like a directory but isn’t a real filesystem location. Refactor anything that walks __file__ to use importlib.resources instead.
  • The archive isn’t a wheel. You can’t pip install a .pyz file — it’s a runnable bundle, not a package. If you want pip-installable, build a wheel separately.

FAQ

Q: Does the target machine need the same Python version?
A: It needs a Python that can run your code — typically the same MAJOR version. A .pyz built using 3.10 syntax (match statements, etc.) won’t run on 3.9. The shebang line /usr/bin/env python3 just picks “any Python 3” — be specific with python3.11 if you rely on newer features.

Q: Can I edit the contents of a .pyz file?
A: Yes — it’s a regular ZIP file. unzip -l myapp.pyz lists contents, and standard ZIP tools can update individual files. Useful for hot-fixes when you can’t rebuild from source.

Q: Is zipapp faster or slower than running from source?
A: Slightly slower on the first run because Python has to read the ZIP table of contents, and slightly faster on subsequent runs since the OS caches the single file in memory. The difference is in milliseconds for normal apps.

Q: How big can a zipapp get?
A: Practically, anything up to a few hundred MB works. The format itself has a 4GB limit (ZIP64). For larger payloads, embed data outside the archive and reference it from a known location.

Q: Why use zipapp over a single-file Python script?
A: Two reasons: (1) you have multiple modules, (2) you want to bundle a few pure-Python dependencies. For genuinely single-file scripts, just ship the .py — that’s the simplest deployment in existence.

Wrapping Up

Python’s zipapp module is one of those tools that does exactly one thing well: bundle pure-Python code into a single executable file. It’s been in the standard library since 3.5 and there’s no setup, no extra dependencies, no hidden magic. For internal tools, build artifacts, and dev environments where Python is already installed, .pyz files are the simplest way to ship code that “just runs”. For everything more complex — C extensions, target machines without Python, true single-binary distribution — reach for Shiv, PyInstaller, or Nuitka instead.

The official zipapp documentation covers the API in full, including the programmatic interface (zipapp.create_archive()) for build scripts.