PowerShell · Modules

Building and Importing Your Own PowerShell Modules

A practical guide to packaging reusable PowerShell functions into a proper module: the .psm1 file, the manifest, and the PSModulePath rules that decide whether PowerShell can find it at all.

Quick idea: A PowerShell module is a folder PowerShell already knows how to find, holding a script file and a manifest. Export-ModuleMember and $env:PSModulePath are the two mechanisms that decide what actually becomes visible to whoever imports it.
.psm1

The script module file. This is where the actual function definitions live.

.psd1

The manifest. A hashtable of metadata that describes the module and, optionally, restricts what it exports.

Export-ModuleMember

The command inside the .psm1 that decides exactly which functions, variables, and aliases callers can see.

What Is a PowerShell Module?

A module is a self-contained, reusable unit that packages functions, and optionally variables, aliases, and other resources, into one thing PowerShell can load as a whole. Instead of pasting the same helper function into every script, or dot-sourcing a loose collection of .ps1 files with brittle relative paths, the functions live in one folder that PowerShell already knows how to discover.

Think of a module as a labelled toolbox kept in one place, rather than tools scattered loose across a desk, a colleague’s desk, and half a dozen one-off scripts. Once the toolbox exists, anyone who needs a tool from it just opens the lid; they do not need to know which script originally wrote the function, or copy-paste it again.

A module has two core files: the .psm1 script module file, which contains the actual function code, and the .psd1 manifest, a hashtable of metadata describing the module’s version, author, and what it exports. A manifest is optional, since PowerShell only requires the .psm1, but almost every module worth reusing has one, because it is also where export restrictions and version requirements are declared.

Important: The module’s folder name must match the base name of its .psm1 or .psd1 file. Get-Module -ListAvailable only recognises “well-formed” modules: a folder containing at least one file whose base name matches the folder name. A folder called ADOpsToolkit containing Toolkit.psm1 is invisible to autoloading and to -ListAvailable.

Why Package Functions Into a Module?

A function that only lives in a PowerShell profile or a script someone dot-sources works, right up until it needs to be reused by someone else, run from a scheduled task under a different account, or kept under source control alongside a version number.

Reusable

The same functions are available in every session without retyping or re-pasting them.

Shareable

Copy the module folder to another admin’s machine, or a server, and it works the same way there.

Discoverable

Get-Command, Get-Help, and tab completion all work naturally once functions live in a real module.

None of this requires publishing anything to a gallery. A module is useful the moment it is a folder with a consistent name sitting in a path PowerShell already searches. Publishing to a feed like the PowerShell Gallery is a separate, later step.

Why Ops Teams Care About Modules

Every environment that has been managed with PowerShell for more than a year or two accumulates the same problem: the same helper function, slightly different, pasted into a dozen scripts on a dozen servers. One copy gets a bug fix, the other eleven do not, and nobody can say with confidence which version is running where.

A module fixes the source of that drift rather than treating each symptom. One folder, one version number, one place to fix a bug once. Dropping it into a source-controlled repository means every change is tracked, and rolling it out to a server is a file copy rather than a rewrite.

Practical rule: If the same function has been copy-pasted into more than two scripts, that is the signal it belongs in a module instead.

Building the Module

The .psm1 file holds the function definitions, exactly as they would appear in any script, plus one line at the end that controls what gets exported.

# ADOpsToolkit.psm1

function Get-StaleComputerAccount {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [int]$InactiveDays
    )

    $cutoff = (Get-Date).AddDays(-$InactiveDays)
    Get-ADComputer -Filter * -Properties LastLogonTimestamp |
        Where-Object { [datetime]::FromFileTime($_.LastLogonTimestamp) -lt $cutoff }
}

function Find-LockedOutUser {
    [CmdletBinding()]
    param ()

    Search-ADAccount -LockedOut
}

# Only these two functions are visible outside the module.
# Anything else defined above is a private helper.
Export-ModuleMember -Function Get-StaleComputerAccount, Find-LockedOutUser

The manifest is generated separately with New-ModuleManifest, which writes a .psd1 file next to the .psm1. Only Path is mandatory; every other parameter has a documented default if left out.

# Generates ADOpsToolkit.psd1 alongside the .psm1 in the same folder
New-ModuleManifest -Path '.\ADOpsToolkit\ADOpsToolkit.psd1' `
    -RootModule 'ADOpsToolkit.psm1' `
    -ModuleVersion '1.0.0' `
    -Author 'K Shankar R Karanth' `
    -Description 'Reusable AD operations helper functions' `
    -FunctionsToExport @('Get-StaleComputerAccount', 'Find-LockedOutUser')
Manifest Key Purpose
RootModule Points the manifest at the .psm1 to load. Without it, the manifest itself is the whole module and there is nothing to run.
ModuleVersion The only key that a manifest actually requires. Defaults to 1.0 if New-ModuleManifest is run without it.
GUID A unique identifier so PowerShell can tell apart modules that happen to share a name. Auto-generated if omitted.
Author / CompanyName / Description Metadata shown by Get-Module | Format-List *, and required if the module is ever published to a gallery.
FunctionsToExport Restricts, but never adds to, which functions the manifest allows out. Defaults to * (all) if left out.
CmdletsToExport / AliasesToExport / VariablesToExport The same restriction rule as FunctionsToExport, one per member type.
PowerShellVersion Minimum engine version required before the module is allowed to import.
RequiredModules Other modules that must already be in the global session state. If they are not, Import-Module imports them automatically; if they are not available at all, the import fails.
Key rule: If the .psm1 never calls Export-ModuleMember at all, PowerShell exports every function and alias by default, but never any variables. Once Export-ModuleMember appears even once, only the members explicitly listed in it are exported, so a variable meant to be shared with callers needs its own -Variable entry, in addition to the function list.

Importing, Reloading, and Testing the Module

Whether Import-Module is needed at all depends on where the folder sits. A module placed under one of the paths in $env:PSModulePath autoloads the first time a command from it is used, with no explicit import required. A module anywhere else needs a manual import with a path.

# Create the module folder under the current user's module path -
# Windows PowerShell 5.1 uses the WindowsPowerShell\Modules subfolder
New-Item -Path "$HOME\Documents\WindowsPowerShell\Modules\ADOpsToolkit" -ItemType Directory

# Write the function code into the .psm1
# (in practice this is an editor, not New-Item -ItemType File, but the file must exist first)
New-Item -Path "$HOME\Documents\WindowsPowerShell\Modules\ADOpsToolkit\ADOpsToolkit.psm1" -ItemType File

# Generate the manifest alongside it
New-ModuleManifest -Path "$HOME\Documents\WindowsPowerShell\Modules\ADOpsToolkit\ADOpsToolkit.psd1" `
    -RootModule 'ADOpsToolkit.psm1' -ModuleVersion '1.0.0'

# Because the module now sits under $env:PSModulePath, this line is optional -
# calling Get-StaleComputerAccount directly would autoload it. -Verbose shows what loaded.
Import-Module ADOpsToolkit -Verbose

# During development, reload the module after editing the .psm1
Import-Module ADOpsToolkit -Force

# Confirm PowerShell can see the module and list what it exports
Get-Module -ListAvailable ADOpsToolkit
Get-Command -Module ADOpsToolkit

# Validate the manifest's own syntax without importing anything
Test-ModuleManifest -Path "$HOME\Documents\WindowsPowerShell\Modules\ADOpsToolkit\ADOpsToolkit.psd1"

# See every path PowerShell searches for modules, one per line
$env:PSModulePath -split ';'

# Unload the module from the current session (this does not delete or uninstall it)
Remove-Module ADOpsToolkit
Key point: Autoloading only works once the module folder is somewhere PowerShell already searches. A module built in a project folder, a Git checkout, or any other location outside $env:PSModulePath needs an explicit Import-Module with the full path every time, because there is nothing to trigger it automatically.
Production note: Import-Module -Force reloads changes to the root module’s functions, but it does not reload any nested modules the manifest references, and it cannot pick up changed class or enum definitions. If a nested module changed, run Remove-Module first and import again; if a class definition changed, start a new session.

Module Locations Cheat Sheet

The default locations differ between PowerShell 7+ and Windows PowerShell 5.1, though both are automatically included in $env:PSModulePath, so a module dropped in the matching folder for the engine in use is discoverable without any extra configuration.

Scope PowerShell 7+ Windows PowerShell 5.1
Current user $HOME\Documents\PowerShell\Modules $HOME\Documents\WindowsPowerShell\Modules
All users $Env:ProgramFiles\PowerShell\Modules $Env:ProgramFiles\WindowsPowerShell\Modules
Shipped with PowerShell $PSHOME\Modules $PSHOME\Modules (%SystemRoot%\System32\WindowsPowerShell\v1.0\Modules)

Troubleshooting Cheat Sheet

Symptom Likely Cause Fix
Function does not appear after Import-Module Export-ModuleMember in the .psm1 does not list it, or FunctionsToExport in the manifest excludes it. Add the function name to both the Export-ModuleMember -Function list and the manifest’s FunctionsToExport, then reimport.
The specified module was not loaded because no valid module file was found The folder name does not match the base name of the .psm1 or .psd1 file, or the module is not under any path in $env:PSModulePath. Rename the folder to match the file base name, or pass the full path directly to Import-Module.
Edits to the .psm1 do not take effect An earlier copy of the module is still loaded in the session. Reimport with Import-Module -Force. If a nested module changed, run Remove-Module first, then import again.
A variable set inside the module is not visible to the caller Export-ModuleMember is present but has no -Variable entry, and variables are never exported by default once the command appears. Add the variable name to Export-ModuleMember -Variable alongside the function list.
Module works in an interactive console but a scheduled task cannot find it The module was installed under the CurrentUser scope for an account different from the one the task runs as. Install the module to the AllUsers path, or have the scheduled script call Import-Module with a fully qualified path.
The wrong version of the module loads when two versions exist PowerShell loads the highest version number by default when multiple version folders sit under the same module path. Pin the version explicitly with Import-Module -RequiredVersion or -FullyQualifiedName.

Final Thoughts

None of this is complicated once the two rules are clear: the folder name has to match the file inside it, and Export-ModuleMember decides what leaves the module, not what the file happens to define. Everything else exists to make those two rules discoverable without reading documentation every time: the manifest keys, the module paths, autoloading.

The functions that end up inside a module are exactly the ones worth turning into advanced functions first. Parameter validation and a correct process block matter more once a function is shared across a team than they ever do in a one-off script.

Key takeaway: If Get-Module -ListAvailable <Name> finds it and Get-Command -Module <Name> lists exactly the functions meant to be public, the module is built correctly.
Next in this series

Next, we can look at publishing a module to a private PowerShell Gallery feed and adding Pester tests, so exported functions are validated automatically before every release instead of by hand.