Notes on PowerShell module paths

Even though I have been using PowerShell for a few good years now, I haven’t really paid much attention to where its modules are stored. When I want a module I just download it using Install-Module and it magically works.

I mostly develop on PowerShell 7 on macOS or Windows 11. These work fine with Azure Functions too, but with Automation Accounts I usually switch to PowerShell 5.1 and 7.x is still in preview there. But starting yesterday I decided to dip my feet into 7.x there too.

One of the modules I use is PnP.PowerShell for interacting with SharePoint Online. Version 2+ of that module only supports PowerShell 7.x so I have to stick with Version 1.12 for my Runbooks as that’s the last version to support PowerShell 5.1. Not a problem, but now that I am going to have some Runbooks be on PowerShell 7.2 there I wanted to use the newer PnP.PowerShell with those Runbooks and yet not affect the existing stuff.

Oh, and before I forget, these Runbooks are on Hybrid Runbook Workers (HRWs), so I have to install PowerShell 7.x manually, install the modules, and so on.

The one cmdlet I typically use to see modules is Get-Module. On its own it shows the currently loaded/ imported modules. Adding a -ListAvailable switch to see everything you have installed. Below, I have 3 modules currently loaded, but 369 available to load.

How does it know where to search for modules, to list the available ones? For that it uses the PSModulePath environment variable. Here’s what it looks like in PowerShell 5.x on my HRW (the variable contains semi-colon separated paths, so I split along that to output one per line for ease of reading):

And what about PowerShell 7?

Most paths are common, so PowerShell 7 can simply use the modules of PowerShell 5. Two paths, highlighted, are special to PowerShell 7. The first of these does not exist by default; the other does.

Odd that the first one doesn’t exist, especially as the documentation too says the first path is the one used for AllUsers scoped modules. From the contents of the folder I think the second path contains the default PowerShell 7 modules. For instance, PowerShell 7.3 includes PSReadLine 2.2.6 while PowerShell 5.1 includes PSReadLine 2.0.0 and this way the included modules can be kept separate.

Notice when I ask PowerShell 7 to find the PSReadLine module it lists both (its own, and the PowerShell 5.1 bundled one). It will choose the newer version when importing the module.

On the other hand, PowerShell 5.1 only sees the one it has.

Back to PnP.PowerShell. Here’s what I have currently (same results from both PowerShell).

I had installed these using Install-Module and as per its docs the modules should have been installed at  $env:ProgramFiles\PowerShell\Modules – a path that doesn’t exist yet (this is the path we saw above in the output of PowerShell 7’s PSModulePath). But where are these installed currently? At C:\Program Files\WindowsPowerShell\Modules\PnP.PowerShell. Huh.

This stumped me for a bit until I realized PowerShell 5.1 comes with version 1 of the PowerShellGet and if I check the docs for PowerShellGet 1.x version of Install-Module sure enough it installs at $env:ProgramFiles\WindowsPowerShell\Modules.

I usually update PowerShellGet on all my machines, so I guess I just missed it on these HRWs. I wonder how the behaviour would be if I update it, because then it would install to $env:ProgramFiles\PowerShell\Modulesand that’s not in PowerShell 5.1’s PSModulePath.

(Aside: I tested this on a different machine where I updated PowerShellGet to the latest version.

It continues to install to $env:ProgramFiles\WindowsPowerShell\Modules for PowerShell 5.1. Good).

Back to PnP.PowerShell. I thought I could just do an Install-Module as I usually do, for PowerShell 7.x, and it will install PnP.PowerShell in a separate path. But that doesn’t work coz it finds the existing one and thinks maybe you want to update it instead.

Nope, I don’t want to update. (I did test this. If I do an Update-Module it will update the existing module as expected, which is not compatible with PowerShell 5.1 in this case). What happens if I use the -Force switch? Interestingly, that installs it in $env:ProgramFiles\PowerShell\Modules.

And since this path is not visible to PowerShell 5.1, it doesn’t see the module. Noice!

It is possible to download and save modules manually. Above, I used Install-Module to install a module and it installed to its default path. But I could have done this too:

In the above snippet, I am saving the module to the path used by Install-Module, so it sees this one and I can use Update-Module to later update it. I don’t really need to do the above as I am saving it to the path used by Install-Module anyway, but this was before I realized the -Force switch saves it to this path.

What else? Initially I thought the order of paths matters in PSModulePath. As in, maybe Install-Module will use the first one available, or PowerShell will use the module in the first path. But nope, PowerShell uses the latest version across all paths; and Install-Module or Update-Module does its own thing.

Earlier I mentioned Get-Module, which shows all loaded modules. To see all modules installed via Install-Module (or rather, PowerShellGet to be specific), use Get-InstalledModule. This too behaves differently in PowerShell 5.1 vs PowerShell 7.x – same as Install-Module. In the former it looks for modules installed in $env:ProgramFiles\WindowsPowerShell\Modules, in the latter $env:ProgramFiles\PowerShell\Modules. This is confusing coz you’d do Get-InstalledModule on PowerShell 7.x for instance, get no results, try to Install-Module and it will complain that the module exists in a different path!

This stumped me a lot until I figured all this stuff out. This, for instance, is why I thought Install-Module in PowerShell 7.x will just ignore the paths not used by PowerShell 5.1, but it does not.

Moral of the story: don’t fully trust Get-InstalledModule.

My Function

As part of all this (including updating my various modules yesterday & today) I wrote a function to check my installed modules and update if needed. Nothing fancy, but here goes in case it helps anyone else.

I then add a line like this to my $PROFILE file:

I have only tested this on my “desktop” machines – so macOS and Windows 11, running PowerShell 7.3. I don’t use PowerShell 5.1 on these so never ran into the inconsistencies with Get-InstalledModule. The function will misbehave in such environments. 🙃