Guide
Automating Reporting WorkflowsDeep dive

Package a Python Excel Script as an EXE with PyInstaller

Ship a report script to someone with no Python: a one-file build, the hidden imports pandas and openpyxl need, keeping the config and template outside the bundle, and how to find your own files at runtime.

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.

What goes inside the bundle and what stays beside it The executable contains the Python interpreter, pandas and openpyxl, your code, and read-only assets such as a template workbook and a logo. The config file, the input export and the generated reports stay in the folder next to the executable, where the user can see and edit them. inside report.exe the Python interpreter pandas · openpyxl · your code template.xlsx, logo.png read-only — changing any of it means a rebuild and a redistribution beside report.exe config.toml the input export the reports it writes editable — the user changes a recipient without you rebuilding anything

Prerequisites

Bash
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:

Bash
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:

Bash
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:

Bash
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
Why PyInstaller misses the Excel engine PyInstaller finds dependencies by reading import statements. Your code imports pandas, and pandas imports openpyxl only at runtime by looking the engine up by name, so the static analysis never sees it and the bundle ships without it. Naming it as a hidden import, or importing it explicitly yourself, closes the gap. your code import pandas seen pandas bundled by name openpyxl never bundled first run: ModuleNotFound Two ways to close the dashed arrow --hidden-import openpyxl in the spec, or a plain import openpyxl in your own module

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:

Python
# 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:

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

Where a frozen script should read from and where it must write to On startup a one-file build unpacks its bundled assets into a temporary directory recorded in sys._MEIPASS, which is deleted when the process exits. Templates and logos are read from there. The config file, the input and the generated reports must live in the directory containing the executable, which survives between runs. report.exe starts sys._MEIPASS — temporary READ template.xlsx, logo.png never write here deleted when the process exits Path(sys.executable).parent READ config.toml, the export WRITE reports/ and the log survives between runs, visible to the user

Step 4: Ship a folder, not a file

What the user receives should be a small folder they can drop anywhere:

Text
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:

Bash
# 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

SymptomCauseFix
ModuleNotFoundError: openpyxl at runtimepandas selects the engine by name; the analysis missed it--hidden-import openpyxl, or import it explicitly in your code
Report file vanishes after the runWritten inside sys._MEIPASSWrite to Path(sys.executable).parent
Executable is 100 MB+Built from a general-purpose environmentBuild in a clean venv; exclude matplotlib, tkinter, tests
Slow start, then it worksOne-file build unpacking every launchUse --onedir
Antivirus quarantines the fileUnsigned self-extracting binary--onedir, and sign the executable if possible
Config changes have no effectThe config was bundled into the exeLoad it from beside the executable
Works on your Windows, not theirsMissing Visual C++ runtime, or a newer OS buildBuild on the oldest Windows version you support
Console window flashes and closesDouble-clicked, finished, exitedKeep 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.

Python
__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.

Up to the parent guide:

Related guides: