Skip to content

What are PyInstaller “hidden imports” — and why do only dynamic imports break?

If you’ve ever packaged a Python desktop app with PyInstaller, you may have run into this: the app runs perfectly from source, but the frozen executable throws ModuleNotFoundError — and only when you exercise one particular feature. It doesn’t crash on startup. It crashes three clicks deep, in a code path nobody happened to test right after the build. This post breaks down why that happens and what “hidden imports” actually means.

Note: PyInstaller is a tool that bundles a Python script together with its dependencies into a single platform-specific executable (a .exe on Windows, or a binary embedded in a .app on macOS), so end users don’t need a Python environment installed to run it.

PyInstaller inspects what the code looks like, not what it does

PyInstaller doesn’t execute your script to figure out its dependencies. It statically reads the entry-point file’s source, walks import and from ... import ... statements, and builds a dependency graph from that. Crucially, none of your code actually runs during this phase — it’s purely a textual scan of what modules are referenced in the source.

This approach works fine as long as module references are written as plain, literal import statements. Trouble starts when a module name is only determined at runtime.

The common patterns behind hidden imports

PyInstaller’s own documentation uses the term “hidden import” for exactly this class of problem, and it generally falls into a few patterns:

  1. Dynamically constructed import strings — code like importlib.import_module(f"core.{plugin_name}"), where the module name is assembled from a runtime variable. At analysis time, there’s no way to know what plugin_name will be, so the module never appears in the dependency graph at all.
  2. Plugin-style discovery — patterns using something like pkgutil.iter_modules() to scan a directory at runtime and import whatever it finds. No individual module name is ever written literally in the source, so static analysis has nothing to latch onto.
  3. Dynamic loading inside third-party packages — some packages internally load submodules conditionally or through their own plugin machinery. If the PyInstaller hook written for that package doesn’t enumerate every possible submodule, some get silently dropped.

The team’s own experience: “it was missed because it lived inside a function”

In this app’s build_app.py, we’ve repeatedly hit incidents where an in-house module — things like core/thumbnail_utils.py or core/site_paths.py — threw ModuleNotFoundError only in the frozen Windows or Mac build. What all of these had in common was that the only reference to them was a from core.xxx import yyy statement written inside a function body, never at the top of the file as a module-level import.

After running into this pattern enough times, the project settled on a standing rule: whenever a new core/*.py module is added, list it explicitly in the hidden array in build_app.py, even if you’re fairly confident it would already be picked up through a top-level import chain elsewhere. The reasoning is simple — don’t over-trust the completeness of static analysis, and declare anything you suspect might be ambiguous. It’s the same instinct as adding an extra assertion “just in case” in a test: a little redundancy in the build configuration is far cheaper than an incident that only surfaces after a build ships.

The quietly dangerous kind of hidden import: the one that doesn’t crash

One incident stands out as a good illustration of why this can be hard to catch. A module handling the app’s version string was referenced inside core/updater.py via a function-scoped from version import VERSION line — and it, too, slipped through static analysis without being added to the hidden-import list.

What made this one nasty was that the app didn’t crash. There was a fallback path that kicked in when the version module wasn’t available, quietly returning a cached or default value ("0.0.0") instead of raising an error. So the app launched normally — it just displayed the wrong version number. A loud ModuleNotFoundError is, in a way, the easier failure to catch. The harder one is the kind where everything appears to work, and only a value quietly drifts wrong.

The fix: --hidden-import and the .spec file

There are two main ways to explicitly tell PyInstaller about a module it can’t discover on its own:

  • Pass --hidden-import <module_name> on the command line, once per module that needs it
  • List modules in the hiddenimports argument of the Analysis object inside a .spec file (the Python file PyInstaller generates to describe a build)

Both accomplish the same thing: manually supplying a module that static analysis can’t find but that the app genuinely needs at runtime. For a third-party package with its own plugin architecture, writing a dedicated hook-<package_name>.py is another option, but for in-house modules, simply listing them in hiddenimports is the more direct fix.

Summary

Situation How static analysis handles it
Top-level import x Usually collected correctly
Dynamically assembled import string Cannot be resolved — the module name isn’t known until runtime
Plugin-style discovery import No literal module name ever appears in source, so nothing to collect
Function-scoped import (this project’s recurring incident pattern) Sometimes collected, but repeated misses led to an explicit-listing policy

PyInstaller’s static analysis is a best-effort inference based on what’s literally written in your source — not a guarantee that it can predict everything your code will do at runtime. If there’s even a slight chance a module is only reached through a dynamic or conditional path, it’s worth explicitly declaring it as a hidden import — both to avoid an outright ModuleNotFoundError, and to avoid the quieter, harder-to-notice kind of bug where a value just silently ends up wrong.