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:

  1. Comment-based help (optional but professional)
  2. Parameter block (if script accepts parameters)
  3. 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.

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

🏋️ Exercise 1: User Management Function

Create an advanced function New-SystemUser that:

  1. Accepts username, email, and role parameters
  2. Validates:
    • Username is 3-20 characters
    • Email matches basic email pattern
    • Role is one of: Admin, User, Guest
  3. Supports -WhatIf and -Confirm
  4. Supports pipeline input by property name
  5. 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
 
🏋️ Exercise 2: Log File Analyzer Script

Create a script Analyze-Logs.ps1 that:

  1. Accepts a log file path parameter (must exist)
  2. Accepts optional filter parameter for log level (INFO, WARNING, ERROR)
  3. Counts occurrences of each log level
  4. Finds most recent error
  5. 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
 
🏋️ Exercise 3: Build a Simple Module

Create a module StringUtilities with three functions:

  1. ConvertTo-TitleCase — converts strings to Title Case
  2. Remove-SpecialCharacters — removes non-alphanumeric characters
  3. Get-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 -WhatIf and -Confirm for 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.

Was this page helpful?
SR

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 →