Writing PowerShell modules is an entirely different approach compared to scripting, even when working with scripts that are several hundred lines long. Modules unlock features that are exclusive to modular development, providing better organization, reusability, and maintainability.
In my experience, most blog posts focus on getting started with modules—explaining what they are and how to create a basic one. However, I haven’t found many that dive into best practices for writing well-structured, efficient modules. Since module development allows for a completely different coding style, I decided to write this post to fill that gap.
Table of Contents
Module Manifest: Why You Should Always Use One
The first key component of a PowerShell module is the Module Manifest. While technically optional, I highly recommend using it. The manifest provides valuable metadata, such as:
- Author Information – Who wrote the module
- Creation Date – When it was built
- Versioning – Helps track updates
- Exported Functions – Defines which functions are available when the module is imported
Essentially, the manifest centralizes all important module information. And the best part? Creating one is easy—just run New-ModuleManifest -Path "MyModule.psd1"
The .psm1 File: The Core of a PowerShell Module
Every PowerShell module must have a .psm1
file—this is where all your functions live (I’ll go into detail about them later). And that’s it! That’s the basic knowledge found in most blog posts. So, by now, you should know what to do, right?
Well, let’s take it a step further.
Public vs. Private Functions in PowerShell Modules
Unlike some programming languages, PowerShell doesn’t let us explicitly define functions as public or private. However, in a module, we can imitate this functionality by controlling which functions are available to the user.
- Public Functions – Exposed to the user when they import the module
- Private Functions – Internal helper functions, not meant for direct use
To maintain a clean and scalable structure, we should also physically separate these functions into different folders. Each function should be in its own .ps1
file for better organization.
Some people say, “Why not just dump everything into the .psm1
file?”
Yes, you can—but once your module grows to 1,000+ lines, scrolling up and down becomes a nightmare (trust me).
Recommended Folder Structure
MyModule/ # Name the module folder
- MyModule.psm1 # The core script module (must-have)
- MyModule.psd1 # Module manifest (optional but recommended)
- Private/ps1/ # Internal functions (not exposed to the user)
+ Get-MyFunctionASAP.ps1
+ Get-ASAPGetters.ps1
- Public/ps1/ # Exposed functions (available to the user)
+ Get-SomeCoolStuff.ps1
I love automating things—so, of course, I automated the creation of this module structure. Why set up the folders manually when PowerShell can do it for you?
param(
[Parameter(Mandatory)]
[string]
$ModuleName
)
$moduleName = $ModuleName
$author = "This could be your Name"
$year = (Get-Date).year
$version = "1.0"
New-Item -ItemType Directory -Name $moduleName
New-Item "$PWD\$moduleName\Private\ps1" -ItemType Directory -Force
New-Item "$PWD\$moduleName\Public\ps1" -ItemType Directory -Force
New-Item -Path "$PWD\$moduleName\$moduleName.psm1" -ItemType File
$moduleManifestValues = @{
Path = "$PWD\$moduleName\$moduleName.psd1"
Author = $author
Copyright = "$year by $author"
Description = ""
Rootmodule = "$moduleName.psm1"
ModuleVersion = $version
}
New-ModuleManifest @moduleManifestValues
Loading Functions in Your .psm1
File
Now that we have a structured module, we need to ensure that our .psm1
file properly loads all the functions from the Public
and Private
folders.
There are two common approaches to achieve this:
1. Full Version (With Error Handling)
This method ensures that if a function fails to load, you get a proper error message instead of silently failing.
#Get public and private function definition files.
$Public = @( Get-ChildItem -Path $PSScriptRoot\Public\ps1 -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue )
$Private = @( Get-ChildItem -Path $PSScriptRoot\Private\ps1 -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue )
#Dot source the files
Foreach($import in @($Public + $Private))
{
Try
{
. $import.fullname
}
Catch
{
Write-Error -Message "Failed to import function $($import.fullname): $_"
}
}
2. Short Version (Simple & Clean)
If you prefer a minimal approach, this two-liner gets the job done:
Get-ChildItem -Path "$PSScriptRoot\Public\ps1" -Filter *.ps1 | ForEach-Object { . $_.FullName }
Get-ChildItem -Path "$PSScriptRoot\Private\ps1" -Filter *.ps1 | ForEach-Object { . $_.FullName }
You might have noticed $PSScriptRoot
in both versions of the function-loading script. This is where we hit our first key difference between regular PowerShell scripts and modules.
- In a regular script (
.ps1
file):$PSScriptRoot
returns the file path of the script. - In a module (
.psm1
file):$PSScriptRoot
returns the directory of the module.
This is crucial because when working with modules, you don’t want absolute file paths—you want to reference files dynamically relative to the module’s location. That’s why $PSScriptRoot
is used to load functions from the Public
and Private
folders.
Persistent Module Variables
One of the biggest differences between PowerShell modules and scripts is how variables persist across function calls. Let’s compare both approaches.
In a regular script (.ps1
), variables exist only during script execution. Once the script finishes, all variables are gone. I’m not talking about development. Save the script, and call it.
# Define a cache variable
$cache = @{}
function Set-CachedItem {
param ($Key, $Value)
$cache[$Key] = $Value
}
function Get-CachedItem {
param ($Key)
return $cache[$Key]
}
# Set a value
Set-CachedItem -Key "User1" -Value "Admin"
# Retrieve value (works within the same execution)
Write-Output (Get-CachedItem -Key "User1") # Output: Admin
# Uncomment 'Set-CachedItem' - Run the script again -> $cache is empty!
Module Variables (Persist While the Module is Loaded)
# Module-level cache variable
$script:Cache = @{}
function Set-CachedItem {
param ($Key, $Value)
$script:Cache[$Key] = $Value
}
function Get-CachedItem {
param ($Key)
return $script:Cache[$Key]
}
Import-Module MyModule
Set-CachedItem -Key "User1" -Value "Admin"
Write-Output (Get-CachedItem -Key "User1") # Output: Admin
# Run another command (variable still exists)
Set-CachedItem -Key "User2" -Value "Editor"
Write-Output (Get-CachedItem -Key "User2") # Output: Editor
# Unload the module → Variable is lost
Remove-Module MyModule
Variable Scopes
You just saw $script:Cache
in the module example, and you might be wondering, “What’s this $script:
scope all about?”
Well, in PowerShell, the scope in which a variable is defined determines its visibility and lifespan. The $script:
scope is one of those special scopes that’s used for variables you want to persist throughout the execution of the module or script, but keep it restricted to the module.
In this case, $script:Cache
is defined at the module level and can be accessed by any function within the module, but it won’t interfere with other scripts or modules. It’s a way to keep the data private to your module, while still being able to store and reuse it as needed—without cluttering the global space.
The $global:
scope is used for variables that are accessible from anywhere in the entire PowerShell session, whether inside functions, scripts, or even other modules. Once you define a variable in the $global:
scope, it’s essentially open to the whole session, and any script or function can modify or read it.
Be careful with global variables!
Since any script or function can modify them, they might get overwritten unintentionally.
Using $script:
is often a better approach for module development to keep variables encapsulated.
Auto-Loading Modules ($env:PSModulePath
)
One of the advantages of PowerShell modules is auto-loading. If a module is installed in the $env:PSModulePath
, PowerShell can automatically discover and load it when a function from the module is called—without needing Import-Module
. You might have noticed that when you open PowerShell and immediately run Get-ADUser -Identity myIdentity
, the Active Directory module loads automatically—even though you didn’t explicitly import it.
Summarize
1. Always Use a Module Manifest
Even though it’s optional, a .psd1
manifest centralizes metadata like author details, versioning, and exported functions. Use New-ModuleManifest
to generate one.
2. Maintain a Clear Folder Structure
Organizing functions into Public
and Private
folders keeps your module clean and scalable. Each function should reside in its own .ps1
file.
3. Dynamically Load Functions
Use $PSScriptRoot
in your .psm1
file to dynamically dot-source functions from the Public and Private folders, avoiding hardcoded paths.
4. Use Persistent Variables Wisely
- $script: for module-level persistence while keeping data private to the module.
- $global: for session-wide variables (use sparingly to avoid conflicts).
5. Keep Public and Private Functions Separate
Only expose necessary functions to users by exporting only public functions in your manifest. Keep internal helper functions private to avoid cluttering the user’s namespace.