Functions and Scripts

Summary: in this tutorial, you will learn learn to write powershell functions with parameters, validation, cmdletbinding, and advanced function features.

Functions and Scripts

Functions and scripts are where PowerShell transitions from interactive command-line tool to full programming language. They're how you package logic into reusable components, build tools your team can share, and create maintainable automation that grows with your needs.

This tutorial takes you from simple one-liner functions to production-quality advanced functions with parameter validation, pipeline support, and proper error handling — the same capabilities built into PowerShell's own cmdlets.

Why Functions Matter

Before diving into syntax, understand what functions solve:

Without functions, you repeat the same code patterns throughout scripts. When requirements change, you must update each copy. When bugs appear, you fix them multiple times. Code becomes unmaintainable.

With functions, you write logic once, call it everywhere. Changes happen in one place. Testing is isolated. Code becomes self-documenting when functions have clear names describing their purpose.

Functions are the foundation of:

  • Code reuse: Write once, call many times
  • Abstraction: Hide complex implementation behind simple interfaces
  • Testability: Test individual components in isolation
  • Maintainability: Fix bugs in one location
  • Readability: Get-UserPermissions $user is clearer than 50 lines of inline code

Basic Functions: Starting Simple

A function is a named block of code that you can call repeatedly.

Simplest Possible Function

function Say-Hello {
    "Hello, World!"
}
 
Say-Hello    # Output: Hello, World!
 

This function takes no inputs and produces a single output. The last (only) statement in the function — the string "Hello, World!" — becomes the function's return value.

Functions with Parameters

Real functions need inputs:

function Say-HelloTo {
    param(
        [string]$Name
    )
    "Hello, $Name!"
}
 
Say-HelloTo -Name "Alice"    # Hello, Alice!
Say-HelloTo "Alice"          # Same result (positional)
 

The param() block declares parameters. Each parameter has a type ([string]) and a name ($Name). PowerShell automatically creates variables for each parameter.

You can call functions two ways:

  • Named: -Name "Alice" — explicit and clear
  • Positional: "Alice" — concise but relies on parameter order

For scripts others will use, named parameters improve readability. For personal quick functions, positional is convenient.

Multiple Parameters with Default Values

function Get-Greeting {
    param(
        [string]$Name,
        [string]$TimeOfDay = "day"    # Default value
    )
    "Good $TimeOfDay, $Name!"
}
 
Get-Greeting -Name "Bob"                        # Good day, Bob!
Get-Greeting -Name "Bob" -TimeOfDay "morning"   # Good morning, Bob!
Get-Greeting "Charlie" "evening"                # Good evening, Charlie! (positional)
 

Default values make parameters optional. If the caller doesn't provide -TimeOfDay, it defaults to "day". This lets you design functions with sensible defaults while allowing customization when needed.

Why defaults matter: They simplify common cases. Get-Greeting "Bob" uses the default "day", keeping calls concise. Power users can override: Get-Greeting "Bob" "evening".

Return Values: Understanding Output

PowerShell's approach to return values differs from many languages and is crucial to understand:

function Get-Square {
    param([int]$Number)
    $Number * $Number    # This expression's result is output
}
 
$result = Get-Square -Number 5
$result    # 25
 

There's no explicit return keyword needed. The expression $Number * $Number evaluates to a value, and since that value isn't assigned to anything, it flows to the output stream. When you call the function and assign it ($result = Get-Square -Number 5), you capture that output.

Critical concept: Everything in a function that produces output goes to the return value:

function Get-Info {
    "Starting..."        # ← This is output!
    $data = Get-Date
    "Processing..."      # ← This too!
    $data                # ← And this!
}
 
$result = Get-Info
$result.Count    # 3 — array of three items!
 

The function returns an array containing three elements: "Starting...", "Processing...", and the DateTime object. This behavior surprises developers from other languages.

PowerShell's #1 gotcha: Unintentional output

Any expression not assigned to a variable becomes output. This means:

  • Strings written with "text" or 'text' → output
  • Variables referenced without assignment ($myVar) → output
  • Command results not assigned (Get-Date) → output

Solution: Use Write-Host for messages that shouldn't be in return value, or use Write-Verbose/Write-Debug for diagnostic messages (covered later).

function Get-InfoCorrect {
    Write-Host "Starting..." -ForegroundColor Cyan    # Display only, not output
    $data = Get-Date
    Write-Verbose "Processing..."    # Verbose stream, not output
    $data    # Only this becomes the return value
}
 
$result = Get-InfoCorrect    # $result contains only the DateTime object
 

The return Keyword

PowerShell does have a return keyword, but it's optional and behaves differently than in C# or Java:

function Test-Even {
    param([int]$Number)
 
    if ($Number % 2 -eq 0) {
        return $true    # Exit function, output $true
    }
    return $false       # Exit function, output $false
}
 

return does two things:

  1. Outputs the value (same as just writing $true)
  2. Exits the function immediately

The second behavior is the main reason to use return — early exit:

function Find-FirstMatch {
    param([string[]]$List, [string]$Pattern)
 
    foreach ($item in $List) {
        if ($item -like $Pattern) {
            return $item    # Exit immediately when found
        }
    }
    return $null    # No match found
}
 

Without return, you'd need to track state or use labels to exit the loop and function.

However, this function is equivalent (last expression is output):

function Test-Even2 {
    param([int]$Number)
    $Number % 2 -eq 0    # Evaluates to $true or $false
}
 

PowerShell convention: Use return for early exits, omit it when the last line naturally produces the result.

Parameter Validation: Built-In Input Checking

Writing validation logic manually (if (!$Name) { throw "Name required" }) is tedious and error-prone. PowerShell provides declarative validation attributes that handle common checks automatically.

The [Parameter()] Attribute

The [Parameter()] attribute controls how parameters behave:

function New-User {
    param(
        [Parameter(Mandatory = $true, HelpMessage = "Enter the user's full name")]
        [string]$Name,
 
        [Parameter(Mandatory = $false)]
        [string]$Email
    )
    # ...
}
 

Mandatory = $true: PowerShell prompts if the parameter isn't provided. The function won't run until the user supplies a value.

HelpMessage: When PowerShell prompts, it displays this hint. Users can type !? at the prompt to see it.

Common Validation Attributes

PowerShell provides a rich set of validation attributes:

function New-Employee {
    param(
        # Must not be null or empty string
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,
 
        # Must be one of these specific values
        [ValidateSet("Developer", "Designer", "Manager", "Admin")]
        [string]$Role = "Developer",
 
        # Must be within numeric range
        [ValidateRange(18, 120)]
        [int]$Age,
 
        # Must match regex pattern (email validation)
        [ValidatePattern('^[\w.+-]+@[\w.-]+\.\w+$')]
        [string]$Email,
 
        # Must satisfy custom condition (path must exist)
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$ResumeFilePath,
 
        # String length constraints
        [ValidateLength(3, 20)]
        [string]$Username,
 
        # Array element count constraints
        [ValidateCount(1, 5)]
        [string[]]$Skills,
 
        # Must not be null (but can be empty)
        [ValidateNotNull()]
        [object]$Metadata = @{}
    )
 
    [PSCustomObject]@{
        Name     = $Name
        Username = $Username
        Role     = $Role
        Age      = $Age
        Email    = $Email
        Skills   = $Skills
    }
}
 

Why validation attributes are powerful:

  1. Tab completion: With [ValidateSet], pressing Tab cycles through valid values:
    New-Employee -Role <Tab>    # Cycles: Developer, Designer, Manager, Admin
  2. Clear error messages: PowerShell generates descriptive errors automatically:
    New-Employee -Name "Alice" -Age 15
    # Error: Cannot validate argument on parameter 'Age'. The 15 argument is less than the minimum allowed range of 18.
  3. No manual validation code: Instead of:
    if ($Age -lt 18 -or $Age -gt 120) {
        throw "Age must be between 18 and 120"
    }
    You write: [ValidateRange(18,120)] — one line, more maintainable.
  4. Validation before function logic: Parameters are validated before your function code runs. If validation fails, your function never executes, preventing invalid state.

Custom Validation with ValidateScript

[ValidateScript()] runs arbitrary code to validate input:

function Copy-ToBackup {
    param(
        [Parameter(Mandatory)]
        [ValidateScript({
            if (Test-Path $_) {
                $true    # Validation passes
            } else {
                throw "File '$_' does not exist"    # Custom error message
            }
        })]
        [string]$SourceFile,
 
        [ValidateScript({
            $parent = Split-Path $_
            if (Test-Path $parent) {
                $true
            } else {
                throw "Parent directory '$parent' does not exist"
            }
        })]
        [string]$DestinationFile
    )
 
    Copy-Item $SourceFile $DestinationFile -Force
    Write-Host "Copied successfully" -ForegroundColor Green
}
 

The script block receives the parameter value in $_. Return $true to pass validation or throw a custom error message to fail.

When to use ValidateScript:

  • File/path existence checks
  • Database existence verification
  • API availability testing
  • Domain-specific validation (credit card numbers, phone formats, etc.)

Advanced Functions with CmdletBinding

Adding [CmdletBinding()] transforms a simple function into an advanced function (also called a cmdlet function). Advanced functions gain the same capabilities as compiled cmdlets written in C#.

Basic Advanced Function

function Get-DiskSpace {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias("CN", "Computer")]
        [string[]]$ComputerName
    )
 
    begin {
        Write-Verbose "Starting disk space check..."
    }
 
    process {
        foreach ($computer in $ComputerName) {
            Write-Verbose "Checking $computer..."
 
            try {
                $disks = Get-CimInstance Win32_LogicalDisk -ComputerName $computer -Filter "DriveType=3"
 
                foreach ($disk in $disks) {
                    [PSCustomObject]@{
                        ComputerName = $computer
                        Drive        = $disk.DeviceID
                        SizeGB       = [math]::Round($disk.Size / 1GB, 2)
                        FreeGB       = [math]::Round($disk.FreeSpace / 1GB, 2)
                        PercentFree  = [math]::Round(($disk.FreeSpace / $disk.Size) * 100, 1)
                    }
                }
            } catch {
                Write-Error "Failed to query $computer: $($_.Exception.Message)"
            }
        }
    }
 
    end {
        Write-Verbose "Disk space check completed"
    }
}
 

This single function demonstrates several advanced features:

  1. CmdletBinding: Enables common parameters like -Verbose, -ErrorAction, etc.
  2. Pipeline support: ValueFromPipeline lets you pipe computer names in
  3. Aliases: CN and Computer work instead of ComputerName
  4. Begin/Process/End blocks: Control execution flow for pipeline processing
  5. Write-Verbose: Provides optional diagnostic output

What CmdletBinding Gives You

[CmdletBinding()] automatically adds common parameters to your function:

  • -Verbose: Enables Write-Verbose output
  • -Debug: Enables Write-Debug output and debugging prompts
  • -ErrorAction: Controls how errors are handled (Stop, Continue, SilentlyContinue, Ignore)
  • -ErrorVariable: Stores errors in a custom variable
  • -WarningAction: Controls warning behavior
  • -WarningVariable: Stores warnings
  • -InformationAction: Controls information stream
  • -OutVariable: Stores output in a variable
  • -OutBuffer: Controls output buffering
  • -PipelineVariable: Names the current pipeline object

You don't declare these — CmdletBinding adds them automatically. Your function now behaves like native PowerShell cmdlets.

Example using common parameters:

# Enable verbose output
Get-DiskSpace -ComputerName "Server01" -Verbose
# Output:
# VERBOSE: Starting disk space check...
# VERBOSE: Checking Server01...
# ComputerName Drive SizeGB FreeGB PercentFree
# ------------ ----- ------ ------ -----------
# Server01     C:    500.00 120.00 24.0
# VERBOSE: Disk space check completed
 
# Stop on first error
Get-DiskSpace -ComputerName "Server01", "InvalidServer", "Server02" -ErrorAction Stop
# Stops at InvalidServer, never reaches Server02
 
# Continue silently on errors
Get-DiskSpace -ComputerName "Server01", "InvalidServer", "Server02" -ErrorAction SilentlyContinue
# Processes Server01, skips InvalidServer silently, processes Server02
 

Pipeline Support: Begin, Process, End

