This guide assumes you already have Powershell 5.1 or Powershell 7+ installed and are comfortable writing basic scripts.
The One Line That Changes Everything
There are two kinds of PowerShell functions in the wild. The first is a glorified script block someone wrapped in function name { ... }, with positional parameters, no validation, no pipeline support, no -WhatIf, and a name that doesn't survive a tab-completion test. The second is structurally indistinguishable from Get-Process or Stop-Service, the cmdlets you reach for every day.
The difference is one attribute. [CmdletBinding()] over the param() block (or [Parameter()] on any parameter) flips the function from "ad-hoc" to "advanced". Once it's there, the runtime gives you, free, things you would otherwise spend two days writing yourself:
-Verbose,-Debug,-ErrorAction,-ErrorVariable,-WarningActionwired in automatically.-WhatIfand-Confirmas soon as you opt in viaSupportsShouldProcess.- Pipeline input, by parameter or by property name, with no manual
foreach ($x in $input)plumbing. - Parameter validation (
[ValidateRange],[ValidateSet],[ValidatePattern],[ValidateScript]) enforced before your code runs. - Tab-completion via
[ArgumentCompleter]for free with one extra attribute. Get-Help <yourFunc> -Fullthat prints exactly like the built-ins.- A predictable contract for callers: any consumer of your function can rely on the cmdlet conventions instead of reading your function's body to figure out what it accepts.
A script that ships to teammates without [CmdletBinding()] is forcing every reader to learn a new convention; a script with it ships in the language the reader already knows.
The Cost: Roughly Eighteen Characters
[CmdletBinding()] over the param() block. That is the cost. Everything below is what you get for those eighteen characters.
The only thing that separates a basic function from an advanced one is the
[CmdletBinding()]attribute or the[Parameter()]attribute on at least one parameter.
The Basic Skeleton
Here is the minimum structure of an advanced function:
function Get-Something
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
begin { }
process { }
end { }
}
The three blocks begin, process, and end are optional, but they are what allow your function to participate cleanly in the pipeline.
| Block | When it runs |
|---|---|
begin |
Once, before any pipeline input is received |
process |
Once per pipeline item |
end |
Once, after all pipeline input is processed |
Parameter Attributes
Mandatory and Positional
[Parameter(Mandatory = $true, Position = 0)]
[string]$Name
If
Mandatoryis set and the caller omits the value, Powershell will prompt for it interactively.
Pipeline Input
To accept values from the pipeline, use ValueFromPipeline or ValueFromPipelineByPropertyName:
[Parameter(ValueFromPipeline = $true)]
[string]$ComputerName
Validation
Powershell ships with a long list of validation attributes that run before your function body executes:
[ValidateNotNullOrEmpty()]
[ValidateSet('Dev','Test','Prod')]
[ValidateRange(1, 100)]
[ValidatePattern('^[A-Z]{2}\d{4}$')]
[ValidateScript({ Test-Path $_ })]
Prefer validation attributes over manual
ifchecks at the top of the function. They produce consistent error messages and integrate with tab completion.
A Complete Example
Below is a function that pings one or more computers and returns a structured object for each one.
function Test-Host
{
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter(
Mandatory = $true,
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Position = 0
)]
[ValidateNotNullOrEmpty()]
[Alias('CN','Computer')]
[string[]]$ComputerName,
[Parameter()]
[ValidateRange(1, 10)]
[int]$Count = 2
)
begin
{
Write-Verbose "Starting Test-Host with Count = $Count"
}
process
{
foreach ($name in $ComputerName)
{
Write-Verbose "Pinging $name"
$online = Test-Connection -ComputerName $name -Count $Count -Quiet -ErrorAction SilentlyContinue
[PSCustomObject]@{
ComputerName = $name
Online = $online
CheckedAt = Get-Date
}
}
}
end
{
Write-Verbose "Test-Host finished"
}
}
You can now call it in any of the following ways:
Test-Host -ComputerName 'server01'
'server01','server02' | Test-Host -Verbose
Get-Content .\hosts.txt | Test-Host -Count 4
Supporting -WhatIf and -Confirm
If your function changes state, opt into ShouldProcess so callers get -WhatIf and -Confirm for free:
function Remove-OldLog
{
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[System.IO.FileInfo]$File
)
process
{
if ($PSCmdlet.ShouldProcess($File.FullName, 'Delete log file'))
{
Remove-Item -LiteralPath $File.FullName -Force
}
}
}
ConfirmImpactcontrols when Powershell auto-prompts the user.Highwill prompt by default unless$ConfirmPreferenceis changed.
Comment-Based Help
Place a comment block immediately above the param() block (or above the function) so Get-Help returns useful information:
function Test-Host
{
<#
.SYNOPSIS
Pings one or more hosts and returns a structured result.
.DESCRIPTION
Wraps Test-Connection and returns a PSCustomObject per host so the
output can be filtered, sorted, or exported easily.
.PARAMETER ComputerName
One or more host names or IP addresses to test.
.PARAMETER Count
Number of echo requests to send. Defaults to 2.
.EXAMPLE
Test-Host -ComputerName 'server01','server02'
.EXAMPLE
Get-Content .\hosts.txt | Test-Host -Count 4 -Verbose
#>
[CmdletBinding()]
param( ... )
}
Now Get-Help Test-Host -Full works just like it does for built-in cmdlets.
What to Do Tomorrow Morning
Pick the single most-edited PowerShell script on your team's automation host, the one that gets touched at least once a sprint. Most likely it does not start with [CmdletBinding()]. Today's exercise:
- Add
[CmdletBinding()]over itsparam()block. - Replace the most-used parameter with
[Parameter(Mandatory)]plus the right[Validate*]attribute. - Add a
<# .SYNOPSIS / .DESCRIPTION / .EXAMPLE #>block above it. - Run it the next time someone needs to call it. Watch how much less they have to ask you about.
That's the whole upgrade. Once one script in the toolbox feels like a real cmdlet, every other script in the same folder will feel weird until you do the same to it.
Where to Go Next
Two natural follow-ups in this series:
- Splatting is the calling convention you adopt the moment your advanced function has more than three parameters. Pairs directly with this post.
- Get-Test-Set is the discipline pattern for advanced functions when you start writing them in groups (read state, classify drift, mutate idempotently). Worth reading once you've shipped a few advanced functions and want a structure to organise them in.
The single takeaway worth remembering: [CmdletBinding()] is not optional. It is the line that distinguishes "a function I wrote" from "something my team can use."


