PowerShell Module Autoloading: PSModulePath and Command Resolution
Diagnose PowerShell module discovery by separating installed and loaded modules, inspecting PSModulePath, and resolving commands unambiguously.
PowerShell module discovery involves several different states that are easy to confuse: a module can be installed on disk, discoverable through a search path, loaded into the current runspace, or exporting a command that actually wins command resolution. “The module is installed” does not prove that the current shell can find it, and “the command is missing” does not prove the module files are absent.
Module autoloading can import a module the first time you invoke a command from it. This reduces setup, but it also means the command you run may depend on $Env:PSModulePath, the host application, the PowerShell engine, installed module versions, and command-name conflicts. A reproducible troubleshooting process inspects each layer in order rather than reinstalling packages at random.
Separate installed modules from imported modules
Get-Module without -ListAvailable shows modules loaded in the current runspace. Get-Module -ListAvailable searches module locations in PSModulePath for installed modules, whether or not they are imported. It cannot find modules in arbitrary directories that are not on the search path unless you give PowerShell an explicit path to import.
# Modules currently loaded in this runspace
Get-Module | Select-Object Name, Version, Path
# Modules discoverable through the current PSModulePath
Get-Module -ListAvailable |
Sort-Object Name, Version -Descending |
Select-Object Name, Version, Path
# Search roots, in their current order
$Env:PSModulePath -split [System.IO.Path]::PathSeparator
PowerShell’s module layout differs across operating systems and installation scopes. On Windows, the documented defaults include machine-wide modules under Program Files, current-user modules under the user’s PowerShell module directory, and modules shipped with the engine under $PSHOME. On Linux and macOS, the typical roots differ. Do not hard-code a Windows directory in a cross-platform module installer. Inspect the actual path list under the specific account and host that runs the job.
If the target module is present outside those roots, import the manifest or module file by path for a controlled test:
$manifest = Resolve-Path './vendor/Acme.Inventory/Acme.Inventory.psd1'
Import-Module -Name $manifest -ErrorAction Stop
Get-Module Acme.Inventory | Select-Object Name, Version, Path
An explicit path test separates a damaged package from a discovery-path problem. Once confirmed, decide whether deployment should install the module into a documented scope or update the search path for a specific process. Avoid adding project-local directories to a global machine path if only one application needs the module.
Understand when autoloading occurs
When PowerShell encounters a command name that is not already resolved, it can search module manifests and module files in PSModulePath and import a matching module. Calling a command, requesting help for an exact command, or using Get-Command with an exact name can trigger discovery and module import. A wildcard query such as Get-Command Get-* does not import every module merely to enumerate possible commands.
This behavior explains why a command can work after a developer has already run an import-related command but fail in a clean job. The shell may not be looking at the same path list, user profile, or engine version. Capture $PSVersionTable, $Host.Name, $Env:PSModulePath, and the module path reported by Get-Module in a sanitized diagnostic before assuming a missing install.
$PSVersionTable | Select-Object PSEdition, PSVersion, Platform
$Host.Name
$Env:PSModulePath -split [System.IO.Path]::PathSeparator
Get-Module -ListAvailable Acme.Inventory |
Select-Object Name, Version, Path
Get-Command Get-InventoryItem -All -ErrorAction SilentlyContinue
Get-Command -All helps identify functions, aliases, cmdlets, and applications with the same name. A command collision can make a module appear to be loaded while a different command is actually invoked. Use a module-qualified command such as Acme.Inventory\Get-InventoryItem when explicit qualification is supported and needed, or import the intended version deliberately and confirm the resolved command’s source.
Treat module search order as part of deployment
PowerShell searches module roots in PSModulePath. If multiple modules with the same name or multiple versions are available, the path order and import request affect what gets selected. Never assume that Get-Module -ListAvailable returning several entries means the latest version will always win for every import form. Pin the intended version or use a manifest’s dependency specification when reproducibility matters, then assert the loaded path and version in the job.
Import-Module -Name 'Acme.Inventory' -RequiredVersion '1.2.0' -ErrorAction Stop
$loaded = Get-Module -Name 'Acme.Inventory' -ErrorAction Stop
if ($loaded.Version -ne [version]'1.2.0') {
throw "Unexpected Acme.Inventory version: $($loaded.Version)"
}
The exact version-selection parameters depend on the import scenario; use Get-Help Import-Module -Full and the module manifest to understand the supported binding. Prefer installing one deliberate version in a controlled module root over maintaining several indistinguishable copies on a shared search path. Upgrades should happen in a repeatable deployment step, not as a hidden side effect of opening an interactive shell.
Be especially careful when starting Windows PowerShell from an intermediate process that inherited a PowerShell 7 module path. Microsoft documents cases where incompatible PowerShell 7 module paths can precede Windows PowerShell’s own roots and break module autoloading. Each engine should construct or receive a correct module path for its version. A child process should not blindly reuse the parent’s PSModulePath if it launches a different PowerShell edition.
Diagnose import failures systematically
Start with Get-Module -ListAvailable Name to determine whether the current search path can see any candidate. Inspect Path, Version, and the manifest’s RootModule, RequiredModules, and PowerShellVersion. Then test explicit import by path in a clean process. If explicit import succeeds while name-based autoload fails, the problem is likely discovery or name resolution. If explicit import fails, inspect manifest validity, required dependencies, and engine compatibility.
pwsh -NoProfile -Command 'Get-Module -ListAvailable Acme.Inventory | Format-List Name, Version, Path; Import-Module Acme.Inventory -ErrorAction Stop -Verbose; Get-Command -Module Acme.Inventory'
The -Verbose output is useful during diagnosis but should not become noisy routine output in a production script. It may expose paths and module names that should be sanitized in public logs. -NoProfile removes one source of environment-dependent behavior; it does not reset process environment variables or install missing modules.
Do not solve a module import issue by copying arbitrary DLLs or module files into $PSHOME. Engine installation directories are managed by the PowerShell package and can be overwritten by servicing or upgrades. Deploy application dependencies in an intentional module scope, pin and update versions through the package manager or release artifact, and retain a rollback path.
Make automation deterministic
For a scheduled task or CI job, specify the engine executable, establish the intended module path, import exact dependencies, and assert command origins. Avoid relying on interactive autoloading to discover an accidental version. The script should fail early with a clear message when a required module is missing, rather than proceeding until it encounters a command-not-found error halfway through a change.
Keep module resolution checks in tests: a clean install, an expected module version, no unexpected command collision, and a module import path matching the deployment artifact. When a command is updated or removed, test consumers that call it by name. Autoloading is convenient, but a stable automation contract makes its assumptions explicit.
Modify module paths at the narrowest scope
PSModulePath is an environment variable inherited by child processes. If a script must add a temporary development module root, append or prepend it in that process and restore the previous value when the test finishes. Use the platform path separator rather than hard-coding ; or :. Do not write to a machine-wide environment setting merely to make one repository’s tests pass.
$separator = [System.IO.Path]::PathSeparator
$originalPath = $Env:PSModulePath
$testModuleRoot = (Resolve-Path './build/modules').Path
try {
$Env:PSModulePath = "$testModuleRoot$separator$originalPath"
Get-Module -ListAvailable Acme.Inventory |
Select-Object Name, Version, Path
}
finally {
$Env:PSModulePath = $originalPath
}
This change affects the current process and its future children; it does not retroactively change the search paths of already-running PowerShell processes. Start a new process when testing how an application initializes module discovery. A profile or parent process may have injected a path before startup, so compare both the inherited environment and the path after engine initialization when diagnosing a mismatch.
For modules that are part of the application package, a direct manifest path is often more predictable than modifying a global search path. For shared modules installed by administrators, keep installation and version upgrade policy separate from application code. The right mechanism depends on ownership: the package owns its local modules, while the machine or platform team may own centrally managed modules.
When several providers or modules expose a similar command name, inspect command resolution rather than importing more modules until something works. Use Get-Command Name -All to see all matching commands and their sources, then invoke the intended module-qualified command or import the selected manifest explicitly. Avoid defining an alias with the same name as a production command in a profile; it can make an interactive test execute a wrapper while CI runs the real executable.
Make the resolution check part of startup for critical jobs. After importing a required module, assert its loaded path and version and verify the command’s Source or module association. This is especially useful when a runner image is updated and gains a newer built-in module. A deterministic job should fail early if it resolves an unexpected implementation, rather than completing part of a deployment with an unreviewed command version.
Related:
- PowerShell Module Manifests: Version, Requirements, and Public Surface
- PowerShell Profiles: Startup Order, Host Scope, and Reproducible Sessions
Sources: