PyInstaller Pack Windows
Turn a Python script into a portable, double-click .exe on Windows. The
default recipe is --onefile --windowed (single file, no console).
When to use
- A user says "打包成 exe", "make this a Windows executable", "double-click to
run", or wants to publish a Tkinter/CLI tool without requiring Python install. - Setting up a repeatable local build (
build_exe.cmd) or CI auto-build.
Core build command
python -m PyInstaller --noconfirm --onefile --windowed ^
--name appname ^
--icon "absolute\path\to\icon.ico" ^
--distpath dist --workpath build_tmp --specpath build_tmp ^
app.pyw
For a fully portable build_exe.cmd (relative paths, auto-detect python, builds
to the user's Desktop), copy references/build-exe-cmd.md and save it as
build_exe.cmd, then adjust the two script names.
Critical pitfalls (read before debugging)
See references/gotchas.md for the full list. The two that bite most often:
--iconrelative path fails. PyInstaller resolves a relative--icon
against--workpath, not the current directory. Always pass an absolute
path. In CI use"$env:GITHUB_WORKSPACE\icon.ico"(runner env var) rather
than a GitHub Actions expression.- Frozen
sys.executableis the exe itself. If the app shells out to a
Python script,_host_python()must fall back to the real interpreter
(e.g.C:\Python313\python.exe), notsys.executable.
Tkinter note
Package with the system Python that actually has tkinter. Managed/isolated
Pythons (e.g. WorkBuddy's bundled 3.13.12) often omit tkinter, so the frozen exe
will crash on import. Verify with python -c "import tkinter".
Icons: --icon is only half the job
--icon sets the exe file icon (Explorer/desktop). The running window
title bar keeps Tk's default blue-feather icon unless you set it in code:
self.iconphoto(True, tk.PhotoImage(data=ICON_PNG_B64, format="png"))
Keep references to the PhotoImage objects (store on self) or they get
garbage-collected and the icon reverts. Embedding base64 PNGs in the source
keeps the exe self-contained. Generating the PNGs: use the zero-dep-icon-gen
skill; packing PNGs into .ico: 32bpp BMP entries (.NET's
System.Drawing.Icon reads PNG-compressed entries unreliably), and swap R/B
channels when copying PNG rows into BMP (RGBA → BGRA) or colors come out
inverted.
Desktop still shows the old default icon? It's the shell cache
Symptom: rebuild with --icon succeeds, but Explorer/desktop still shows the
PyInstaller default yellow-folder icon (or a different app's icon, if the
cache entry was keyed off an older same-named file). The exe itself is fine —
Explorer reads icons through a path-keyed cache and does not invalidate on
file replace.
Diagnose before touching anything (pure ctypes, no .NET): call
SHGetFileInfoW(path, SHGFI_ICON|SHGFI_USEFILEATTRIBUTES), pull the HICON's
color bitmap via GetIconInfo+GetDIBits, and count pixels by hue. If the
pixels match your new icon (e.g. indigo ~84% / white ~14% / yellow 0%), the
problem is purely the display cache. Notes: Add-Type may be blocked by the
sandbox security policy — use ctypes.windll.shell32 from Python instead, and
set argtypes/restype or 64-bit handles get truncated (OverflowError: int
too long to convert). In this sandbox even the real-access mode (without
SHGFI_USEFILEATTRIBUTES) fails with ERROR_NO_TOKEN (1008) on every path —
the attribute-simulated mode still returns the correct cached icon, so use it
for pixel probing.
Fix — clear the whole cache family, not one file. On Windows 10/11 the
icon cache is NOT a single IconCache.db (that legacy file is ignored); it is
a set of per-size files under
%LOCALAPPDATA%\Microsoft\Windows\Explorer\:
iconcache_16.db iconcache_32.db iconcache_48.db iconcache_96.db
iconcache_256.db iconcache_768.db iconcache_1280.db iconcache_1920.db ...
iconcache_idx.db iconcache_sr.db iconcache_wide.db iconcache_wide_alternate.db
iconcache_custom_stream.db iconcache_exif.db
The desktop shows large icons (reads iconcache_48.db), list views read
iconcache_16.db — which is exactly why a stale rebuild can look correct in a
folder window while the desktop still shows the old icon. Reliable sequence:
Stop-Process -Name explorer -Force
$dir = "$env:LOCALAPPDATA\Microsoft\Windows\Explorer"
$bak = "<project>\tmp\_iconcache_backup" # MOVE, don't delete
New-Item -ItemType Directory -Force -Path $bak | Out-Null
Get-ChildItem $dir -Filter "iconcache_*.db" | ForEach-Object {
Move-Item $_.FullName -Destination $bak -Force }
Start-Process explorer
Two traps when scripting this:
- Delete can fail closed. A sandbox
safe-deletepolicy may force trash
semantics, and the recycle-bin API needs explorer running — but you just
stopped it, soRemove-Itemaborts withSAFE_DELETE_FAIL_CLOSEDand the
loop dies halfway (leaving some caches old). Move to a backup dir
instead — moving isn't delete, and you keep a rollback copy. SHChangeNotify(SHCNE_ASSOCCHANGED)alone usually does NOT clear a stale
per-file entry. After explorer restarts, additionally push
SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, "C:\Users\DELL\Desktop", NULL)
(ctypes:SHCNE_UPDATEDIR=0x1000,SHCNF_PATHW=5lets you pass a path
string instead of a PIDL) so the desktop view re-reads the folder.
Before assuming the cache is stale, confirm you're looking at the new file
(md5 vs dist/) — copying the exe to Desktop and then rebuilding leaves the
Desktop copy on the old build. Note Windows copy preserves the source mtime,
so a replaced same-path file can keep an "unchanged" cache key.
Version management
Keep a single source of truth version.py (__version__ = "1.0.0"). The app
imports it and shows it in the title/About. On release, CI rewrites it from the
git tag (see assets/version.py). Import defensively:
try:
from version import __version__ as APP_VERSION
except Exception:
APP_VERSION = "dev"
CI auto-build + Release
assets/build-exe.yml is a ready workflow: runs on windows-latest, builds both
exes, uploads an Actions Artifact on every main push, and attaches the exes to
any published GitHub Release. It stamps the version first (tag on release,
-dev suffix on push). Requirements:
- Runner must be
windows-latest— real.execan only be produced on
Windows. - Grant
permissions: contents: writesogh release uploadcan attach assets. - If the repo
.gitignoreignores everything by default, whitelist
!version.py,!.github/,!.github/**.
Deliverable checklist
- [ ]
--onefile --windowed, absolute--icon. - [ ] Build script copies the exe to a known location (Desktop) or CI uploads it.
- [ ]
version.pypresent and imported defensively. - [ ] (Optional) CI workflow added and validated by a test push.