PowerShell ยท Functions

Writing PowerShell Advanced Functions

A practical guide to turning a plain PowerShell function into an advanced function that validates its input, behaves correctly in the pipeline, and holds up in production scripts.

Quick idea: An advanced function is a script function that has been given the CmdletBinding attribute, so it validates parameters, supports common parameters like -Verbose and -WhatIf, and behaves like a real cmdlet in the pipeline.
CmdletBinding

The attribute that turns a plain function into something that behaves like a compiled cmdlet.

Validation Attributes

Rules attached to a parameter that reject bad input before the function body ever runs.

begin / process / end

The blocks that let a function handle one object or ten thousand objects from the pipeline the same way.

What Is an Advanced Function?

An advanced function is a PowerShell function written entirely in script that behaves like a compiled cmdlet. It accepts the common parameters every built-in cmdlet has, such as -Verbose, -ErrorAction, and -WhatIf, and PowerShell binds its parameters using the same strict rules used for compiled cmdlets, including rejecting unknown parameters.

Think of an advanced function as a homemade cmdlet. From the outside, whoever calls Get-Something cannot tell whether it is C# code shipped in a compiled module or a PowerShell script function, because CmdletBinding makes both participate in the same contract: predictable parameter binding, pipeline support, and self-documenting help.

A function becomes advanced once it has either the CmdletBinding attribute or a Parameter attribute on at least one of its parameters, or both. In practice almost every advanced function uses CmdletBinding, and most parameters also carry a Parameter attribute so they can declare things like Mandatory or ValueFromPipeline.

Key rule: Once CmdletBinding is added, PowerShell also adds the common parameters automatically, and you cannot declare a parameter with the same name as one of them.

Why Write One Instead of a Basic Function?

A basic function with a bare param() block works fine for a one-off script run interactively. It stops being enough the moment the function is going to be reused, called from another script, or run unattended against more than one target.

Fails Fast

Validation attributes reject bad input before the function body runs, instead of failing halfway through a loop.

Pipeline Aware

Correct use of begin, process, and end means the function works the same whether it is called once or piped a thousand objects.

Self-Documenting

Comment-based help means Get-Help -Full works without a separate document explaining what the function does.

None of this is decoration. A function that silently accepts $null, an empty string, or a mistyped computer name will fail somewhere downstream where the error message no longer points back to the actual mistake. Validation attributes move that failure to the one place where it is cheap to diagnose: the moment the parameter is bound.

Why Production Code Cares About This

Scripts that are only ever run once by the person who wrote them can get away with sloppy parameter handling. Scripts that get reused by other admins, scheduled as jobs, or called from other functions cannot.

SupportsShouldProcess is the clearest example. Any function that deletes files, restarts a service, or disables an account should support -WhatIf so it can be tested without making the change, and -Confirm so a destructive action prompts before it runs. Without it, the only way to know what a script will do is to read the code or run it for real.

Correct begin/process/end use is the other half of reliability. A function that accepts pipeline input but has no process block will silently run once instead of once per object, which is one of the most common bugs in hand-written PowerShell tools. Getting the block structure right the first time means the function keeps working correctly whether it is called with one computer name or piped a CSV of five hundred.

Production note: If a function’s parameter is set to accept pipeline input and the function has no process block, record-by-record processing fails silently. The function still runs, but only once, regardless of how many objects were piped in.

Anatomy of an Advanced Function

The example below deletes log files older than a given number of days. It touches the filesystem, so it is a good candidate to walk through CmdletBinding, parameter validation, and begin/process/end together.

