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#scriptingupdated 05-26-2026

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.

powershell
# 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:

text
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 =.

powershell
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:

text
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.

PropertyWhat it does
MandatoryRefuse to run without this param (prompts interactively if missing).
PositionAllows positional passing (Get-Snapshot 'daily' instead of -Name 'daily').
ValueFromPipelineBind the whole pipeline object to this param.
ValueFromPipelineByPropertyNameBind a same-named property of the pipeline object.
ParameterSetNameGroup params into mutually exclusive sets (different signatures).
HelpMessageText shown when a mandatory param prompt fires.
DontShowHide from tab completion and IntelliSense.
powershell
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:

text
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.

powershell
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:

text
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.

powershell
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:

text
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".

powershell
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:

text
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.

powershell
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:

text
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.

powershell
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:

text
→ 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.

powershell
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:

text
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.

powershell
function Get-Lower {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory, ValueFromPipeline)]
    [string]$Text
  )
  process { $Text.ToLowerInvariant() }
}

'Alice', 'Bob', 'CARLA' | Get-Lower

Output:

text
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.

powershell
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:

text
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.

powershell
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:

text
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.

powershell
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:

text
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.

powershell
$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.

powershell
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:

text
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.

powershell
$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.

powershell
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:

text
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.

powershell
# 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:

text
Hello, Alice Dev!
Hello, Bob!
powershell
# 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:

text
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 whenUse a .psm1 when
Running a one-off task (provision.ps1)Distributing reusable functions
You want everything to run on invocationYou want functions exposed without running
Scheduling via Task Scheduler / cronPublishing to the Gallery / NuGet feed
Acting as the entry point for a workflowActing 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.

powershell
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:

text
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.

powershell
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:

text
A
C
powershell
# -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:

text
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).

powershell
# 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:

text
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(...).

powershell
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:

text
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.

powershell
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:

text
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.

powershell
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:

text
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.

powershell
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:

text
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.

powershell
#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:

text
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.

powershell
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:

text
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.

powershell
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:

text
<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

  1. Forgetting the process block on a pipeline param — without it the function only sees the last piped item, not every item. Wrap pipeline-handling code in process { ... }.
  2. Comparing to $null on the right — write $null -eq $X, not $X -eq $null. The latter short-circuits when $X is itself a collection and produces surprising results.
  3. Using return from a process block to abort the pipelinereturn only exits the current item; the next item still runs. Use throw or $PSCmdlet.ThrowTerminatingError(...) to stop the whole pipeline.
  4. Forgetting to type a [switch] parameter[bool]$Force requires -Force $true; [switch]$Force accepts a bare -Force. Always use [switch] for opt-in flags.
  5. 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.
  6. 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.
  7. Set-StrictMode -Version Latest only at the prompt — strict mode is scope-local. Add it inside every script, not just your interactive session.
  8. Using Write-Host for data outputWrite-Host writes to the console and is invisible to the pipeline. Use the function's return value (or Write-Output) for data; reserve Write-Host for 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.

powershell
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:

text
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.

powershell
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:

text
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.

powershell
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:

text
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.

powershell
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:

text
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:

text
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