cheat sheet
PowerShell Scripting
Author production-grade PowerShell: advanced functions with [CmdletBinding()], parameter validation, pipeline-bound parameters, splatting, switch parameters, .ps1 vs .psm1, and all the control-flow constructs.
PowerShell Scripting — Functions, Parameters, and Control Flow
What it is
This article covers how to write production-grade PowerShell scripts and functions — the kind that pass PSScriptAnalyzer, behave well in pipelines, validate their input, expose -Verbose/-WhatIf/-Confirm, and look indistinguishable from compiled cmdlets to the people calling them. The core ingredients: [CmdletBinding()] to make a "simple function" an "advanced function"; the param() block with [Parameter()] attributes and validators; pipeline-bound parameters with process blocks; splatting; switch parameters; the .ps1 (script) vs .psm1 (module) split; and the full set of control-flow constructs (if, switch, for, foreach, while, do). Treat this as the bridge between writing one-off command-line one-liners and shipping reusable PowerShell tools.
Simple functions vs advanced functions
A simple function is a function Name { ... } block with no special attributes — it accepts arguments via $args, has no automatic parameters, and doesn't appear in Get-Help. An advanced function opts in by adding [CmdletBinding()] (and usually a param() block); in exchange, PowerShell adds common parameters (-Verbose, -Debug, -ErrorAction, -ErrorVariable, -WarningAction, -OutVariable, -WhatIf, -Confirm), proper help support, and the process/begin/end block structure for pipeline input. Always reach for the advanced form for anything you'll reuse.
# Simple — minimal, but no common params
function Greet-Simple {
"Hello, $($args[0])!"
}
# Advanced — typed param, gets -Verbose automatically
function Greet-Advanced {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name
)
Write-Verbose "Greeting $Name"
"Hello, $Name!"
}
Greet-Simple 'Alice Dev'
Greet-Advanced 'Alice Dev' -Verbose
Output:
Hello, Alice Dev!
VERBOSE: Greeting Alice Dev
Hello, Alice Dev!
Even when an advanced function only takes one parameter, it's worth the extra ceremony — you get -WhatIf support for free (with SupportsShouldProcess), proper help, and tab-completion for parameter names.
The param() block
param() declares the inputs to a function or script. Inside it, you list one parameter per line, each annotated with [Parameter()] (controls naming, position, mandatoryness, pipeline binding), zero or more validators ([ValidateSet()], [ValidateRange()], …), and a typed declaration. Default values come after =.
function Get-Snapshot {
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string]$Name,
[Parameter()]
[ValidateSet('json', 'csv', 'yaml')]
[string]$Format = 'json',
[Parameter()]
[ValidateRange(1, 100)]
[int]$MaxItems = 10,
[Parameter()]
[string[]]$Tag = @(),
[switch]$IncludeDeleted
)
[pscustomobject]@{
Name = $Name
Format = $Format
MaxItems = $MaxItems
Tag = $Tag
IncludeDeleted = [bool]$IncludeDeleted
}
}
Get-Snapshot -Name 'daily' -Format csv -Tag prod, eu-west
Output:
Name : daily
Format : csv
MaxItems : 10
Tag : {prod, eu-west}
IncludeDeleted : False
The [Parameter()] attribute
[Parameter()] accepts a handful of named properties; the most useful ones are listed below. Setting Mandatory = $true (or just Mandatory) makes PowerShell prompt for the value if the caller omits it; Position = 0 lets the caller pass it without naming.
| Property | What it does |
|---|---|
Mandatory | Refuse to run without this param (prompts interactively if missing). |
Position | Allows positional passing (Get-Snapshot 'daily' instead of -Name 'daily'). |
ValueFromPipeline | Bind the whole pipeline object to this param. |
ValueFromPipelineByPropertyName | Bind a same-named property of the pipeline object. |
ParameterSetName | Group params into mutually exclusive sets (different signatures). |
HelpMessage | Text shown when a mandatory param prompt fires. |
DontShow | Hide from tab completion and IntelliSense. |
function Set-WidgetState {
[CmdletBinding(DefaultParameterSetName = 'ById')]
param(
[Parameter(Mandatory, ParameterSetName = 'ById', ValueFromPipelineByPropertyName)]
[int]$Id,
[Parameter(Mandatory, ParameterSetName = 'ByName')]
[string]$Name,
[Parameter(Mandatory)]
[ValidateSet('Enabled', 'Disabled', 'Pending')]
[string]$State
)
process {
"Setting $($PSCmdlet.ParameterSetName) → $State for $(if ($Id) { $Id } else { $Name })"
}
}
Set-WidgetState -Id 42 -State Enabled
Set-WidgetState -Name 'alpha' -State Disabled
Output:
Setting ById → Enabled for 42
Setting ByName → Disabled for alpha
Parameter validation
Validators run before the function body and reject bad input with a clear error message. Use them instead of writing your own if (...) throw checks — they integrate with Get-Help, tab completion, and prompt messages.
[ValidateSet] — one of a fixed list
[ValidateSet] constrains a string (or any value type with overridden equality) to a fixed set of options; tab completion offers the values. Use it for environment names, log levels, output formats, anything enum-like.
function Deploy {
[CmdletBinding()]
param(
[ValidateSet('dev','staging','prod')]
[string]$Environment = 'dev'
)
"Deploying to $Environment"
}
Deploy -Environment prod
Deploy -Environment qa # errors before body runs
Output:
Deploying to prod
Deploy: Cannot validate argument on parameter 'Environment'.
The argument "qa" does not belong to the set "dev,staging,prod" specified by the ValidateSet attribute.
[ValidateRange] — bounded numbers
[ValidateRange] requires an integer or any IComparable to fall within a min/max. PowerShell 6.2+ also accepts the special keywords Positive, Negative, NonNegative, NonPositive.
function Set-Replicas {
param(
[ValidateRange(1, 50)] [int]$Count = 3,
[ValidateRange('Positive')] [int]$Timeout = 30 # PS 6.2+
)
"replicas=$Count timeout=${Timeout}s"
}
Set-Replicas -Count 5 -Timeout 60
Set-Replicas -Count 200 # rejected
Output:
replicas=5 timeout=60s
Set-Replicas: Cannot validate argument on parameter 'Count'.
The 200 argument is greater than the maximum allowed range of 50.
[ValidateScript] — arbitrary predicate
[ValidateScript] runs a script block that returns $true to accept or $false to reject. Use it when a ValidateSet/ValidateRange doesn't fit — for example, "the file must exist", "the URL must be reachable", or "the value must be a multiple of 4".
function Import-Config {
param(
[ValidateScript({
if (-not (Test-Path $_)) { throw "File not found: $_" }
if ((Get-Item $_).Length -gt 10MB) { throw "File too large: $_" }
$true
})]
[string]$Path
)
"Importing $Path"
}
Import-Config -Path 'C:\Users\Alice\config.json'
Import-Config -Path 'C:\nonexistent.json'
Output:
Importing C:\Users\Alice\config.json
Import-Config: Cannot validate argument on parameter 'Path'. File not found: C:\nonexistent.json
[ValidatePattern] — regex match
[ValidatePattern] requires a string to match a regular expression. Pair it with ErrorMessage (PowerShell 6+) for a friendlier message than the default.
function New-User {
param(
[ValidatePattern(
'^[a-z][a-z0-9-]{2,19}$',
ErrorMessage = 'Username must be 3-20 chars, lowercase, start with a letter.'
)]
[string]$Login
)
"Creating user: $Login"
}
New-User -Login 'alicedev'
New-User -Login '1bad' # rejected
Output:
Creating user: alicedev
New-User: Cannot validate argument on parameter 'Login'. Username must be 3-20 chars, lowercase, start with a letter.
[ValidateNotNullOrEmpty] and friends
These three save a lot of if ($null -eq $X) boilerplate. [ValidateNotNull] rejects $null; [ValidateNotNullOrEmpty] also rejects empty strings, empty arrays, and empty hashtables; [ValidateNotNullOrWhiteSpace] (PowerShell 7+) goes further and rejects whitespace-only strings.
function Send-Notice {
param(
[ValidateNotNullOrEmpty()]
[string]$Recipient,
[ValidateNotNullOrWhiteSpace()] # PS 7+
[string]$Subject
)
"→ $Recipient: $Subject"
}
Send-Notice -Recipient 'alice@example.com' -Subject 'Hi'
Send-Notice -Recipient '' -Subject 'Hi' # rejected
Send-Notice -Recipient 'alice@example.com' -Subject ' ' # rejected on PS 7+
Output:
→ alice@example.com: Hi
Send-Notice: Cannot validate argument on parameter 'Recipient'. The argument is null or empty.
Send-Notice: Cannot validate argument on parameter 'Subject'. The argument is null, empty, or consists of only white-space.
[ValidateCount] and [ValidateLength]
[ValidateCount(min, max)] constrains how many items an array parameter has; [ValidateLength(min, max)] constrains string length.
function Add-Tags {
param(
[ValidateCount(1, 10)]
[string[]]$Tag,
[ValidateLength(3, 32)]
[string]$Bucket
)
"$Bucket gets tags: $($Tag -join ', ')"
}
Add-Tags -Tag prod, eu -Bucket 'main-bucket'
Add-Tags -Tag @() -Bucket 'main-bucket' # rejected (count)
Add-Tags -Tag prod -Bucket 'x' # rejected (length)
Output:
main-bucket gets tags: prod, eu
Add-Tags: Cannot validate argument on parameter 'Tag'. The argument count of 0 is too small. Specify between 1 and 10 arguments.
Add-Tags: Cannot validate argument on parameter 'Bucket'. The character length of 1 is too short. Specify an argument with a length between 3 and 32 characters.
Pipeline-bound parameters
Advanced functions can accept input from the pipeline by adding ValueFromPipeline or ValueFromPipelineByPropertyName to a [Parameter()] attribute. The function then needs a process block — the script body that runs once per pipeline item — and optionally begin (one-time setup) and end (one-time teardown) blocks.
ValueFromPipeline — whole-object binding
ValueFromPipeline binds the entire pipeline object to the parameter. The receiving param must be typed compatibly: a [string] param accepts string input, an [object] param accepts anything, and a [FileInfo] param accepts Get-ChildItem output directly.
function Get-Lower {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Text
)
process { $Text.ToLowerInvariant() }
}
'Alice', 'Bob', 'CARLA' | Get-Lower
Output:
alice
bob
carla
ValueFromPipelineByPropertyName — property binding
ValueFromPipelineByPropertyName binds when an upstream object has a property whose name matches the parameter name (or one of its aliases). This is how Stop-Process accepts Get-Process output — Stop-Process -Id 42 works, and Get-Process pwsh | Stop-Process also works because each Process object has an Id property.
function Compress-Asset {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[Alias('FullName','PSPath')]
[string]$Path,
[Parameter(ValueFromPipelineByPropertyName)]
[int]$Length
)
process {
"$Path ($Length bytes) → would compress"
}
}
Get-ChildItem C:\Users\Alice\photos\*.png | Compress-Asset
Output:
C:\Users\Alice\photos\holiday.png (2410231 bytes) → would compress
C:\Users\Alice\photos\sunset.png (1184902 bytes) → would compress
C:\Users\Alice\photos\family.png (3201144 bytes) → would compress
The [Alias('FullName','PSPath')] line is the trick — FullName is what Get-ChildItem exposes, so adding it as an alias lets the binder match the property to our Path parameter.
begin / process / end — pipeline lifecycle
begin runs once before any pipeline input arrives; process runs once per item; end runs once after the last item. Use begin for accumulator setup, process for the per-item work, and end to flush results.
function Measure-Size {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[System.IO.FileInfo]$File
)
begin {
$total = 0
$count = 0
Write-Verbose 'Starting size accumulator'
}
process {
$total += $File.Length
$count++
}
end {
[pscustomobject]@{
Files = $count
TotalMB = [Math]::Round($total / 1MB, 2)
AvgMB = if ($count) { [Math]::Round(($total / $count) / 1MB, 2) } else { 0 }
}
}
}
Get-ChildItem C:\Users\Alice\photos -File | Measure-Size -Verbose
Output:
VERBOSE: Starting size accumulator
Files TotalMB AvgMB
----- ------- -----
42 124.71 2.97
If you omit begin/process/end and just write an inline body, the body becomes an implicit end block — meaning pipeline input is collected first and then your code runs once. That's almost never what you want; always wrap pipeline-handling code in process.
Switch parameters
A [switch] parameter is a boolean that defaults to $false and becomes $true when the caller passes the flag. Use it for opt-in toggles (-Force, -Recurse, -IncludeDeleted) — never for opt-out toggles, because -NoFoo reads as a double negative when set to $false.
function Remove-Cache {
[CmdletBinding()]
param(
[string]$Path = 'C:\Users\Alice\AppData\Local\MyApp\cache',
[switch]$Recurse,
[switch]$Force
)
if ($Force) { Write-Verbose 'Force mode' }
if ($Recurse) { Write-Verbose 'Recursive' }
Remove-Item $Path -Recurse:$Recurse -Force:$Force -WhatIf
}
Remove-Cache -Recurse -Force
Output:
What if: Performing the operation "Remove Directory" on target "C:\Users\Alice\AppData\Local\MyApp\cache".
The :$Recurse syntax (-Recurse:$Recurse) explicitly passes the switch's value to a downstream cmdlet, so the caller's -Recurse propagates correctly without manual if plumbing.
Splatting — clean parameter passing
Splatting passes a hashtable (or array) of parameters to a cmdlet using the @ sigil instead of $. The collection's keys map to parameter names and the values to argument values — long parameter lists become readable, and you can conditionally build the parameter set before calling.
Hashtable splatting (named parameters)
The hashtable form is the common case: build up @{ Param = value } and pass with @varname.
$params = @{
Path = 'C:\Users\Alice\report.csv'
Delimiter = ','
NoTypeInformation = $true
Encoding = 'UTF8'
Force = $true
}
$data | Export-Csv @params
Output: (none — writes to disk)
Conditional splatting
A killer feature: assemble the hashtable conditionally before splatting. No more strings of if/else blocks each with a duplicate command.
function Connect-Service {
param(
[string]$Server,
[pscredential]$Credential,
[switch]$UseSsl
)
$params = @{ ComputerName = $Server }
if ($Credential) { $params.Credential = $Credential }
if ($UseSsl) {
$params.UseSSL = $true
$params.Port = 5986
} else {
$params.Port = 5985
}
$params | Format-Table -AutoSize
# In real code: Invoke-Command @params -ScriptBlock { hostname }
}
Connect-Service -Server 'web01' -UseSsl
Output:
Name Value
---- -----
ComputerName web01
UseSSL True
Port 5986
Array splatting (positional)
Pass an array with @varname and the elements bind by position. Less common — most calls benefit from named splatting — but useful when wrapping a native command.
$args = @('alice@myhost', '-p', 2222)
ssh @args # equivalent to: ssh alice@myhost -p 2222
Output: (depends on the SSH target)
Forwarding parameters with @PSBoundParameters
$PSBoundParameters is an automatic variable that holds the parameters explicitly passed to the current function. Splat it into a downstream cmdlet to forward arguments without listing each one — handy for wrapper functions.
function Get-DetailedProcess {
[CmdletBinding()]
param(
[string]$Name,
[int]$Id,
[string]$ComputerName
)
$PSBoundParameters | Format-Table -AutoSize
# In real code: Get-Process @PSBoundParameters | Select-Object Name, Id, CPU
}
Get-DetailedProcess -Name 'pwsh' -Id 4892
Output:
Key Value
--- -----
Name pwsh
Id 4892
.ps1 vs .psm1 — scripts vs modules
A .ps1 is a script: PowerShell reads and executes it top to bottom. Functions defined in a .ps1 are scoped to its execution unless you dot-source it (. ./helpers.ps1) into the caller's scope. A .psm1 is a script module: PowerShell loads it once, evaluates the top-level statements, and exposes whatever the module exports (functions, aliases, variables, classes) for as long as it stays imported.
# helpers.ps1 — a script with a helper function
function Get-Greeting { param([string]$Who) "Hello, $Who!" }
Get-Greeting 'Alice Dev' # runs immediately when the script is executed
# Calling the script
& C:\Users\Alice\src\helpers.ps1
# Output: Hello, Alice Dev!
# Dot-sourcing — pulls Get-Greeting into the current scope
. C:\Users\Alice\src\helpers.ps1
Get-Greeting 'Bob'
Output:
Hello, Alice Dev!
Hello, Bob!
# helpers.psm1 — a module
function Get-Greeting { param([string]$Who) "Hello, $Who!" }
function private:_Internal { 'not exported' }
Export-ModuleMember -Function Get-Greeting # explicit export list
# Calling the module
Import-Module C:\Users\Alice\src\helpers.psm1
Get-Greeting 'Carla'
_Internal # error — not exported
Output:
Hello, Carla
_Internal: The term '_Internal' is not recognized as a name of a cmdlet, function, script file, or executable program.
When to use which
Use a .ps1 when | Use a .psm1 when |
|---|---|
Running a one-off task (provision.ps1) | Distributing reusable functions |
| You want everything to run on invocation | You want functions exposed without running |
| Scheduling via Task Scheduler / cron | Publishing to the Gallery / NuGet feed |
| Acting as the entry point for a workflow | Acting as a library imported by other code |
Pair a .psm1 with a .psd1 manifest (see the powershell-modules article) for any module you publish. For internal use, a bare .psm1 is fine — Import-Module path\to\file.psm1 works without a manifest.
Control flow
PowerShell ships the usual control-flow constructs — if, switch, for, foreach, while, do...while, do...until. The switch statement is particularly powerful: it supports regex matching, wildcards, file input, and per-case script blocks.
if / elseif / else
The most common branch. Conditions can be any expression — non-zero numbers, non-empty strings, and non-null objects are truthy; $null, 0, empty strings, and empty arrays are falsy.
function Get-Verdict {
param([int]$Score)
if ($Score -ge 90) { 'A' }
elseif ($Score -ge 80) { 'B' }
elseif ($Score -ge 70) { 'C' }
else { 'F' }
}
Get-Verdict 92
Get-Verdict 73
Get-Verdict 51
Output:
A
C
F
switch
switch matches a value against a list of patterns and runs the script block for the first match (or every match if -Regex or -Wildcard plus no break). It can take an entire file as input with -File, regex-match with -Regex, and compare case-sensitively with -CaseSensitive.
function Get-Verdict2 {
param([int]$Score)
switch ($Score) {
{ $_ -ge 90 } { 'A'; break }
{ $_ -ge 80 } { 'B'; break }
{ $_ -ge 70 } { 'C'; break }
default { 'F' }
}
}
Get-Verdict2 92
Get-Verdict2 73
Output:
A
C
# -Regex switch over a log file, with grouped patterns
switch -Regex -File 'C:\Users\Alice\logs\app.log' {
'^\s*\[(\d{4}-\d{2}-\d{2}) ([\d:]+)\] ERROR (.*)' {
[pscustomobject]@{
Date = $Matches[1]; Time = $Matches[2]; Message = $Matches[3]
}
}
'^\s*\[.*WARN' { $warnCount++ }
default { }
}
"Warnings seen: $warnCount"
Output:
Date Time Message
---- ---- -------
2026-05-23 14:02:11 Connection reset by peer
2026-05-23 14:18:42 Failed to flush write buffer
2026-05-24 08:01:01 Timeout waiting for response
Warnings seen: 14
for, foreach (statement), while, do
PowerShell has both a foreach statement and a ForEach-Object cmdlet. The statement form iterates over an in-memory collection without pipeline overhead — faster for hot loops. while repeats while a condition is truthy; do...while runs the body at least once and checks at the bottom; do...until flips the condition (run while false).
# for — classic C-style counter
for ($i = 0; $i -lt 5; $i++) { "i = $i" }
# foreach (statement) — iterate a collection
$colors = 'red','green','blue'
foreach ($c in $colors) { "color = $c" }
# while
$attempt = 0
while ($attempt -lt 3) {
"attempt #$($attempt + 1)"
$attempt++
}
# do...until — runs at least once
$picked = ''
do { $picked = 'yes' } until ($picked -eq 'yes')
"$picked picked"
Output:
i = 0
i = 1
i = 2
i = 3
i = 4
color = red
color = green
color = blue
attempt #1
attempt #2
attempt #3
yes picked
break, continue, return
break exits the innermost loop or switch block; continue skips to the next iteration; return exits the current function. Note: inside a process block, return ends only the current pipeline iteration, not the function — to abort the whole pipeline use throw or $PSCmdlet.ThrowTerminatingError(...).
function Find-FirstEven {
[CmdletBinding()]
param([int[]]$Numbers)
foreach ($n in $Numbers) {
if ($n -lt 0) { continue }
if ($n % 2 -eq 0) { return $n }
}
$null
}
Find-FirstEven -Numbers 1, -4, 3, 6, 9
Output:
6
Error handling in scripts
PowerShell distinguishes between terminating errors (caught by try/catch) and non-terminating ones (written to $Error but allowing the script to continue). Most cmdlets emit non-terminating errors by default. Promote them with -ErrorAction Stop (per-call) or $ErrorActionPreference = 'Stop' (script-wide), then handle with try/catch/finally.
function Get-ConfigFile {
[CmdletBinding()]
param([Parameter(Mandatory)] [string]$Path)
try {
$content = Get-Content $Path -Raw -ErrorAction Stop
$config = $content | ConvertFrom-Json -ErrorAction Stop
Write-Verbose "Loaded $Path"
$config
}
catch [System.IO.FileNotFoundException] {
Write-Error "File not found: $Path"
}
catch [System.Text.Json.JsonException] {
Write-Error "Invalid JSON in $Path: $($_.Exception.Message)"
}
catch {
Write-Error "Unexpected: $($_.Exception.Message)"
}
finally {
Write-Verbose 'Done loading config'
}
}
Get-ConfigFile -Path 'C:\Users\Alice\config.json' -Verbose
Get-ConfigFile -Path 'C:\nope.json'
Output:
VERBOSE: Loaded C:\Users\Alice\config.json
VERBOSE: Done loading config
Get-ConfigFile: File not found: C:\nope.json
VERBOSE: Done loading config
Inside a catch, $_ (alias $PSItem) is the ErrorRecord — $_.Exception is the underlying .NET exception, $_.ScriptStackTrace is the call stack, and $_.InvocationInfo.PositionMessage points at the line that threw.
SupportsShouldProcess — -WhatIf and -Confirm for free
Adding SupportsShouldProcess to [CmdletBinding()] and gating destructive operations with if ($PSCmdlet.ShouldProcess(...)) gives the caller -WhatIf (preview without executing) and -Confirm (prompt before executing) for free. Set ConfirmImpact = 'High' for actions that warrant a prompt by default at the user's $ConfirmPreference level.
function Remove-Snapshot {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Name
)
process {
if ($PSCmdlet.ShouldProcess($Name, 'Delete snapshot')) {
# In real code: Remove-Item "C:\snaps\$Name" -Recurse -Force
"Deleted $Name"
}
}
}
'snap-2026-05-20', 'snap-2026-05-21' | Remove-Snapshot -WhatIf
'snap-2026-05-22' | Remove-Snapshot -Confirm:$false
Output:
What if: Performing the operation "Delete snapshot" on target "snap-2026-05-20".
What if: Performing the operation "Delete snapshot" on target "snap-2026-05-21".
Deleted snap-2026-05-22
Comment-based help
Place a comment block immediately before (or at the top of) a function and PowerShell parses it for Get-Help. The supported keywords are .SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE, .INPUTS, .OUTPUTS, .NOTES, .LINK. This is the documentation users see when they type Get-Help YourFunction -Detailed.
function Get-Greeting {
<#
.SYNOPSIS
Build a friendly greeting.
.DESCRIPTION
Returns "Hello, <name>!" for the supplied -Name. Supports pipeline
input for batch greetings.
.PARAMETER Name
The recipient's name. Accepts pipeline input.
.EXAMPLE
Get-Greeting -Name 'Alice Dev'
Hello, Alice Dev!
.EXAMPLE
'Alice','Bob' | Get-Greeting
Hello, Alice!
Hello, Bob!
.OUTPUTS
System.String
.NOTES
Author: Alice Dev <alice@example.com>
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Name
)
process { "Hello, $Name!" }
}
Get-Help Get-Greeting -Examples
Output:
NAME
Get-Greeting
SYNOPSIS
Build a friendly greeting.
-------------------------- EXAMPLE 1 --------------------------
PS> Get-Greeting -Name 'Alice Dev'
Hello, Alice Dev!
-------------------------- EXAMPLE 2 --------------------------
PS> 'Alice','Bob' | Get-Greeting
Hello, Alice!
Hello, Bob!
Strict mode and #requires
Set-StrictMode -Version Latest turns silent typo-class bugs into errors — referencing an undefined variable, calling a non-existent property, accessing an out-of-range array index. Turn it on at the top of every script you intend to ship. #requires directives at the top of a script declare prerequisites (PowerShell version, modules, OS edition, admin rights) — PowerShell refuses to run the script if any aren't met.
#requires -Version 7.2
#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5' }
#requires -RunAsAdministrator
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Typo now errors instead of silently being $null
$port = 8080
$ports # → error: variable not assigned (StrictMode 3.0+)
Output:
InvalidOperation: The variable '$ports' cannot be retrieved because it has not been set.
The clean block — PowerShell 7.3+ resource cleanup
PowerShell 7.3 added a fourth named block, clean, to the begin/process/end lifecycle. It runs unconditionally at the end of the function — including when the pipeline is stopped (Ctrl+C), the function throws, or Select-Object -First causes an early stop. Use it to release file handles, dispose of disposables, and unsubscribe from events without copy-pasting cleanup into both end and a try/finally inside process.
function Read-LinesWithCleanup {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Path
)
begin { $reader = $null }
process {
$reader = [System.IO.StreamReader]::new($Path)
while ($null -ne ($line = $reader.ReadLine())) { $line }
}
clean {
if ($reader) { $reader.Dispose(); Write-Verbose "Disposed reader for $Path" }
}
}
'C:\Users\Alice\logs\app.log' | Read-LinesWithCleanup -Verbose | Select-Object -First 3
Output:
2026-05-24 09:14:02 [INFO] Service started on port 8080
2026-05-24 09:14:02 [WARN] Disk usage at 87%
2026-05-24 09:14:02 [ERROR] Failed to write checkpoint
VERBOSE: Disposed reader for C:\Users\Alice\logs\app.log
clean runs even though Select-Object -First 3 issues an StopUpstreamCommandsException to halt the upstream pipeline — a case end would skip. Available on PowerShell 7.3+; gate with #requires -Version 7.3 if your script must not run on 5.1 or 7.2.
Cancellation with $PSCmdlet.PipelineStopToken (PowerShell 7.5+)
Beginning with PowerShell 7.5, $PSCmdlet exposes a PipelineStopToken property — a CancellationToken tied to the pipeline's stop event. Hand it to any async/blocking .NET API that accepts a cancellation token (HTTP clients, Task.Delay, Stream.ReadAsync) and your function becomes responsive to Ctrl+C without you writing a Stop-Job-style watchdog.
function Invoke-CancellableFetch {
[CmdletBinding()]
param([Parameter(Mandatory)][Uri]$Url)
process {
$client = [System.Net.Http.HttpClient]::new()
try {
# The HTTP call aborts cleanly when the user presses Ctrl+C
$task = $client.GetStringAsync($Url, $PSCmdlet.PipelineStopToken)
$task.GetAwaiter().GetResult()
}
finally { $client.Dispose() }
}
}
Invoke-CancellableFetch -Url 'https://example.com/slow-endpoint'
Output:
<html>... (body) ...</html>
Pressing Ctrl+C during the request now terminates the HTTP call promptly with an OperationCanceledException instead of leaving the function hanging until the upstream timeout. Requires PowerShell 7.5 or later — guard with #requires -Version 7.5.
Common pitfalls
- Forgetting the
processblock on a pipeline param — without it the function only sees the last piped item, not every item. Wrap pipeline-handling code inprocess { ... }. - Comparing to
$nullon the right — write$null -eq $X, not$X -eq $null. The latter short-circuits when$Xis itself a collection and produces surprising results. - Using
returnfrom aprocessblock to abort the pipeline —returnonly exits the current item; the next item still runs. Usethrowor$PSCmdlet.ThrowTerminatingError(...)to stop the whole pipeline. - Forgetting to type a
[switch]parameter —[bool]$Forcerequires-Force $true;[switch]$Forceaccepts a bare-Force. Always use[switch]for opt-in flags. - Putting validation logic in the function body — it's redundant and unhelpful. Use
[Validate*]attributes so callers get errors at parameter binding, not deep inside the function. - Calling another function inside a wrapper without splatting
@PSBoundParameters— leads to drift when you add a parameter to the wrapper and forget to forward it. Always splat unless you have a reason not to. Set-StrictMode -Version Latestonly at the prompt — strict mode is scope-local. Add it inside every script, not just your interactive session.- Using
Write-Hostfor data output —Write-Hostwrites to the console and is invisible to the pipeline. Use the function's return value (orWrite-Output) for data; reserveWrite-Hostfor human-facing status messages.
Real-world recipes
A small advanced function with pipeline input, validation, and common parameters
A complete example combining everything: pipeline-bound parameter aliases, multiple validators, SupportsShouldProcess, Write-Verbose, and a structured object as output.
function Invoke-AssetMove {
<#
.SYNOPSIS
Move an asset file from a staging path to its production location.
.DESCRIPTION
Accepts pipeline input from Get-ChildItem. Validates that the source
exists, the destination directory exists, and the file isn't larger
than the configured ceiling. Supports -WhatIf and -Confirm.
.EXAMPLE
Get-ChildItem C:\Users\Alice\stage\*.png |
Invoke-AssetMove -DestinationRoot \\myserver\share\assets
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName','Path')]
[ValidateScript({ Test-Path $_ -PathType Leaf })]
[string]$SourcePath,
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Container })]
[string]$DestinationRoot,
[Parameter()]
[ValidateRange(1, 1024)]
[int]$MaxSizeMB = 100,
[Parameter()]
[ValidateSet('keep','overwrite','rename')]
[string]$OnConflict = 'rename',
[switch]$DryRun
)
begin {
$moved = 0; $skipped = 0
Write-Verbose "DestinationRoot = $DestinationRoot, MaxSizeMB = $MaxSizeMB"
}
process {
$file = Get-Item $SourcePath
$sizeMB = [Math]::Round($file.Length / 1MB, 2)
if ($sizeMB -gt $MaxSizeMB) {
Write-Warning "Skipping $($file.Name) — $sizeMB MB exceeds $MaxSizeMB MB"
$skipped++
return
}
$dest = Join-Path $DestinationRoot $file.Name
if (Test-Path $dest) {
switch ($OnConflict) {
'keep' { Write-Verbose "Keeping existing $dest"; $skipped++; return }
'rename' { $dest = Join-Path $DestinationRoot ("{0}-{1:yyyyMMdd-HHmmss}{2}" -f $file.BaseName, (Get-Date), $file.Extension) }
'overwrite' { }
}
}
if ($DryRun) {
[pscustomobject]@{ Source = $file.FullName; Dest = $dest; Action = 'DryRun' }
}
elseif ($PSCmdlet.ShouldProcess($file.FullName, "Move to $dest")) {
Move-Item -LiteralPath $file.FullName -Destination $dest -ErrorAction Stop
$moved++
[pscustomobject]@{ Source = $file.FullName; Dest = $dest; Action = 'Moved' }
}
}
end {
Write-Verbose "Moved: $moved, Skipped: $skipped"
}
}
# Demo run
Get-ChildItem C:\Users\Alice\stage\*.png |
Invoke-AssetMove -DestinationRoot 'C:\Users\Alice\assets' -OnConflict rename -DryRun -Verbose
Output:
VERBOSE: DestinationRoot = C:\Users\Alice\assets, MaxSizeMB = 100
Source Dest Action
------ ---- ------
C:\Users\Alice\stage\holiday.png C:\Users\Alice\assets\holiday.png DryRun
C:\Users\Alice\stage\sunset.png C:\Users\Alice\assets\sunset-20260524-091422.png DryRun
C:\Users\Alice\stage\family.png C:\Users\Alice\assets\family.png DryRun
VERBOSE: Moved: 0, Skipped: 0
A retry-with-backoff wrapper
A reusable wrapper that runs any script block, catches errors, and retries with exponential backoff up to a configurable cap.
function Invoke-WithRetry {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [scriptblock]$ScriptBlock,
[ValidateRange(1, 20)] [int]$MaxAttempts = 5,
[ValidateRange(0.1, 60)] [double]$InitialDelaySec = 1.0,
[ValidateRange(1.0, 10.0)] [double]$BackoffFactor = 2.0
)
$attempt = 1
$delay = $InitialDelaySec
while ($attempt -le $MaxAttempts) {
try {
Write-Verbose "Attempt $attempt of $MaxAttempts"
return & $ScriptBlock
}
catch {
if ($attempt -eq $MaxAttempts) { throw }
Write-Warning "Attempt $attempt failed: $($_.Exception.Message). Sleeping ${delay}s."
Start-Sleep -Seconds $delay
$delay *= $BackoffFactor
$attempt++
}
}
}
# Demo: an API call that's flaky on the first try
$tries = 0
Invoke-WithRetry -InitialDelaySec 0.5 -BackoffFactor 2 -Verbose -ScriptBlock {
$script:tries++
if ($script:tries -lt 3) { throw "temporary network error" }
"success on attempt $script:tries"
}
Output:
VERBOSE: Attempt 1 of 5
WARNING: Attempt 1 failed: temporary network error. Sleeping 0.5s.
VERBOSE: Attempt 2 of 5
WARNING: Attempt 2 failed: temporary network error. Sleeping 1s.
VERBOSE: Attempt 3 of 5
success on attempt 3
A parameter-set example — by id or by name, mutually exclusive
Two ways to call the same function, neither one optional but never both. PowerShell picks the set automatically based on which params are bound.
function Stop-Job2 {
[CmdletBinding(DefaultParameterSetName = 'ById')]
param(
[Parameter(Mandatory, ParameterSetName = 'ById', Position = 0)]
[int]$Id,
[Parameter(Mandatory, ParameterSetName = 'ByName', Position = 0)]
[string]$Name,
[Parameter()]
[switch]$Force
)
$target = if ($PSCmdlet.ParameterSetName -eq 'ById') { "id=$Id" } else { "name=$Name" }
"Would stop $target (Force=$Force)"
}
Stop-Job2 -Id 42 -Force
Stop-Job2 -Name 'nightly'
Stop-Job2 -Id 42 -Name 'nightly' # error: ambiguous
Output:
Would stop id=42 (Force=True)
Would stop name=nightly (Force=False)
Stop-Job2: Parameter set cannot be resolved using the specified named parameters. One or more parameters issued cannot be used together or an insufficient number of parameters were provided.
Reusable logging helper with -Verbose / -Debug routing
A tiny logger that writes to a file and honours the caller's -Verbose and -Debug flags via the standard PowerShell streams — no parallel logging framework required.
function Write-AppLog {
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[ValidateSet('INFO','WARN','ERROR','DEBUG')]
[string]$Level,
[Parameter(Mandatory, Position = 1, ValueFromPipeline)]
[string]$Message,
[Parameter()]
[string]$LogFile = 'C:\Users\Alice\logs\app.log'
)
process {
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Add-Content -Path $LogFile -Value $line
switch ($Level) {
'INFO' { Write-Information $Message -InformationAction Continue }
'WARN' { Write-Warning $Message }
'ERROR' { Write-Error $Message }
'DEBUG' { Write-Debug $Message }
}
}
}
Write-AppLog INFO 'Service started on port 8080'
Write-AppLog WARN 'Disk usage at 87%'
Write-AppLog ERROR 'Failed to write checkpoint'
Write-AppLog DEBUG 'Resolved cache key alpha/v3' -Debug
Output:
Service started on port 8080
WARNING: Disk usage at 87%
Write-AppLog: Failed to write checkpoint
DEBUG: Resolved cache key alpha/v3
Get-Content 'C:\Users\Alice\logs\app.log' -Tail 4 shows the matching log lines persisted to disk:
Output:
2026-05-24 09:14:02 [INFO] Service started on port 8080
2026-05-24 09:14:02 [WARN] Disk usage at 87%
2026-05-24 09:14:02 [ERROR] Failed to write checkpoint
2026-05-24 09:14:02 [DEBUG] Resolved cache key alpha/v3
Sources
- about_Functions_Advanced — PowerShell | Microsoft Learn — current reference for advanced functions
- about_Functions_CmdletBindingAttribute — PowerShell | Microsoft Learn — full CmdletBinding property list
- about_Functions_Advanced_Methods — PowerShell | Microsoft Learn — begin/process/end/clean block semantics
- What's New in PowerShell 7.5 | Microsoft Learn —
$PSCmdlet.PipelineStopTokenintroduction