function Remove-OldLogFile {
    <#
    .SYNOPSIS
        Deletes log files older than a specified number of days.
    .DESCRIPTION
        Removes files matching a given extension from one or more folders
        when their LastWriteTime is older than the specified threshold.
        Supports -WhatIf and -Confirm because it deletes data.
    .PARAMETER Path
        One or more folder paths to clean up. Accepts pipeline input.
    .PARAMETER OlderThanDays
        Minimum age, in days, before a file is eligible for deletion.
    .PARAMETER Extension
        The file extension to target, without the dot. Defaults to 'log'.
    .EXAMPLE
        PS> Remove-OldLogFile -Path C:\Logs\IIS -OlderThanDays 30
    .EXAMPLE
        PS> 'C:\Logs\App', 'C:\Logs\Web' | Remove-OldLogFile -OlderThanDays 90 -WhatIf
    #>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
        [string[]]$Path,

        [Parameter()]
        [ValidateRange(1, 3650)]
        [int]$OlderThanDays = 30,

        [Parameter()]
        [ValidatePattern('^[A-Za-z0-9]{1,10}$')]
        [string]$Extension = 'log'
    )

    begin {
        Write-Verbose "Deleting .$Extension files older than $OlderThanDays day(s)"
        $cutoff = (Get-Date).AddDays(-$OlderThanDays)
        $removedCount = 0
    }

    process {
        foreach ($folder in $Path) {
            $files = Get-ChildItem -LiteralPath $folder -Filter "*.$Extension" -File -ErrorAction Stop |
                Where-Object { $_.LastWriteTime -lt $cutoff }

            foreach ($file in $files) {
                if ($PSCmdlet.ShouldProcess($file.FullName, 'Remove log file')) {
                    Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop
                    $removedCount++
                }
            }
        }
    }

    end {
        Write-Verbose "Removed $removedCount file(s) older than $cutoff"
    }
}
Part What It Does
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] Makes this an advanced function, adds the common parameters, and adds -WhatIf and -Confirm because the function changes the filesystem.
ValueFromPipeline, ValueFromPipelineByPropertyName Lets Path accept either bare strings piped in directly, or an object with a Path property, such as the output of Import-Csv.
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] Rejects any folder that does not exist before the function body ever runs, instead of failing inside the loop.
[ValidateRange(1, 3650)] Keeps OlderThanDays to a sane window, roughly one day to ten years, and rejects zero or negative values.
[ValidatePattern('^[A-Za-z0-9]{1,10}$')] Keeps Extension to a short alphanumeric string, so it cannot be used to smuggle a wildcard or path separator into the filter.
begin Runs once, before any pipeline input arrives. Used here to compute the cutoff date a single time instead of recalculating it per file.
process Runs once for every folder that reaches the function, whether that is one folder passed as an argument or a hundred piped in.
$PSCmdlet.ShouldProcess(...) Checks whether -WhatIf or -Confirm were used, and only deletes the file when the caller has not asked for a dry run.
end Runs once, after all pipeline input has been processed. Used here to report a final summary.
Important: ShouldProcess can only be called from inside the process block, and the function’s CmdletBinding attribute must declare SupportsShouldProcess or the call has no effect.

Pipeline Input and Validation in Practice

ValueFromPipeline and ValueFromPipelineByPropertyName solve two different problems, and mixing them up is one of the most common reasons pipeline input silently fails to bind.

# ValueFromPipeline accepts the whole piped object, so bare strings bind directly
function Get-DiskUsageReport {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$ComputerName
    )

    process {
        foreach ($computer in $ComputerName) {
            Write-Output "Checking disk usage on $computer"
        }
    }
}

# Works: bare strings bind to $ComputerName because the type matches
'SRV01', 'SRV02' | Get-DiskUsageReport

# ValueFromPipelineByPropertyName only binds when the piped object has a
# property with the exact same name as the parameter (or one of its aliases)
function Get-DiskUsageReportByProperty {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('CN')]
        [string[]]$ComputerName
    )

    process {
        foreach ($computer in $ComputerName) {
            Write-Output "Checking disk usage on $computer"
        }
    }
}

# servers.csv has a header row: ComputerName
# Works: Import-Csv produces objects with a ComputerName property
Import-Csv .\servers.csv | Get-DiskUsageReportByProperty
Practical rule: Declare both ValueFromPipeline and ValueFromPipelineByPropertyName on the same parameter when practical, as in the Remove-OldLogFile example. It accepts bare values piped directly and property-matched values piped from a structured object, without needing two separate parameters.

Validation attributes are just as easy to get wrong in the other direction: reaching for ValidateScript when a simpler, built-in attribute already covers the case. The table below lists the ones used most often.

Validation Attributes Cheat Sheet

Attribute What It Checks Example
ValidateNotNullOrEmptyValue cannot be $null, an empty string, or an empty array.[ValidateNotNullOrEmpty()]
ValidateNotNullOrWhiteSpaceSame as above, and also rejects a string that is only whitespace.[ValidateNotNullOrWhiteSpace()]
ValidateSetValue must match one entry in a fixed list, and enables tab completion.[ValidateSet('Low','Medium','High')]
ValidateRangeNumeric value must fall within a min/max range, or match a ValidateRangeKind such as Positive.[ValidateRange(0,10)]
ValidateLengthString length must fall within a min/max character count.[ValidateLength(1,10)]
ValidateCountNumber of values passed to an array parameter must fall within a min/max range.[ValidateCount(1,5)]
ValidatePatternValue must match a regular expression.[ValidatePattern('^[0-9]{4}$')]
ValidateScriptValue must pass a custom script block; the value is available inside it as $_.[ValidateScript({ Test-Path $_ })]
ValidateDriveA path parameter’s drive must be one of an allowed list of PSDrives.[ValidateDrive('C','D')]
AllowNull / AllowEmptyString / AllowEmptyCollectionRelax the normal rule that a mandatory parameter cannot be $null or empty.[AllowNull()]
Key difference: ValidateScript cannot validate a $null value at all; PowerShell raises an error before the script block ever runs. If a parameter genuinely needs to accept $null, combine ValidateScript with AllowNull, or move the check inside the function body instead.

Troubleshooting Cheat Sheet

Symptom Likely Cause Fix
Function only runs once despite piped input Parameter accepts pipeline input but the function has no process block. Add a process block; without one, per-object processing silently fails and the function body runs a single time.
ValueFromPipelineByPropertyName parameter never receives a value The piped object’s property name does not exactly match the parameter name or one of its aliases. Check the incoming object with Get-Member and either rename the property, or add an [Alias()] that matches it.
Function ignores -WhatIf SupportsShouldProcess is declared, but the code never calls $PSCmdlet.ShouldProcess. Wrap the mutating action in if ($PSCmdlet.ShouldProcess($target, $action)) { ... }.
Cannot validate argument on parameter for a value that looks correct ValidateScript or another validation attribute received $null, or the regex/range does not match what was intended. Test the validation logic in isolation, and add AllowNull only if $null is genuinely a valid input.
Unknown parameter or positional binding errors appear only after adding CmdletBinding CmdletBinding enforces strict parameter binding; unrecognized parameters and unmatched positional arguments now fail instead of falling into $args. Match parameter names and Position values exactly, and remove any reliance on loose, unbound arguments.
Get-Help -Full shows no description Comment-based help block is not directly adjacent to the function keyword, or has more than one blank line separating them. Place the help block as the first lines inside the function body, at the end of the function body, or immediately before the function keyword.

Final Thoughts

None of this turns a script into something more complicated than it needs to be. A ten-line function with CmdletBinding, one or two validation attributes, and a correct process block is still a short script. What changes is where it fails: at the parameter, with a clear message, instead of three loops deep with a stack trace that no longer points at the real mistake.

That difference is what separates a function you use once and throw away from one that ends up in a shared module other people rely on. Once a function accepts pipeline input, supports -WhatIf, and validates its own parameters, it behaves exactly like the built-in cmdlets it sits next to.

Key takeaway: Add [CmdletBinding()], validate every parameter that can realistically receive bad input, and never accept pipeline input without a process block. That combination is what makes a function safe to hand to someone else.
Next in this series

Next, we can cover error handling in PowerShell properly: how try/catch/finally interacts with $ErrorActionPreference, and why a validated parameter is still not a substitute for handling the errors a function can raise once it starts running.