Script Files, Scope, and Modules
Summary: in this tutorial, you will learn package powershell functions into script files, understand variable scope, organize code into modules, and distribute your tools.
Script Files, Scope, and Modules
Functions are the building blocks, but to share and distribute your PowerShell tools, you need to package them into script files and modules. This tutorial covers script file best practices, how PowerShell's scope system controls variable visibility, and how to create modules that organize related functions into reusable packages.
Script Files: Packaging for Distribution
Functions are great for interactive sessions and sourcing, but script files (.ps1) are how you package code for reuse and distribution.
Basic Script Structure
A well-structured script has:
- Comment-based help (optional but professional)
- Parameter block (if script accepts parameters)
- Script logic
<#
.SYNOPSIS
Backs up important directories to a specified location.
.DESCRIPTION
Creates compressed archives of specified source directories and saves them
to a backup location with timestamps. Includes logging and error handling.
.PARAMETER SourcePaths
Array of directory paths to back up.
.PARAMETER BackupLocation
Destination directory for backup archives.
.PARAMETER RetainDays
Number of days to retain old backups. Older backups are automatically deleted.
Default is 30 days.
.EXAMPLE
.\Backup-Directories.ps1 -SourcePaths "C:\Projects","C:\Documents" -BackupLocation "D:\Backups"
.EXAMPLE
.\Backup-Directories.ps1 -SourcePaths "C:\Projects" -BackupLocation "D:\Backups" -RetainDays 60
.NOTES
Author: Your Name
Date: 2025-01-15
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Container })]
[string[]]$SourcePaths,
[Parameter(Mandatory)]
[string]$BackupLocation,
[Parameter()]
[ValidateRange(1, 365)]
[int]$RetainDays = 30
)
# Script logic begins
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
# Ensure backup location exists
if (!(Test-Path $BackupLocation)) {
New-Item $BackupLocation -ItemType Directory -Force | Out-Null
}
foreach ($source in $SourcePaths) {
$sourceName = Split-Path $source -Leaf
$archiveName = "${sourceName}_${timestamp}.zip"
$archivePath = Join-Path $BackupLocation $archiveName
if ($PSCmdlet.ShouldProcess($archivePath, "Create backup archive")) {
Write-Host "Backing up $source..." -ForegroundColor Cyan
try {
Compress-Archive -Path $source -DestinationPath $archivePath -CompressionLevel Optimal
Write-Host " ✓ Created $archiveName" -ForegroundColor Green
} catch {
Write-Error "Failed to backup $source: $($_.Exception.Message)"
}
}
}
# Clean up old backups
if ($PSCmdlet.ShouldProcess("Old backups (older than $RetainDays days)", "Delete")) {
$cutoffDate = (Get-Date).AddDays(-$RetainDays)
Get-ChildItem $BackupLocation -Filter *.zip |
Where-Object { $_.LastWriteTime -lt $cutoffDate } |
ForEach-Object {
Remove-Item $_.FullName -Force
Write-Verbose "Deleted old backup: $($_.Name)"
}
}
Write-Host "Backup completed!" -ForegroundColor Green
Comment-based help uses special keywords (.SYNOPSIS, .DESCRIPTION, etc.) that PowerShell's Get-Help command recognizes:
Get-Help .\Backup-Directories.ps1 -Full
This displays professional documentation just like built-in cmdlets.
Script Parameters vs Function Parameters
Script parameters work identically to function parameters:
param(
[Parameter(Mandatory)]
[string]$ServerName,
[ValidateSet("Start", "Stop", "Restart")]
[string]$Action = "Restart"
)
# Rest of script logic...
Run the script:
.\Manage-Service.ps1 -ServerName "SQLSERVER01" -Action "Start"
Scope: Understanding Variable Visibility
PowerShell has multiple scopes that control where variables are visible and can be modified.
Scope Hierarchy
Scopes are hierarchical:
- Global: Variables visible everywhere in the session
- Script: Variables visible throughout a script file, but not outside
- Local: Variables in the current scope (function, script block, etc.)
- Private: Variables in the current scope only, not visible to child scopes
$globalVar = "I'm global"
function Test-Scope {
$localVar = "I'm local to Test-Scope"
Write-Host "Inside function:"
Write-Host " globalVar = $globalVar" # Accessible
Write-Host " localVar = $localVar" # Accessible
}
Test-Scope
Write-Host "Outside function:"
Write-Host " globalVar = $globalVar" # Accessible
Write-Host " localVar = $localVar" # Not accessible (empty)
Functions create new local scopes. Variables created in a function aren't visible outside unless explicitly scoped.
Modifying Parent Scope Variables
By default, assigning to a variable in a function creates a local variable, even if a parent variable has the same name:
$counter = 10
function Increment-Counter {
$counter = $counter + 1 # Creates LOCAL $counter, doesn't affect parent
Write-Host "Inside: $counter"
}
Increment-Counter # Inside: 11
Write-Host "Outside: $counter" # Outside: 10 (unchanged!)
To modify the parent scope's variable, use scope modifiers:
$counter = 10
function Increment-CounterCorrect {
$script:counter = $script:counter + 1 # Modifies script-level variable
Write-Host "Inside: $script:counter"
}
Increment-CounterCorrect # Inside: 11
Write-Host "Outside: $counter" # Outside: 11 (modified)
Scope Modifiers
$global:varname: Accesses/modifies global scope$script:varname: Accesses/modifies script scope$local:varname: Explicitly local (default behavior)$private:varname: Private to current scope (not visible to child functions)
$global:config = @{}
function Set-GlobalConfig {
param([string]$Key, [string]$Value)
$global:config[$Key] = $Value
}
Set-GlobalConfig -Key "Server" -Value "SQL01"
$global:config["Server"] # SQL01
Best practice: Minimize use of $global: and $script: scope modifiers. Prefer passing values as parameters and returning values as output — makes functions more testable and predictable.
Modules: Organizing Related Functions
As your collection of functions grows, organize them into modules — reusable packages of functions, variables, and other code.
Script Module (.psm1)
A script module is simply a .psm1 file containing functions:
File: MyUtilities.psm1
function Get-Timestamp {
Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
function Write-Log {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet("Info", "Warning", "Error")]
[string]$Level = "Info"
)
$timestamp = Get-Timestamp
$logMessage = "[$timestamp] [$Level] $Message"
switch ($Level) {
"Info" { Write-Host $logMessage -ForegroundColor Green }
"Warning" { Write-Host $logMessage -ForegroundColor Yellow }
"Error" { Write-Host $logMessage -ForegroundColor Red }
}
}
function Test-ServerConnection {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$ComputerName,
[int]$TimeoutSeconds = 5
)
process {
Write-Log "Testing connection to $ComputerName..."
$result = Test-Connection $ComputerName -Count 1 -Quiet -TimeoutSeconds $TimeoutSeconds
[PSCustomObject]@{
ComputerName = $ComputerName
IsOnline = $result
Timestamp = Get-Date
}
}
}
# Export only specific functions (optional)
Export-ModuleMember -Function Get-Timestamp, Write-Log, Test-ServerConnection
Using Modules
Import the module:
Import-Module .\MyUtilities.psm1
Now all exported functions are available:
Write-Log "Application started" -Level Info
Test-ServerConnection "SERVER01"
Remove the module from session:
Remove-Module MyUtilities
Module Manifest (.psd1)
For professional modules, create a manifest with metadata:
New-ModuleManifest -Path MyUtilities.psd1 `
-Author "Your Name" `
-Description "Utility functions for system administration" `
-ModuleVersion "1.0.0" `
-RootModule "MyUtilities.psm1" `
-FunctionsToExport @("Get-Timestamp", "Write-Log", "Test-ServerConnection")
The manifest specifies module version, author, dependencies, and exported functions. Import by manifest:
Import-Module .\MyUtilities.psd1
Installing Modules to Standard Locations
For modules you want always available:
# Check module paths
$env:PSModulePath -split ';'
# C:\Users\YourName\Documents\PowerShell\Modules
# C:\Program Files\PowerShell\Modules
# C:\Windows\System32\WindowsPowerShell\v1.0\Modules
# Copy your module to user modules directory
$modulePath = "$HOME\Documents\PowerShell\Modules\MyUtilities"
New-Item $modulePath -ItemType Directory -Force
Copy-Item MyUtilities.* $modulePath\
# Now import works from anywhere
Import-Module MyUtilities
Exercises
Create an advanced function New-SystemUser that:
- Accepts username, email, and role parameters
- Validates:
- Username is 3-20 characters
- Email matches basic email pattern
- Role is one of: Admin, User, Guest
- Supports
-WhatIfand-Confirm - Supports pipeline input by property name
- Outputs a custom object with user details
Show Solution
function New-SystemUser {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateLength(3, 20)]
[ValidatePattern('^[a-zA-Z0-9_-]+$', ErrorMessage = "Username can only contain letters, numbers, underscores, and hyphens")]
[string]$Username,
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidatePattern('^[\w.+-]+@[\w.-]+\.\w+$')]
[string]$Email,
[Parameter(ValueFromPipelineByPropertyName)]
[ValidateSet("Admin", "User", "Guest")]
[string]$Role = "User"
)
begin {
Write-Verbose "Starting user creation process..."
}
process {
if ($PSCmdlet.ShouldProcess("User: $Username", "Create system user")) {
Write-Verbose "Creating user '$Username' with role '$Role'..."
# Simulate user creation
$user = [PSCustomObject]@{
Username = $Username
Email = $Email
Role = $Role
CreatedDate = Get-Date
Status = "Active"
ID = [guid]::NewGuid().ToString()
}
Write-Host "✓ Created user: $Username ($Role)" -ForegroundColor Green
# Output the user object
$user
}
}
end {
Write-Verbose "User creation process completed"
}
}
# Test with single user
New-SystemUser -Username "jdoe" -Email "jdoe@example.com" -Role "Admin" -Verbose
# Test with pipeline input
@(
@{Username="alice"; Email="alice@example.com"; Role="Admin"}
@{Username="bob"; Email="bob@example.com"; Role="User"}
@{Username="charlie"; Email="charlie@example.com"}
) | ForEach-Object { [PSCustomObject]$_ } | New-SystemUser -Verbose
# Test -WhatIf
New-SystemUser -Username "testuser" -Email "test@example.com" -WhatIf
Create a script Analyze-Logs.ps1 that:
- Accepts a log file path parameter (must exist)
- Accepts optional filter parameter for log level (INFO, WARNING, ERROR)
- Counts occurrences of each log level
- Finds most recent error
- Outputs summary statistics
Assume log format: [2025-01-15 14:30:22] [ERROR] Database connection failed
Show Solution
<#
.SYNOPSIS
Analyzes log files and provides statistics.
.DESCRIPTION
Parses structured log files, counts log levels, and identifies recent errors.
.PARAMETER LogPath
Path to the log file to analyze.
.PARAMETER FilterLevel
Optional filter to show only specific log levels.
.EXAMPLE
.\Analyze-Logs.ps1 -LogPath "C:\Logs\app.log"
.EXAMPLE
.\Analyze-Logs.ps1 -LogPath "C:\Logs\app.log" -FilterLevel ERROR -Verbose
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateScript({
if (Test-Path $_ -PathType Leaf) { $true }
else { throw "Log file '$_' does not exist" }
})]
[string]$LogPath,
[Parameter()]
[ValidateSet("INFO", "WARNING", "ERROR")]
[string]$FilterLevel
)
Write-Verbose "Analyzing log file: $LogPath"
# Read and parse log file
$logEntries = Get-Content $LogPath | ForEach-Object {
if ($_ -match '\[(.+?)\]\s+\[(.+?)\]\s+(.+)') {
[PSCustomObject]@{
Timestamp = [datetime]::Parse($Matches[1])
Level = $Matches[2]
Message = $Matches[3]
}
}
}
Write-Verbose "Parsed $($logEntries.Count) log entries"
# Apply filter if specified
if ($FilterLevel) {
$logEntries = $logEntries | Where-Object { $_.Level -eq $FilterLevel }
Write-Verbose "Filtered to $($logEntries.Count) $FilterLevel entries"
}
# Count by level
$levelCounts = $logEntries | Group-Object Level | Sort-Object Count -Descending
# Find most recent error
$lastError = $logEntries | Where-Object { $_.Level -eq "ERROR" } | Sort-Object Timestamp -Descending | Select-Object -First 1
# Display summary
Write-Host ""
Write-Host "=== Log Analysis Summary ===" -ForegroundColor Cyan
Write-Host "File: $LogPath" -ForegroundColor White
Write-Host "Total entries: $($logEntries.Count)" -ForegroundColor White
Write-Host ""
Write-Host "Entries by level:" -ForegroundColor Cyan
$levelCounts | ForEach-Object {
$color = switch ($_.Name) {
"ERROR" { "Red" }
"WARNING" { "Yellow" }
default { "Green" }
}
Write-Host " $($_.Name): $($_.Count)" -ForegroundColor $color
}
if ($lastError) {
Write-Host ""
Write-Host "Most recent error:" -ForegroundColor Red
Write-Host " Time: $($lastError.Timestamp)" -ForegroundColor White
Write-Host " Message: $($lastError.Message)" -ForegroundColor White
}
# Output structured results
[PSCustomObject]@{
LogFile = $LogPath
TotalEntries = $logEntries.Count
LevelCounts = $levelCounts
LastError = $lastError
}
Create a sample log file to test:
$sampleLog = @"
[2025-01-15 10:00:00] [INFO] Application started
[2025-01-15 10:05:30] [INFO] User logged in: alice
[2025-01-15 10:10:45] [WARNING] High memory usage detected
[2025-01-15 10:15:22] [ERROR] Database connection failed
[2025-01-15 10:16:05] [INFO] Retrying database connection
[2025-01-15 10:16:30] [INFO] Database connection restored
[2025-01-15 10:20:10] [ERROR] File not found: config.xml
[2025-01-15 10:25:00] [INFO] Processing completed
"@
$sampleLog | Out-File "C:\temp\sample.log"
# Test the script
.\Analyze-Logs.ps1 -LogPath "C:\temp\sample.log" -Verbose
.\Analyze-Logs.ps1 -LogPath "C:\temp\sample.log" -FilterLevel ERROR
Create a module StringUtilities with three functions:
ConvertTo-TitleCase— converts strings to Title CaseRemove-SpecialCharacters— removes non-alphanumeric charactersGet-StringHash— generates SHA256 hash of string
Package as a module with manifest and test all functions.
Show Solution
# File: StringUtilities.psm1
function ConvertTo-TitleCase {
<#
.SYNOPSIS
Converts a string to Title Case.
.EXAMPLE
ConvertTo-TitleCase "hello world" # Hello World
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Text
)
process {
$textInfo = (Get-Culture).TextInfo
$textInfo.ToTitleCase($Text.ToLower())
}
}
function Remove-SpecialCharacters {
<#
.SYNOPSIS
Removes all non-alphanumeric characters from a string.
.EXAMPLE
Remove-SpecialCharacters "Hello, World! @2025" # HelloWorld2025
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Text,
[Parameter()]
[switch]$KeepSpaces
)
process {
if ($KeepSpaces) {
$Text -replace '[^a-zA-Z0-9 ]', ''
} else {
$Text -replace '[^a-zA-Z0-9]', ''
}
}
}
function Get-StringHash {
<#
.SYNOPSIS
Computes SHA256 hash of a string.
.EXAMPLE
Get-StringHash "password123"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Text,
[Parameter()]
[ValidateSet("SHA1", "SHA256", "SHA512", "MD5")]
[string]$Algorithm = "SHA256"
)
process {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
$hasher = [System.Security.Cryptography.HashAlgorithm]::Create($Algorithm)
$hashBytes = $hasher.ComputeHash($bytes)
$hashString = ($hashBytes | ForEach-Object { $_.ToString("x2") }) -join ''
[PSCustomObject]@{
Text = $Text
Algorithm = $Algorithm
Hash = $hashString
}
}
}
# Export module members
Export-ModuleMember -Function ConvertTo-TitleCase, Remove-SpecialCharacters, Get-StringHash
Create module manifest:
New-ModuleManifest -Path StringUtilities.psd1 `
-Author "Your Name" `
-Description "Utility functions for string manipulation" `
-ModuleVersion "1.0.0" `
-RootModule "StringUtilities.psm1" `
-FunctionsToExport @("ConvertTo-TitleCase", "Remove-SpecialCharacters", "Get-StringHash")
Test the module:
# Import module
Import-Module .\StringUtilities.psd1 -Force
# Test ConvertTo-TitleCase
"hello world from powershell" | ConvertTo-TitleCase
# Output: Hello World From Powershell
# Test Remove-SpecialCharacters
"User@123: John-Doe!" | Remove-SpecialCharacters
# Output: User123JohnDoe
"User@123: John-Doe!" | Remove-SpecialCharacters -KeepSpaces
# Output: User123 JohnDoe
# Test Get-StringHash
"mypassword" | Get-StringHash
Get-StringHash -Text "mypassword" -Algorithm SHA1
# Test pipeline
@("test1", "test2", "test3") | Get-StringHash | Format-Table
# Check exported functions
Get-Command -Module StringUtilities
Install module permanently:
$modulePath = "$HOME\Documents\PowerShell\Modules\StringUtilities"
New-Item $modulePath -ItemType Directory -Force
Copy-Item StringUtilities.* $modulePath\
# Now available from anywhere
Import-Module StringUtilities
Summary
Functions and scripts are how you build maintainable, reusable PowerShell code:
- Basic functions encapsulate logic with parameters and return values
- Parameter validation uses attributes like
[ValidateSet],[ValidateRange],[ValidateScript]to enforce input requirements - Advanced functions with
[CmdletBinding()]gain common parameters, pipeline support, and professional behavior - Begin/Process/End blocks enable efficient pipeline processing
- SupportsShouldProcess adds
-WhatIfand-Confirmfor safe modifications - Scripts package functions for distribution with comment-based help
- Scope controls variable visibility across functions and scripts
- Modules organize related functions into reusable packages
Master these concepts to write PowerShell code that's indistinguishable from built-in cmdlets in quality and capabilities.
Written by the ShellRAG Team
The ShellRAG editorial team writes practical, beginner-friendly PowerShell tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.
Learn more about us →