A script is fine for one task. The second time you need the same logic you should reach for a function. The third time, a module. PowerShell has a graceful progression from "interactive line" to "published module on the gallery."
Basic Function
function Get-Greeting {
param(
[string]$Name = "world"
)
"Hello, $Name!"
}
Get-Greeting
Get-Greeting -Name "Alice"
Get-Greeting Alice # positional
The function emits whatever its last expression returns. return exists but is rarely needed — anything that "falls out" goes to the pipeline.
Advanced Functions
Add [CmdletBinding()] at the top and the function becomes an "advanced function" — it gains all the common parameters (-Verbose, -ErrorAction, -WhatIf, -Confirm) and the parameter machinery cmdlets enjoy.
function Get-LogSummary {
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[Parameter()]
[ValidateSet("ERROR", "WARN", "INFO")]
[string]$Level = "ERROR",
[Parameter()]
[int]$Top = 10
)
Write-Verbose "Reading $Path for level $Level"
Get-Content $Path |
Select-String -Pattern $Level |
Group-Object |
Sort-Object Count -Descending |
Select-Object -First $Top
}
Get-LogSummary -Path app.log -Level WARN -Verbose
Key attributes:
[Parameter(Mandatory)]— required parameter; PowerShell prompts if omitted[Parameter(Position = 0)]— positional[ValidateSet(...)]— restrict to a list of values; tab-completes[ValidateRange(1, 100)]— numeric range[ValidatePattern("^\d+$")]— regex[ValidateScript({ ... })]— arbitrary validation;$_is the value[ValidateNotNullOrEmpty()]— common-case validation
Validation happens before the function body runs and produces clean error messages.
Pipeline Input
Make a function accept pipeline input by marking a parameter:
function Test-Reachable {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$ComputerName
)
process {
if (Test-Connection $ComputerName -Count 1 -Quiet) {
[pscustomobject]@{ ComputerName = $ComputerName; Reachable = $true }
} else {
[pscustomobject]@{ ComputerName = $ComputerName; Reachable = $false }
}
}
}
"github.com", "example.com", "doesnotexist.invalid" | Test-Reachable
Three special blocks make pipeline-binding work properly:
begin { }— runs once before processingprocess { }— runs once per pipeline itemend { }— runs once after
For pipeline-aware functions, the body goes in process. Plain functions without these blocks process all items at once — usually not what you want for streaming.
Comment-Based Help
Add a structured comment block above the function and Get-Help shows it:
function Get-Greeting {
<#
.SYNOPSIS
Returns a greeting message.
.DESCRIPTION
Builds a greeting using the Name parameter. Defaults to "world".
.PARAMETER Name
The name to greet.
.EXAMPLE
Get-Greeting -Name "Alice"
.OUTPUTS
System.String
#>
param([string]$Name = "world")
"Hello, $Name!"
}
Get-Help Get-Greeting -Examples
Production functions deserve full help. Microsoft's authoring guidance is consistent and expected by reviewers.
Scripts
A script is a .ps1 file. Run it directly: ./deploy.ps1 -Environment prod. Scripts can declare a param(...) block at the top:
# deploy.ps1
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet("dev", "staging", "prod")]
[string]$Environment,
[switch]$DryRun
)
Write-Verbose "Deploying to $Environment"
if ($DryRun) { "Would deploy to $Environment"; return }
# real work...
[switch] is the type for boolean flags — -DryRun on the command line.
Modules
A module bundles related functions. Two kinds:
- Script module — a
.psm1file containing functions, sometimes with a.psd1manifest - Binary module — a compiled .NET DLL exposing cmdlets (rare for ordinary users)
Minimal script module:
# MyTools/MyTools.psm1
function Get-PublicIP {
(Invoke-RestMethod ifconfig.me/ip).Trim()
}
function Get-DiskFree {
[CmdletBinding()]
param([string]$Path = "/")
[pscustomobject]@{
Path = $Path
FreeGB = [math]::Round((Get-PSDrive (Split-Path $Path -Qualifier).TrimEnd(":")).Free / 1GB, 2)
}
}
Export-ModuleMember -Function Get-PublicIP, Get-DiskFree
Place under $HOME/Documents/PowerShell/Modules/MyTools/ (Windows) or ~/.local/share/powershell/Modules/MyTools/ (macOS/Linux). PowerShell auto-discovers and lazy-loads it: typing Get-PublicIP imports the module if needed.
Module Manifest (.psd1)
For a publishable module, add a manifest:
New-ModuleManifest -Path ./MyTools/MyTools.psd1 `
-RootModule MyTools.psm1 `
-ModuleVersion "1.0.0" `
-Author "You" `
-Description "Personal toolkit" `
-PowerShellVersion "7.0" `
-FunctionsToExport @("Get-PublicIP", "Get-DiskFree")
The manifest declares version, dependencies, exported functions, required modules, supported runtime. PowerShellGet needs it to publish.
The PowerShell Gallery
PowerShell Gallery (PSGallery) is the central repository, like npm or PyPI. Use it daily:
Find-Module Az
Install-Module Az -Scope CurrentUser
Update-Module Az
Get-InstalledModule
# Publish your own (with an API key from gallery)
Publish-Module -Path ./MyTools -NuGetApiKey $env:GALLERY_KEY
For private distribution use a private gallery (Azure DevOps Artifacts, GitHub Packages with the NuGet provider) or simply git-clone and copy.
Patterns for Reusability
- Verb-Noun naming — your functions look like cmdlets and benefit from discovery.
- One function, one job. Resist God functions; compose smaller ones via the pipeline.
- Emit objects, not strings.
[pscustomobject]makes the next person's job easier. - Use
Write-Verbosefor diagnostic chatter;Write-Informationfor progress; neverWrite-Hostin scripts (it writes to the host directly, bypassing the pipeline). - Handle
-WhatIf/-Confirmfor destructive functions — declareSupportsShouldProcess = $trueand check$PSCmdlet.ShouldProcess(...).
With functions and modules, you can scale from "one-liner I ran once" to "150-function module my team depends on." The next lesson covers the most important production concern — errors, when and how they happen, and how you respond.