A client called us on a Monday morning because half their admin team couldn’t run Exchange management commands. The other half could. Same servers, same accounts, same Group Policy. Turned out three admins had version 2.x of the Exchange Online module. Two others had version 3.x. And one person had both installed, importing the wrong one every time. The fix took ten minutes. The outage cost them half a day. That’s what bad PowerShell module management looks like in production.
We see this pattern constantly across managed environments. Modules get installed ad hoc, never inventoried, never updated. Then something breaks and nobody knows why. The good news? PowerShell gives you every tool you need to keep this clean. You just have to use them.
The Problem: You Don’t Know What You Have
Before you install anything, you need to know what’s already on the box. Sounds obvious. You’d be surprised how many client environments we walk into where nobody has run this command:
# List every module installed on this system
Get-Module -ListAvailable
This returns every module PowerShell can find in its module paths. On a fresh Windows Server install, you’ll see dozens. On a machine that’s been in production for three years with six different admins touching it? Hundreds. Many of them duplicates at different versions.
Here’s the thing most people miss. Get-Module -ListAvailable shows what’s installed. It doesn’t show what’s loaded. To see what’s actually imported into your current session:
# Show modules loaded in the current session
Get-Module
No -ListAvailable flag. Just Get-Module. That distinction matters when you’re troubleshooting why a cmdlet isn’t found or why you’re getting unexpected behavior from a command that exists in multiple modules.
Building a Module Inventory
We run this configuration across all our managed endpoints as a baseline check. Your first step in module management should always be inventory. Being unaware of what modules are installed leads to conflicts and vulnerabilities. One environment we audited had an outdated Az module with a known CVE sitting on every server because nobody tracked what was deployed.
# Version 1: Quick and dirty inventory
Get-Module -ListAvailable | Select-Object Name, Version, ModuleBase |
Sort-Object Name | Export-Csv -Path C:\Admin\ModuleInventory.csv -NoTypeInformation
That works. But it doesn’t tell you much about duplicates. Here’s the better version:
# Version 2: Find modules with multiple versions installed
Get-Module -ListAvailable |
Group-Object Name |
Where-Object { $_.Count -gt 1 } |
ForEach-Object {
[PSCustomObject]@{
Module = $_.Name
Versions = ($_.Group.Version -join ', ')
Count = $_.Count
}
} | Format-Table -AutoSize
Run that on any machine that’s been around for a while. The results will make you uncomfortable. PowerShell allows multiple versions of the same module to exist side by side. That’s flexible, sure. It’s also a recipe for version conflicts if you’re not actively managing it.
Finding Modules: The PowerShell Gallery
The PowerShell Gallery is the central repository for community and Microsoft-published modules. Think of it as NuGet for PowerShell. Before you install anything, you search for it with Find-Module:
# Search the PowerShell Gallery
Find-Module -Name PSWindowsUpdate
# Search with wildcards
Find-Module -Name *Exchange*
# Filter by tag
Find-Module -Tag 'ActiveDirectory'
You can use wildcards in names, specify tags, and filter in dozens of ways. This is your shopping step. Look at the download count, the publish date, the author. Not every module on the Gallery is production-worthy.
Here’s my opinionated take: if a module has fewer than 1,000 downloads and hasn’t been updated in over a year, think twice before putting it on a production server. Check the GitHub repo if there is one. Read the issues. We learned this the hard way on a client engagement where a third-party reporting module had an unhandled exception that crashed scheduled tasks silently.
Installing Modules: The Right Way
Version 1: The Quick and Dirty Install
Most people start here:
# Basic install from the Gallery
Install-Module -Name ModuleName
This works. It pulls the latest version from the PowerShell Gallery and drops it into C:\Program Files\WindowsPowerShell\Modules for all users. But there are problems with this approach in a real environment.
First, it requires admin rights. Second, it installs for all users by default, which might not be what you want on a shared server. Third, it doesn’t pin a version.
Version 2: Scoped and Specific
# Install for current user only (no admin needed)
Install-Module -Name PSWindowsUpdate -Scope CurrentUser
# Install a specific version
Install-Module -Name Az.Accounts -RequiredVersion 2.12.1
# Install for all users (requires elevation)
Install-Module -Name Microsoft.Online.SharePoint.PowerShell -Scope AllUsers
The -Scope CurrentUser parameter is your friend on shared systems. It installs into the user’s personal module path instead of the system-wide location. No admin rights needed. No impact on other users.
Version 3: Production-Ready Installation
Here’s what we actually deploy in client environments. This handles the first-run trust prompt, forces installation without interactive confirmation, and logs what happened:
# Production install script
$ModuleName = 'PSWindowsUpdate'
$LogPath = "C:\Admin\Logs\ModuleInstall_$(Get-Date -Format 'yyyyMMdd').log"
try {
# Trust the PSGallery if not already trusted
$repo = Get-PSRepository -Name PSGallery
if ($repo.InstallationPolicy -ne 'Trusted') {
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
"$(Get-Date) - Set PSGallery as Trusted" | Add-Content $LogPath
}
# Check if already installed
$existing = Get-Module -Name $ModuleName -ListAvailable
if ($existing) {
"$(Get-Date) - $ModuleName already installed (v$($existing.Version))" | Add-Content $LogPath
} else {
Install-Module -Name $ModuleName -Scope AllUsers -Force
$installed = Get-Module -Name $ModuleName -ListAvailable
"$(Get-Date) - Installed $ModuleName v$($installed.Version)" | Add-Content $LogPath
}
} catch {
"$(Get-Date) - FAILED: $($_.Exception.Message)" | Add-Content $LogPath
throw
}
The -Force parameter suppresses the confirmation prompt. Important for automated deployments. Without it, your script will hang waiting for user input that never comes.
One caveat here: -Force will also overwrite an existing installation of the same version. That’s fine most of the time. But if someone has customized a module file locally (don’t do this, but people do), you’ll blow away their changes. Always check first in environments you didn’t build.
Importing Modules: Three Ways In
Installing a module puts it on disk. Importing it loads it into your current session so you can actually use its commands. There are three ways PowerShell handles this.
Method 1: Explicit Import
# Import a module by name
Import-Module -Name ActiveDirectory
# Import a specific version
Import-Module -Name Az.Accounts -RequiredVersion 2.12.1
# Import from a specific path (useful for custom modules)
Import-Module -Name C:\Scripts\Modules\MyModule.psm1
Explicit imports are predictable. You know exactly what’s loading and when. For scripts that will run in production, always use explicit imports. Don’t rely on autoloading in automation. A client we consult for had a scheduled task that worked for months, then broke after a Windows update changed the module autoload paths. Explicit Import-Module at the top of the script would have prevented that entirely.
Method 2: Module Autoloading
Since PowerShell version 2, modules autoload when you call one of their commands. You type Get-ADUser and PowerShell finds the ActiveDirectory module, imports it, and runs the command. Convenient for interactive use. Risky for scripts.
Why risky? Because autoloading depends on the module being discoverable in $env:PSModulePath. Change that path, run the script as a different user, run it on a server with a different module layout—and suddenly autoload can’t find what it needs.
Method 3: Get-Command Discovery
# This also triggers the module import
Get-Command -Module PSWindowsUpdate
When you use Get-Command to discover commands within a module, PowerShell ensures the module is imported and then returns the requested commands. Useful for exploration. Not something you’d use in a script.
Loading Modules at Session Start
Want specific modules available every time you open PowerShell? Add them to your profile:
# Add to your PowerShell profile ($PROFILE)
Import-Module ActiveDirectory
Import-Module PSWindowsUpdate
Import-Module Az.Accounts
For client environments where admins need specific tooling every session, we bake this into the Group Policy-deployed profile. Every admin gets the same modules loaded on login. No guessing, no forgetting.
PowerShell 7 Compatibility Note
If you’re running PowerShell 7 and need a module built for Windows PowerShell 5.1, you have an option:
# Import in Windows PowerShell compatibility mode
Import-Module Microsoft.Online.SharePoint.PowerShell -UseWindowsPowerShell
This runs the module in an implicit Windows PowerShell remoting session. It works, but there are serialization trade-offs. Test thoroughly before relying on this in production.
Updating Modules: Don’t Fall Behind
Outdated modules are a security risk. They’re also a compatibility risk. And they’re everywhere. We discovered during a routine review of a client environment that their Az module was 18 months out of date. Half the cmdlets they needed for their new Azure deployment simply didn’t exist in their installed version.
# Update a single module
Update-Module -Name PSWindowsUpdate
# Check what would update (dry run isn't built in, so compare)
$installed = (Get-Module -Name Az.Accounts -ListAvailable).Version
$gallery = (Find-Module -Name Az.Accounts).Version
if ($gallery -gt $installed) {
Write-Host "Update available: $installed -> $gallery"
}
Here’s something that trips people up: Update-Module only works on modules that were originally installed via Install-Module. If the module came pre-installed with Windows or was copied manually, Update-Module won’t touch it. You’ll get an error or silent failure.
Bulk Update Script
# Update all Gallery-installed modules with logging
$LogPath = "C:\Admin\Logs\ModuleUpdate_$(Get-Date -Format 'yyyyMMdd').log"
Get-InstalledModule | ForEach-Object {
$moduleName = $_.Name
$currentVersion = $_.Version
try {
$gallery = Find-Module -Name $moduleName -ErrorAction Stop
if ($gallery.Version -gt $currentVersion) {
Update-Module -Name $moduleName -Force
"$(Get-Date) - Updated $moduleName from $currentVersion to $($gallery.Version)" |
Add-Content $LogPath
}
} catch {
"$(Get-Date) - FAILED updating $moduleName`: $($_.Exception.Message)" |
Add-Content $LogPath
}
}
We run a version of this monthly across our managed endpoints. Automate it with a scheduled task and you’ll never fall behind again. If you’re familiar with PowerShell loop structures, you can see the ForEach-Object pipeline doing the heavy lifting here.
The Incident: When Version Chaos Hits Production
Let me walk you through a real incident from a client engagement last year. This is why module management matters beyond tidiness.
Timeline
08:15 — Client’s monitoring team reports that their automated user provisioning script is failing. New hires aren’t getting mailboxes.
08:30 — We pull the script logs. The error: The term 'New-Mailbox' is not recognized as the name of a cmdlet. But the module is installed. We can see it.
08:45 — Run Get-Module ExchangeOnlineManagement -ListAvailable. Two versions. 2.0.5 and 3.1.0. The script doesn’t specify which version to import.
09:00 — Root cause identified. An admin ran Install-Module ExchangeOnlineManagement the previous Friday without the -Force flag or removing the old version. PowerShell’s autoloader was picking up 2.0.5 (alphabetically first in the path). Version 2.0.5 doesn’t have the cmdlet the script needs.
09:10 — Fix deployed. Pinned the import to the required version and cleaned up the old one.
Root Cause
No version pinning in the script. No cleanup of old module versions after updates. No module inventory or baseline. Three small oversights that added up to a production outage.
The Fix
# What the script had (bad)
Import-Module ExchangeOnlineManagement
# What we changed it to (good)
Import-Module ExchangeOnlineManagement -RequiredVersion 3.1.0
Then we cleaned up the old version:
# Remove a specific version
Get-InstalledModule -Name ExchangeOnlineManagement -AllVersions |
Where-Object { $_.Version -ne '3.1.0' } |
Uninstall-Module -Force
Removing Modules: Clean Up After Yourself
Uninstalling modules is straightforward but comes with a gotcha.
# Remove a module from the current session (doesn't uninstall)
Remove-Module -Name PSWindowsUpdate
# Uninstall a module from disk
Uninstall-Module -Name PSWindowsUpdate
# Uninstall a specific version
Uninstall-Module -Name Az.Accounts -RequiredVersion 2.10.0
Remove-Module unloads it from your session. Uninstall-Module deletes it from disk. Different commands, different outcomes. If you only Remove-Module, it’ll be back next session when autoload picks it up again.
If you’re dealing with ODBC or DSN cleanup alongside module management, we’ve covered that process in our guide on automating ODBC DSN management with PowerShell.
Creating Custom Modules: Package Your Work
Once you’ve written enough scripts for a client, you start seeing patterns. The same functions show up in multiple scripts. That’s when you build a module.
Custom modules let you encapsulate functions, variables, and scripts into a single manageable file that you can import into any session. No more copying functions between scripts. No more “which version of this function is the latest” conversations.
# MyAdminTools.psm1
function Get-ServerUptime {
param(
[Parameter(Mandatory)]
[string]$ComputerName
)
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName
$uptime = (Get-Date) - $os.LastBootUpTime
[PSCustomObject]@{
ComputerName = $ComputerName
LastBoot = $os.LastBootUpTime
UptimeDays = [math]::Round($uptime.TotalDays, 2)
}
}
function Get-DiskSpaceReport {
param(
[Parameter(Mandatory)]
[string]$ComputerName
)
Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $ComputerName |
Where-Object { $_.DriveType -eq 3 } |
Select-Object DeviceID,
@{N='SizeGB';E={[math]::Round($_.Size/1GB,2)}},
@{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,2)}},
@{N='PctFree';E={[math]::Round(($_.FreeSpace/$_.Size)*100,1)}}
}
Export-ModuleMember -Function Get-ServerUptime, Get-DiskSpaceReport
Save that as a .psm1 file, drop it in a folder with the same name under your modules path, and you can import it like any Gallery module:
# Import your custom module
Import-Module C:\Scripts\Modules\MyAdminTools\MyAdminTools.psm1
# Or if it's in your PSModulePath
Import-Module MyAdminTools
# See what it provides
Get-Command -Module MyAdminTools
Get-Command -Module ModuleName lists every function, cmdlet, and alias the module exposes. Use it to verify your module is exporting what you expect.
Lessons Learned: The Module Management Checklist
After cleaning up module sprawl across dozens of client environments, here’s what we enforce now:
1. Inventory first. Run Get-Module -ListAvailable and Get-InstalledModule before touching anything. Know what you have.
2. Pin versions in scripts. Every production script uses Import-Module -RequiredVersion. No exceptions. Autoloading is for interactive sessions only.
3. Clean up old versions. After every update, remove the previous version unless you have a specific reason to keep it. Multiple versions cause confusion.
4. Trust the Gallery explicitly. Run Set-PSRepository -Name PSGallery -InstallationPolicy Trusted once, in your setup script. Stops the interactive prompt from breaking automation.
5. Use -Scope CurrentUser on shared systems. Don’t install modules system-wide unless every user on that box needs them.
6. Log everything. Module installs and updates should be logged. When something breaks at 2 AM, you need to know what changed.
7. Automate updates monthly. Use the bulk update script above as a scheduled task. Review the log. Keep your data backup current before mass updates.
Practical Takeaway
PowerShell module management isn’t glamorous work. But every hour you spend building a clean module strategy saves you ten hours of debugging version conflicts, missing cmdlets, and mysterious script failures down the road. Start with an inventory. Pin your versions. Automate your updates. Clean up what you don’t need.
If your environment has gotten out of hand—multiple versions everywhere, no inventory, scripts breaking unpredictably—we can help sort it out. Reach out to us and we’ll get your PowerShell module strategy production-ready.


