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, -WarningAction wired in automatically.
  • -WhatIf and -Confirm as soon as you opt in via SupportsShouldProcess.
  • 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> -Full that 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 Mandatory is 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 if checks 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
        }
    }
}

ConfirmImpact controls when Powershell auto-prompts the user. High will prompt by default unless $ConfirmPreference is 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:

  1. Add [CmdletBinding()] over its param() block.
  2. Replace the most-used parameter with [Parameter(Mandatory)] plus the right [Validate*] attribute.
  3. Add a <# .SYNOPSIS / .DESCRIPTION / .EXAMPLE #> block above it.
  4. 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."