Package a Python Excel Script as an EXE with PyInstaller
There is one situation where a packaged executable is the right answer: the person who needs the report has a locked-down desktop, no Python, no ability to install one, and the job must run there rather than on a server. In every other case a virtual environment or a pipx install is smaller, faster to fix and easier to update.
If you are in that situation, PyInstaller does the job well — with two complications specific to Excel work. pandas and openpyxl pull in modules that PyInstaller's static analysis cannot see, and a report usually needs files (a template workbook, a config file, a logo) that must either travel inside the bundle or sit beside it. This guide, part of Testing and Packaging Excel Automation Scripts, covers both.
Prerequisites
pip install pyinstaller pandas openpyxl
Build on the operating system you are shipping to — PyInstaller freezes the interpreter of the machine it runs on, so there is no cross-compilation. Build inside a virtual environment containing only what the script needs; a build run from a general-purpose environment sweeps in every library you have ever installed, and the executable grows accordingly.
The script should already have a command line and external configuration, because both become much harder to add once the code is inside a bundle.
Step 1: The first build
Start with the simplest command that produces something to test:
pyinstaller --onefile --name report cli.py
The result lands in dist/report.exe (or dist/report on Linux and macOS). Run it immediately with the same arguments a user would:
dist/report.exe orders.xlsx --output july.xlsx --verbose
If it works, you are most of the way there. If it fails, the error is almost always a ModuleNotFoundError for something you never imported directly — which is the next step.
Step 2: Fix the imports PyInstaller cannot see
PyInstaller finds dependencies by reading the source for import statements. pandas chooses its Excel engine at runtime by name, so nothing in your code mentions openpyxl and the analysis misses it. The same happens with several pandas internals:
pyinstaller --onefile --name report \
--hidden-import openpyxl \
--hidden-import openpyxl.cell._writer \
--hidden-import pandas._libs.tslibs.base \
--exclude-module matplotlib \
--exclude-module tkinter \
--exclude-module pytest \
cli.py
Adding an explicit import openpyxl at the top of your own module fixes the first one just as well, and is easier to remember. The --exclude-module flags are the cheapest size win available: matplotlib and tkinter are pulled in by transitive dependencies far more often than they are used, and removing them typically takes 20-30 MB off the bundle.
When a module still goes missing at runtime, the error names it exactly — add it to the hidden-import list and rebuild. Keep the growing command in a spec file rather than a shell history:
# report.spec — generated by the command above, then edited and committed
a = Analysis(
["cli.py"],
pathex=[],
binaries=[],
datas=[("assets/template.xlsx", "assets"), # (source, destination in bundle)
("assets/logo.png", "assets")],
hiddenimports=["openpyxl", "openpyxl.cell._writer"],
excludes=["matplotlib", "tkinter", "pytest"],
)
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, a.binaries, a.datas, name="report",
console=True, upx=False)
Then build with pyinstaller report.spec. The spec file is the build definition; committing it means the next person rebuilds exactly what you shipped.
Step 3: Find your own files at runtime
A frozen application has two directories that matter, and they are not the same one. Bundled assets are unpacked into a temporary folder that PyInstaller records in sys._MEIPASS; the user's editable files live next to the executable. Getting these the wrong way round is the most common packaging bug in report tools:
import sys
from pathlib import Path
def bundled(relative):
"""A read-only asset shipped INSIDE the executable (template, logo)."""
base = Path(getattr(sys, "_MEIPASS", Path(__file__).parent))
return base / relative
def alongside(relative):
"""An editable file NEXT TO the executable (config, output directory)."""
if getattr(sys, "frozen", False):
base = Path(sys.executable).parent
else:
base = Path(__file__).parent
return base / relative
TEMPLATE = bundled("assets/template.xlsx") # read-only, inside
CONFIG = alongside("config.toml") # editable, outside
OUTPUT_DIR = alongside("reports") # the user can open this folder
sys.frozen is set only in a packaged build, so both helpers work unchanged when you run the script normally during development. Writing anywhere inside sys._MEIPASS is the failure to avoid: that directory is deleted when the process exits, so a report saved there vanishes the moment the job finishes — and on a one-file build it is recreated on every run, so nothing persists between them.
Step 4: Ship a folder, not a file
What the user receives should be a small folder they can drop anywhere:
regional-report/
├── report.exe
├── config.toml ← they edit this
├── README.txt ← three lines: what to edit, how to run, who to ask
└── reports/ ← output lands here
README.txt is not a formality. The three things a non-technical user needs are which file to edit, the exact command or double-click that runs it, and who to contact — and a text file beside the executable is the only documentation that reliably travels with it.
Prefer --onedir over --onefile for this audience despite the extra files. A one-file build unpacks the whole bundle to a temporary directory on every launch, which adds several seconds of startup for a pandas-based tool and is a frequent trigger for antivirus heuristics. One-directory starts immediately and looks more like ordinary software.
Step 5: Verify the build like a user
Test on a machine that has never had Python installed, or at minimum in a clean environment with PATH and PYTHONPATH cleared. A build that quietly imports a library from your development machine passes every test you run and fails on the first desktop it reaches:
# from a clean directory containing only the shipped folder
cd /tmp/handover/regional-report
./report.exe sample-export.xlsx --verbose
echo "exit code: $?"
Check the exit code as well as the output file, because the scheduler on the user's machine reads that number and nothing else.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError: openpyxl at runtime | pandas selects the engine by name; the analysis missed it | --hidden-import openpyxl, or import it explicitly in your code |
| Report file vanishes after the run | Written inside sys._MEIPASS | Write to Path(sys.executable).parent |
| Executable is 100 MB+ | Built from a general-purpose environment | Build in a clean venv; exclude matplotlib, tkinter, tests |
| Slow start, then it works | One-file build unpacking every launch | Use --onedir |
| Antivirus quarantines the file | Unsigned self-extracting binary | --onedir, and sign the executable if possible |
| Config changes have no effect | The config was bundled into the exe | Load it from beside the executable |
| Works on your Windows, not theirs | Missing Visual C++ runtime, or a newer OS build | Build on the oldest Windows version you support |
| Console window flashes and closes | Double-clicked, finished, exited | Keep console=True and tell users to run it from a command prompt, or pause on exit |
Performance and scale notes
Packaging does not change how fast the report runs, but it changes what a fix costs. A bug in a bundled script is a rebuild, a virus-scan cycle and a redistribution to every desktop that has a copy — measured in days rather than minutes. Two habits keep that manageable: put everything that might plausibly change into the external config file, and print a version string at startup so a support conversation begins with which build the user is running.
__version__ = "1.4.0"
log.info("regional-report %s", __version__)
If you find yourself rebuilding often, that is the signal to move the job onto a server and hand the user the output instead of the tool.
Conclusion
Package a report script only when the user genuinely cannot have Python. When you do, build in a clean environment, add the hidden imports pandas and openpyxl need, keep read-only assets inside the bundle and everything editable beside the executable, prefer a one-directory build for startup speed and fewer antivirus problems, and ship a folder with a config file and a three-line README. Then test it on a machine that has never seen Python, because that is the only test that matches how it will be used.
Frequently asked questions
Can I build a Windows .exe on Linux or macOS? No. PyInstaller freezes the interpreter and libraries of the machine it runs on, so a Windows executable must be built on Windows. Use a Windows CI runner or a virtual machine if your own machine is not.
Why is the executable 60 MB when the script is 80 lines? pandas and NumPy carry compiled extensions and data files, and the bundle also contains a Python interpreter. Excluding matplotlib and the test packages usually saves 20-30 MB; dropping pandas in favour of plain openpyxl saves far more.
My exe works but a colleague's antivirus quarantines it — what now? Unsigned one-file executables that unpack themselves at startup are a common false positive. Build one-directory instead of one-file, and sign the binary if you can; both reduce the heuristics that trigger it.
How do I keep the config file editable after packaging?
Read it from beside the executable rather than from inside the bundle. sys.executable's parent is that directory when the app is frozen, so ship config.toml next to the exe and load it from there.
Related
Up to the parent guide:
- Testing and Packaging Excel Automation Scripts — the alternatives to packaging, and when each is right.
Related guides:
- Keep Excel Report Settings in a Config File — the file that must stay outside the bundle.
- Build a Command-Line Tool for Excel Reports with argparse — the interface the executable exposes.
- Fill an Excel Template with Python and openpyxl — the template that travels inside the bundle.
- Run a Python Excel Script on Windows Task Scheduler — scheduling the packaged executable on the user's own machine.