Advanced functions can process pipeline input efficiently using three special blocks:

function Test-PipelineDemo {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        [string]$Item
    )
 
    begin {
        Write-Host "BEGIN: Runs once before processing" -ForegroundColor Cyan
        $count = 0
    }
 
    process {
        Write-Host "PROCESS: Processing '$Item'" -ForegroundColor Yellow
        $count++
    }
 
    end {
        Write-Host "END: Runs once after all processing" -ForegroundColor Green
        Write-Host "Processed $count items total"
    }
}
 
# Test it
"Apple", "Banana", "Cherry" | Test-PipelineDemo
 

Output:


BEGIN: Runs once before processing
PROCESS: Processing 'Apple'
PROCESS: Processing 'Banana'
PROCESS: Processing 'Cherry'
END: Runs once after all processing
Processed 3 items total

How it works:

  1. begin {}: Runs once before any pipeline items are processed. Use for initialization (opening files, setting up connections, initializing counters).
  2. process {}: Runs once for each pipeline item. This is where you handle each input.
  3. end {}: Runs once after all pipeline items are processed. Use for cleanup (closing files, finalizing output, displaying summaries).

Why this matters: Efficient memory usage. The process block handles one item at a time, so your function can process millions of items without loading them all into memory.

ValueFromPipeline vs ValueFromPipelineByPropertyName

Two ways to accept pipeline input:

ValueFromPipeline: Accepts the entire piped object:

function Write-Upper {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        [string]$Text
    )
    process {
        $Text.ToUpper()
    }
}
 
"hello", "world" | Write-Upper
# HELLO
# WORLD
 

ValueFromPipelineByPropertyName: Binds parameter to a property of the piped object:

function Get-FileDetails {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias("FullName")]
        [string]$Path
    )
    process {
        if (Test-Path $Path) {
            Get-Item $Path | Select-Object Name, Length, LastWriteTime
        }
    }
}
 
# Get-ChildItem returns objects with a FullName property
Get-ChildItem C:\Windows\*.log | Get-FileDetails
 

Get-ChildItem outputs file objects with a FullName property. Because the $Path parameter has ValueFromPipelineByPropertyName and an alias FullName, PowerShell automatically binds each file's FullName property to the $Path parameter.

Rule of thumb:

  • Use ValueFromPipeline for simple types (strings, numbers) or when you want the whole object
  • Use ValueFromPipelineByPropertyName when working with objects that have properties matching your parameter names

SupportsShouldProcess: WhatIf and Confirm

For functions that modify system state, add -WhatIf and -Confirm support:

function Remove-OldLogFiles {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$Path,
 
        [Parameter()]
        [int]$DaysOld = 30
    )
 
    $cutoffDate = (Get-Date).AddDays(-$DaysOld)
    $files = Get-ChildItem $Path -Filter *.log | Where-Object { $_.LastWriteTime -lt $cutoffDate }
 
    foreach ($file in $files) {
        if ($PSCmdlet.ShouldProcess($file.FullName, "Delete file")) {
            Remove-Item $file.FullName -Force
            Write-Verbose "Deleted $($file.Name)"
        }
    }
}
 

Now your function supports:

# See what would be deleted without actually deleting
Remove-OldLogFiles -Path "C:\Logs" -WhatIf
# What if: Performing the operation "Delete file" on target "C:\Logs\app-2024-01-15.log".
# What if: Performing the operation "Delete file" on target "C:\Logs\app-2024-01-16.log".
 
# Prompt for confirmation before each deletion
Remove-OldLogFiles -Path "C:\Logs" -Confirm
 
# Actually delete (no prompts)
Remove-OldLogFiles -Path "C:\Logs"
 

$PSCmdlet.ShouldProcess($target, $action) returns $true if the action should proceed. It handles -WhatIf and -Confirm logic automatically.

Best practice: Always add SupportsShouldProcess to functions that:

  • Delete files or data
  • Modify system configuration
  • Stop services or processes
  • Make any irreversible changes
Was this page helpful?
SR

Written by the ShellRAG Team

The ShellRAG editorial team writes practical, beginner-friendly PowerShell tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.

Learn more about us →