PyInstaller bundles your code, its dependencies and the Python interpreter into one executable, so a user with no Python can run your tool. The build itself is one command; making it reliable comes down to two things static analysis cannot see — dynamic imports and file paths.
TL;DR
pyinstaller --onefile --name mytool src/mytool/__main__.pyproducesdist/mytool.- Commit a spec file rather than repeating flags; it is the only place complex builds stay readable.
- Declare anything imported dynamically in
hiddenimports, and bundle data indatas. - Read bundled files with
importlib.resources, never from__file__. --onefilere-extracts on every run;--onedirstarts faster and is easier to debug.
Prerequisites
A working CLI installed in a virtual environment, and PyInstaller in that same environment:
uv add --dev pyinstaller # or: pip install pyinstaller
Build from an environment containing exactly your runtime dependencies. Freezing from a kitchen-sink environment produces a much larger binary and hides missing declarations, because everything happens to be importable.
A first build
pyinstaller --onefile --name mytool --clean src/mytool/__main__.py
./dist/mytool --version
Point it at __main__.py rather than at the console script. The generated shim is an installation
artifact that assumes a specific environment layout; __main__.py is your own three-line entry
point and freezes cleanly:
# src/mytool/__main__.py
from mytool.cli import app
def main() -> None:
app()
if __name__ == "__main__":
main()
Once the build works, move the configuration into a spec file and commit it. The spec is a Python script, so it can compute values — which is how the hidden-import list stays in sync with your command registry:
# mytool.spec
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = collect_submodules("mytool.commands") + ["mytool.plugins"]
a = Analysis(
["src/mytool/__main__.py"],
pathex=["src"],
hiddenimports=hiddenimports,
datas=[("src/mytool/templates", "mytool/templates")],
excludes=["tkinter", "test", "pytest"],
)
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, a.binaries, a.datas, name="mytool", console=True, upx=False)
pyinstaller mytool.spec --clean --noconfirm
excludes is worth populating: tkinter alone adds several megabytes to a tool that will never
draw a window.
The imports PyInstaller cannot see
Static analysis follows import statements. It does not follow importlib.import_module(name)
where name is a string, and it does not know that entry_points() will return anything.
That means the two patterns this handbook otherwise recommends — lazy command loading and entry-point plugins — both need declaring:
# The registry your LazyGroup reads
COMMANDS = {
"sync": "mytool.commands.sync:sync",
"report": "mytool.commands.report:report",
}
# mytool.spec — derive the list rather than maintaining a second copy
import tomllib, pathlib
from mytool.registry import COMMANDS
hiddenimports = sorted({target.split(":")[0] for target in COMMANDS.values()})
Deriving it is the point. A hand-maintained list in the spec file is a second source of truth that
falls behind the day someone adds a command, and the symptom is a ModuleNotFoundError that only
happens for users.
For third-party packages that load things dynamically — httpx transports, database drivers,
rich themes — PyInstaller ships hooks that already know. When one is missing, the fix is either
a hiddenimports entry or a small hook file of your own in a hookspath directory.
Paths inside a bundle
At run time a one-file build extracts itself into a temporary directory and sets sys._MEIPASS to
it. Anything that assumed your source tree exists on disk is now wrong:
# breaks once frozen
template = (Path(__file__).parent / "templates" / "report.html").read_text()
# works installed, zipped and frozen
from importlib.resources import files
template = files("mytool.templates").joinpath("report.html").read_text(encoding="utf-8")
importlib.resources needs no branch and no knowledge of freezing, which is why it is worth
adopting long before you bundle anything. When you genuinely need the bundle root — to locate a
sibling binary you shipped, say — read it explicitly:
import sys
from pathlib import Path
def bundle_root() -> Path:
return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
Note what does not change: the user's working directory. Relative paths the user typed still resolve against where they ran the command, which is correct and occasionally surprising when you are debugging a path problem.
One file or one directory
--onefile is a single executable that unpacks its whole bundle to a temporary directory on
every invocation. For a tool with a large dependency tree that can add hundreds of
milliseconds, charged on --help and on every tab completion.
--onedir produces a directory containing the executable and its libraries. It starts much
faster, it is far easier to debug — you can look at what was actually included — and it ships as a
tar.gz or zip, which is barely less convenient than a bare file.
The usual compromise: --onedir inside an archive for the download page, and --onefile only if
you specifically need a curl … | sh-style install.
UX considerations
Three details make a frozen tool feel finished.
Say what version and build this is. Frozen binaries get copied around and lose their
provenance, so --version should print more than a number:
typer.echo(f"mytool {get_version()} ({'frozen' if getattr(sys, 'frozen', False) else 'source'})")
Keep start-up honest. If the extraction cost is unavoidable, make sure nothing else is added to it — the lazy-import discipline from startup performance matters more in a frozen build, not less.
Expect the antivirus question. Unsigned one-file executables are a common false-positive for
Windows Defender and SmartScreen, because self-extracting binaries look like packers. Signing is
the real fix; a --onedir build in a zip is a partial mitigation.
Testing the behaviour
The build succeeding proves almost nothing. What proves the bundle works is running the paths that use dynamic imports and bundled data:
./dist/mytool --version
./dist/mytool --help
./dist/mytool report --period 7d # a lazily-loaded command
./dist/mytool render --template report # reads a bundled data file
env -i ./dist/mytool --version # empty environment: nothing leaks in from outside
Run those in the release job, on the platform that produced the artifact, on a runner that has never had your project installed. A job with your package importable will happily satisfy a missing hidden import from the environment and tell you nothing.
Conclusion
PyInstaller is reliable once you accept what it cannot infer. Commit a spec file, derive the hidden
imports from the same registry your code uses, read bundled data through importlib.resources, and
verify the artifact by running the interesting commands rather than by checking that the build
exited zero.
Frequently asked questions
Why does my frozen build fail with ModuleNotFoundError?
Something is imported by name rather than by an import statement — a lazily loaded command, a
plugin, an optional backend. Add it to hiddenimports, or better, derive the list from the registry
that drives the dynamic import so the two cannot drift.
Can I build a Windows binary on Linux?
No. PyInstaller bundles the platform's interpreter and libraries, so each target needs a build on that platform. A CI matrix is the standard answer; Wine is occasionally suggested and is not worth the maintenance.
How do I make the binary smaller?
Freeze from a minimal environment, populate excludes with things you never use (tkinter,
test, notebook machinery), and check what got pulled in with --onedir before compressing. UPX
compression shrinks the file further but slows start-up and increases antivirus false positives —
usually a bad trade for a CLI.
Does PyInstaller work with Typer and Rich?
Yes, both freeze without special handling. The one thing to check is any dynamic theme or lexer
lookup — Pygments styles, for instance, are resolved by name, which is exactly the pattern that
needs a hiddenimports entry.
Should the spec file be committed?
Yes. It is the build configuration, it is a Python file so it belongs in review, and it is the only readable way to express hidden imports, bundled data and exclusions. Repeating a dozen command-line flags in a CI workflow is how build configuration diverges between machines.
How do I debug a frozen build that fails on a user's machine?
Build a --onedir version of the same commit and reproduce there first: you can inspect the
collected files, and tracebacks reference real paths. Then run the failing command with
PyInstaller's debug bootloader (--debug=imports), which prints every import as it resolves and
names the one that is missing. Keeping a doctor command that prints the bundle root, the
resolved version and the loaded command list turns most user reports into a single paste.
Does freezing protect my source code?
No. The bundle contains compiled bytecode, which is straightforward to extract and decompile. Freezing is a distribution mechanism, not an obfuscation one — if source confidentiality matters, that is a different problem and one Python solves badly.
Can I bundle a tool that shells out to another program?
Yes, but decide whether that program travels with you. If it does, add it to binaries in the spec
and resolve its path through the bundle root at run time; if it does not, look it up on PATH and
fail with a message naming what is missing. What causes support tickets is the middle case, where
the tool silently uses whatever version happens to be installed.