Most of the PowerShell functions in the wild are simple functions β a name, a few parameters, a body. They work, but they do not feel like real cmdlets. They do not support -Verbose, they do not support -WhatIf, they accept any garbage as a parameter, and they cannot be used in a pipeline. Turning a simple function into an advanced function takes one attribute and a parameter block β but the difference in usability is enormous.
This guide is the practical, end-to-end version: CmdletBinding, parameter validation attributes, pipeline input via ValueFromPipeline and ValueFromPipelineByPropertyName, the begin/process/end blocks, ShouldProcess for safe destructive operations, and the patterns you will use in every module you write. Free PDF cheat sheet at the bottom.
Table of Contents
Simple vs advanced functions
Compare:
# Simple function
function Get-DiskFree {
param($ComputerName)
Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $ComputerName |
Select-Object DeviceID, FreeSpace
}
# Advanced function
function Get-DiskFree {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('CN', 'Name')]
[ValidateNotNullOrEmpty()]
[string[]]$ComputerName
)
process {
foreach ($c in $ComputerName) {
Write-Verbose "Querying $c"
Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $c |
Select-Object @{n='Server';e={$c}}, DeviceID, FreeSpace
}
}
}
The advanced version supports -Verbose, takes pipeline input from a list of names or from objects with a ComputerName / CN / Name property, validates the parameter, and emits one record per disk on every server. The investment is two lines of attributes.
CmdletBinding β the magic word
Adding [CmdletBinding()] to the top of param(...) turns a simple function into an advanced one. That single attribute opts you in to all of the common parameters: -Verbose, -Debug, -ErrorAction, -ErrorVariable, -WarningAction, -OutVariable, -OutBuffer, -PipelineVariable. You do not have to write any code for any of them β they just work.
Common CmdletBinding options:
[CmdletBinding(
SupportsShouldProcess = $true, # enables -WhatIf and -Confirm
ConfirmImpact = 'High', # prompt level (Low / Medium / High)
DefaultParameterSetName = 'ByName',
PositionalBinding = $false,
HelpUri = 'https://dargslan.com/blog/powershell-advanced-functions-cmdletbinding-2026'
)]
The param block
Each parameter is a [Type]$Name declaration with one or more attributes:
param(
[Parameter(Mandatory, Position = 0, ValueFromPipeline)]
[string[]]$Name,
[Parameter()]
[ValidateRange(1, 65535)]
[int]$Port = 443,
[switch]$Force
)
Notes:
- Always type-annotate parameters.
[string[]]means an array of strings; PowerShell will helpfully convert a single string into a one-element array. - Default values come right after the parameter name, after all attributes.
[switch]is the right type for boolean flags. Test them withif ($Force), never with$Force -eq $true.
Parameter validation attributes
Validation attributes catch bad input before your function body runs. Use them aggressively β a function that fails fast with a clear message is much friendlier than one that crashes 50 lines in.
param(
[ValidateNotNull()]
[object]$Config,
[ValidateNotNullOrEmpty()]
[string]$Path,
[ValidateLength(1, 64)]
[string]$Name,
[ValidateRange(0, 100)]
[int]$Percent,
[ValidatePattern('^[a-z][a-z0-9-]*$')]
[string]$Slug,
[ValidateSet('Dev', 'Test', 'Prod')]
[string]$Environment,
[ValidateScript({ Test-Path $_ })]
[string]$LogFile,
[ValidateCount(1, 10)]
[string[]]$Tags
)
Each one produces a clear error message naming the offending parameter. Use them at the parameter declaration, not inside an if in the body.
Pipeline input
Two ways for a parameter to accept pipeline input:
param(
# By value: the entire object becomes $Name
[Parameter(ValueFromPipeline)]
[string[]]$Name,
# By property name: extracts the .ComputerName property of each piped object
[Parameter(ValueFromPipelineByPropertyName)]
[string]$ComputerName
)
Inside the function, pipeline input is processed one item at a time inside the process block (next section).
begin / process / end blocks
An advanced function with pipeline input has three optional blocks:
function Get-FileSummary {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[string[]]$Path
)
begin {
$totalSize = 0
$count = 0
}
process {
foreach ($p in $Path) {
$f = Get-Item $p -ErrorAction SilentlyContinue
if ($f) {
$totalSize += $f.Length
$count++
}
}
}
end {
[PSCustomObject]@{
Files = $count
TotalSize = $totalSize
Average = if ($count) { [int]($totalSize / $count) } else { 0 }
}
}
}
# Use it
Get-ChildItem C:\Logs -Recurse | Get-FileSummary
begin runs once at the start (initialize counters), process runs once per piped object (the work), end runs once at the end (finalize and emit). This is the same lifecycle as a real cmdlet.
ShouldProcess and -WhatIf
For destructive operations (delete, modify, restart), supporting -WhatIf and -Confirm is non-negotiable. Add SupportsShouldProcess to CmdletBinding and gate the dangerous code with $PSCmdlet.ShouldProcess():
function Remove-OldLog {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)]
[string]$Path,
[int]$DaysOld = 30
)
Get-ChildItem $Path -File | Where-Object {
$_.LastWriteTime -lt (Get-Date).AddDays(-$DaysOld)
} | ForEach-Object {
if ($PSCmdlet.ShouldProcess($_.FullName, "Delete file older than $DaysOld days")) {
Remove-Item $_.FullName
}
}
}
# Now it just works:
Remove-OldLog -Path C:\Logs -WhatIf
Remove-OldLog -Path C:\Logs -Confirm
Parameter sets
Sometimes one cmdlet has two or three different ways to be called β you have an ID or a name or an object. Parameter sets express this:
function Get-User {
[CmdletBinding(DefaultParameterSetName = 'ByName')]
param(
[Parameter(Mandatory, ParameterSetName = 'ByName')]
[string]$Name,
[Parameter(Mandatory, ParameterSetName = 'ById')]
[int]$Id,
[Parameter(Mandatory, ParameterSetName = 'ByObject', ValueFromPipeline)]
[pscustomobject]$User
)
process {
switch ($PSCmdlet.ParameterSetName) {
'ByName' { ... }
'ById' { ... }
'ByObject' { ... }
}
}
}
Comment-based help
Drop a comment block above the function and PowerShell parses it into help that Get-Help can render:
<#
.SYNOPSIS
Returns disk-free information for one or more computers.
.DESCRIPTION
Queries Win32_LogicalDisk via CIM. Accepts pipeline input by value
or by ComputerName property.
.PARAMETER ComputerName
One or more computer names to query.
.EXAMPLE
Get-DiskFree -ComputerName web01,web02
.EXAMPLE
Get-Content servers.txt | Get-DiskFree
.LINK
https://dargslan.com/blog/powershell-advanced-functions-cmdletbinding-2026
#>
function Get-DiskFree { ... }
Get-Help Get-DiskFree -Full now produces a real help page.
Putting it all together
function Stop-StaleProcess {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High',
DefaultParameterSetName = 'ByName')]
param(
[Parameter(Mandatory, ValueFromPipeline,
ValueFromPipelineByPropertyName,
ParameterSetName = 'ByName')]
[Alias('ProcessName')]
[ValidateNotNullOrEmpty()]
[string[]]$Name,
[Parameter(Mandatory, ParameterSetName = 'ById')]
[int[]]$Id,
[ValidateRange(1, 1440)]
[int]$IdleMinutes = 60,
[switch]$Force
)
begin { $stopped = 0 }
process {
$procs = if ($PSCmdlet.ParameterSetName -eq 'ByName') {
Get-Process -Name $Name -ErrorAction SilentlyContinue
} else {
Get-Process -Id $Id -ErrorAction SilentlyContinue
}
foreach ($p in $procs) {
$idle = ((Get-Date) - $p.StartTime).TotalMinutes
if ($idle -ge $IdleMinutes) {
$msg = "Stop $($p.Name) (PID $($p.Id), idle $([int]$idle) min)"
if ($PSCmdlet.ShouldProcess($p.MachineName, $msg)) {
Stop-Process -Id $p.Id -Force:$Force
$stopped++
}
}
}
}
end { Write-Verbose "Stopped $stopped processes" }
}
Read it slowly. Every PowerShell idiom from this article appears exactly once: CmdletBinding with ShouldProcess, two parameter sets, validation, pipeline input, aliases, the three blocks, ShouldProcess gating, and verbose output.
Cheat sheet
All the attributes, syntax, and the full example on a single PDF: PowerShell Advanced Functions Cheat Sheet.
FAQ
Do I always need CmdletBinding?
For one-off scripts, no. For anything that lives in a module or is shared with anyone else, yes. The cost is one line; the benefit is consistent UX with the rest of PowerShell.
What is the difference between $args and a param block?
$args is the unbound positional argument array β useful for tiny throwaway functions. The moment you want validation, types, or named parameters, you need a param block.
Can I have both pipeline input and a default value?
Yes. The default value applies when the parameter is not bound from the pipeline or from a positional/named argument.
Can I write my own validation attribute?
Yes β derive from System.Management.Automation.ValidateArgumentsAttribute. Useful for organisation-specific patterns (allowed environments, internal hostname formats).
Why is my pipeline input only running once?
You probably forgot the process block. Without it, all pipeline input is dumped into the parameter as a single bound value once at the end, not once per item.
Should every parameter be Mandatory?
No β only the ones the function genuinely cannot run without. Optional parameters with sensible defaults make a function more pleasant to use.
How do I make a function callable both as a script and as a module?
Save it as a .ps1 with function Verb-Noun {...} at the top, then Verb-Noun @PSBoundParameters at the bottom. Run as a script for one-shot use, or dot-source / import as a module to expose the function long-term.