Lesson 6 • Intermediate
Functions and Scripts
By the end of this lesson you'll wrap reusable logic in named functions with typed, validated, and mandatory parameters, accept input straight from the pipeline, and understand exactly what a function returns — so your tools behave like real cmdlets instead of leaking surprises.
What You'll Learn
- Define a function with the function keyword and Verb-Noun naming
- Declare typed parameters with defaults inside a param() block
- Force required input with [Parameter(Mandatory)]
- Accept pipeline input using ValueFromPipeline + a process {} block
- Know exactly what a function returns (everything it emits) and use return correctly
- Promote a function to an advanced function with [CmdletBinding()]
💡 Real-World Analogy
A function is a kitchen appliance. A blender has labelled inputs (the lid, the speed dial) — those are your param() entries. Some inputs are required: you can't blend without the jug attached, just like [Parameter(Mandatory)]. You feed ingredients in through the top one at a time — that's pipeline input arriving in the process {} block. And the appliance produces one thing: the smoothie that pours out. In PowerShell, everything you pour out is the result — so if you accidentally drop a stray spoon in the jug, it comes out in the smoothie too. That "accidental spoon" is the output-pollution bug you'll learn to avoid.
1. The function keyword & param() blocks
A function is a named, reusable block of code. You write function Verb-Noun { ... } — the same Verb-Noun naming PowerShell uses for built-in cmdlets like Get-Date, which makes your tools feel native. Inputs go in a param() block, where each parameter can declare a type (like [string] or [int]) and a default value. Crucially, you call a function the shell way: space-separated, dash-named arguments like Get-Greeting -Name "Alice" — not with commas in brackets. Read this worked example and run it.
# 'function' gives a block of code a name so you can reuse it.
# Naming convention is Verb-Noun, the same as built-in cmdlets (Get-Date).
function Get-Greeting {
# param() lists the inputs. [string] is the TYPE; = "World" is a DEFAULT.
param([string]$Name = "World")
# Any value you leave "hanging" (not assigned to a variable) is OUTPUT.
"Hello, $Name!"
}
# Call it. PowerShell args are SPACE-separated and named with a leading dash:
Get-Greeting # uses the default -> Hello, World!
Get-Greeting -Name "Alice" # passes a value -> Hello, Alice!
Get-Greeting Bob # positional (no -Name) also works -> Hello, Bob!Hello, World!
Hello, Alice!
Hello, Bob!Your turn. The function below is almost complete — fill in the ___ blanks using the hints, then run it. Notice how a typed parameter with a default lets the caller skip arguments.
# 🎯 YOUR TURN — replace each ___ , then run it.
function Get-Area {
param(
# 1) Give -Width a type of [int] and a default of 1
[___]$Width = ___, # 👉 type is [int], default is 1
# 2) Give -Height a type of [int] and a default of 1
[int]$Height = ___ # 👉 default is 1
)
# 3) Output the area. Wrap maths in $(...) so it calculates inside the text
"Area is $(___)" # 👉 $Width * $Height
}
Get-Area -Width 4 -Height 3
Get-Area # both default to 1
# ✅ Expected output:
# Area is 12
# Area is 1Area is 12
Area is 1___, then run it in the free PowerShell terminal and check your output matches.2. Required input with [Parameter(Mandatory)] & [CmdletBinding()]
Sometimes a parameter is essential and there's no sensible default. Mark it [Parameter(Mandatory)] and PowerShell will refuse to run without it — if the caller forgets, the shell interactively prompts for the value instead of crashing. Adding [CmdletBinding()] at the top of the param() block promotes your function to an advanced function: it instantly gains the common parameters every real cmdlet has, like -Verbose and -ErrorAction. Write-Verbose messages only appear when the caller asks for them with -Verbose, and they never mix into your data output.
# [Parameter(Mandatory)] forces the caller to supply a value.
# [CmdletBinding()] turns this into an "advanced function" — it now gets
# free common parameters like -Verbose and -ErrorAction, just like a real cmdlet.
function New-Password {
[CmdletBinding()]
param(
[Parameter(Mandatory)] # if you omit -Length, PowerShell PROMPTS for it
[int]$Length,
[string]$Prefix = "pw" # optional, has a default
)
# Write-Verbose only prints when the caller passes -Verbose. It does NOT
# pollute the function's real output — handy for progress messages.
Write-Verbose "Building a password of length $Length"
$chars = -join ((1..$Length) | ForEach-Object { 'x' })
"$Prefix-$chars"
}
New-Password -Length 5
New-Password -Length 3 -Prefix "key"pw-xxxxx
key-xxx3. Pipeline input: ValueFromPipeline + process {}
PowerShell's superpower is the pipeline (|), and your functions can plug straight into it. Two pieces make it work: mark a parameter [Parameter(ValueFromPipeline)] so values are allowed to arrive from the pipe, and put your logic in a process {} block, which runs once for every item that comes down the pipeline. Inside process, your parameter (and the automatic variable $_) holds the current item. Skip the process block and your function only ever sees the last piped item — a very common beginner bug.
# Make a function accept PIPELINE input with two pieces:
# [Parameter(ValueFromPipeline)] -> "values can arrive from the pipeline"
# a process {} block -> runs ONCE PER item that comes down the pipe
function ConvertTo-Upper {
param(
[Parameter(ValueFromPipeline)]
[string]$Text
)
process {
# Inside process{}, $Text is the CURRENT item. $_ also refers to it.
$Text.ToUpper()
}
}
# Send three strings down the pipeline — process{} fires three times.
"hello", "world", "powershell" | ConvertTo-Upper
# It still works as a normal parameter too:
ConvertTo-Upper -Text "direct"HELLO
WORLD
POWERSHELL
DIRECTYour turn. Combine what you've learned: make Get-Square accept numbers from the pipeline and require a label. Fill in the three blanks.
# 🎯 YOUR TURN — make Get-Square accept pipeline input and a required label.
function Get-Square {
[CmdletBinding()]
param(
# 1) Mark -Number so values can arrive FROM the pipeline
[Parameter(___)] # 👉 ValueFromPipeline
[int]$Number,
# 2) Make -Label required
[Parameter(___)] # 👉 Mandatory
[string]$Label
)
# 3) This must run once per piped item — name the block correctly
___ { # 👉 process
"$Label $Number = $($Number * $Number)"
}
}
1, 2, 3 | Get-Square -Label "sq"
# ✅ Expected output:
# sq 1 = 1
# sq 2 = 4
# sq 3 = 9sq 1 = 1
sq 2 = 4
sq 3 = 9___ blanks (ValueFromPipeline, Mandatory, process), then run it and confirm three lines appear.4. return and what a function really outputs
Here's the idea that trips up everyone coming from C#, Python, or JavaScript: in PowerShell, every value a function emits is part of its return value — not just the thing after return. A bare "Starting..." on its own line is emitted and ends up in the output. return $x doesn't mean "this is THE result"; it means "stop the function now and emit $x". To send a value back you simply leave it un-assigned. To print a human message without polluting the output, use Write-Host or Write-Verbose, which write to separate channels.
# THE BIG RULE: everything a function emits becomes its output.
# 'return' does NOT mean "this is the result" — it just STOPS the function and
# hands back whatever you put after it (which is also added to the output).
function Get-Total {
param([int[]]$Numbers)
"Starting..." # ⚠️ this string is ALSO output (a leak!)
$sum = ($Numbers | Measure-Object -Sum).Sum # assigned -> NOT output
return $sum # stops here and emits $sum
}
# Because "Starting..." was emitted, the caller gets TWO objects back:
$result = Get-Total -Numbers 1, 2, 3
"Captured: $result"
# Fix: don't emit stray values. Use Write-Host/Write-Verbose for messages,
# which are NOT part of the data output.
function Get-CleanTotal {
param([int[]]$Numbers)
Write-Host "Starting..." # message channel, not the output channel
($Numbers | Measure-Object -Sum).Sum
}
$clean = Get-CleanTotal -Numbers 1, 2, 3
"Clean: $clean"Captured: Starting... 6
Starting...
Clean: 6Watch the output channel
In the first example, $result captured both "Starting..." and 6 because both were emitted. The fix is to never leave a stray value un-handled: assign it, pipe it to Out-Null, or use Write-Host/Write-Verbose for messages. This is the single most common reason a PowerShell function "returns weird extra stuff".
Deep Dive: variable scope
Variables you create inside a function are local to it — they vanish when the function ends and don't leak out to the caller. That's a good thing: it keeps your functions self-contained.
The twist is that a function can read a variable from the surrounding (parent) scope, but if it assigns to one, it creates a new local copy and leaves the original untouched. So this prints 1, not 99:
$count = 1
function Bump { $count = 99 } # creates a LOCAL $count
Bump
$count # still 1 — the outer one never changedPrefer passing values in as parameters and returning results out — relying on outer variables makes functions fragile. If you truly must change a caller's variable, that's what [ref] parameters or explicit $script: / $global: scopes are for, but reach for them rarely.
Common Errors (and the fix)
- Calling with commas/brackets like other languages:
Get-Area(4, 3)does not pass two arguments — it passes one two-item array to the first parameter. PowerShell uses space-separated, dash-named args:Get-Area -Width 4 -Height 3. - Accidental output pollution: a stray line like
"done", or$list.Add(1)(which returns the index), gets added to your output. Symptom:$x = My-Funccomes back as an array of unexpected things. Fix: assign it, pipe to| Out-Null, or useWrite-Host/Write-Verbosefor messages. - Pipeline input only sees the last item: you used
[Parameter(ValueFromPipeline)]but put the logic outside aprocess {}block, so it ran once on the final item. Fix: move the per-item work intoprocess {}. - Changing an outer variable from inside a function and it "doesn't stick": assigning inside a function makes a local copy. Return the new value and capture it (
$count = Bump $count) instead of mutating the outer one. - "A parameter cannot be found that matches parameter name 'X'": you passed a
-Nametheparam()block doesn't declare (often a typo). Check the parameter spelling against the function definition.
📋 Quick Reference
| Concept | Syntax |
|---|---|
| Define a function | function Verb-Noun { ... } |
| Typed param + default | param([int]$N = 1) |
| Required param | [Parameter(Mandatory)] |
| Pipeline input | [Parameter(ValueFromPipeline)] + process {} |
| Advanced function | [CmdletBinding()] |
| Return a value | just emit it (or return $x to stop early) |
| Call a function | Verb-Noun -Param value |
| Suppress output | expression | Out-Null |
Frequently Asked Questions
Q: Why does Get-Greeting -Name 'Alice' use a space and a dash, not ("Alice")?
PowerShell is a shell, so it parses commands like a command line, not like C# or Python. Arguments are separated by spaces and named with a leading dash (-Name). Writing Get-Greeting("Alice") passes a single array argument to the first positional parameter, and Get-Greeting("Alice", "Bob") passes ONE two-item array — almost never what you want. Always call functions as: Name -Param value -Param2 value2.
Q: Do I need 'return' at the end of a function?
No. In PowerShell every value you leave un-assigned is automatically added to the output, so the last expression is already returned. 'return' only stops the function early — it is not how you mark 'the result'. Use it for guard clauses (return early if input is bad), not at the natural end of a function.
Q: My function returns extra junk I didn't ask for. Why?
Output pollution. Any expression that isn't captured into a variable, piped, or sent to Out-Null becomes part of the output — including a stray string, or the return value of something like $list.Add(1). Assign such values away, pipe them to Out-Null, or use Write-Host / Write-Verbose for human messages so they don't mix into the data you return.
Q: What is the difference between begin, process, and end blocks?
When a function takes pipeline input you can split its body into three blocks. begin {} runs once before any items, process {} runs once for EACH item coming down the pipeline (this is where $_ / your ValueFromPipeline parameter holds the current item), and end {} runs once after all items. If you don't write these blocks, your whole function body behaves like an end block and only sees the LAST piped item — which is a classic pipeline bug.
Q: What does [CmdletBinding()] actually give me?
It promotes a basic function into an 'advanced function' that behaves like a compiled cmdlet. You get the common parameters for free — -Verbose, -Debug, -ErrorAction, -ErrorVariable, -WhatIf/-Confirm (with SupportsShouldProcess) — plus stricter parameter binding. It costs you nothing, so it's good practice on any function that takes parameters.
Mini-Challenge: Convert-Temperature
No blanks this time — just a brief and an outline. Build a pipeline-friendly function from scratch, run it, and check your output against the example in the comments. This is exactly the shape of a real reusable tool.
# 🎯 MINI-CHALLENGE: a Convert-Temperature function
# 1. function Convert-Temperature with [CmdletBinding()]
# 2. Params:
# -Celsius [double] marked [Parameter(Mandatory)] AND ValueFromPipeline
# -Round [int] default 1 (decimal places)
# 3. In a process {} block, work out fahrenheit = $Celsius * 9 / 5 + 32
# and OUTPUT one line: "$Celsius C = <fahrenheit> F"
# (use [math]::Round(<value>, $Round) to round)
# 4. Make sure NOTHING else is emitted (no stray strings -> no output leak).
#
# Try it with: 0, 37, 100 | Convert-Temperature
#
# ✅ Expected output:
# 0 C = 32 F
# 37 C = 98.6 F
# 100 C = 212 F
# your code here0 C = 32 F
37 C = 98.6 F
100 C = 212 F0, 37, 100 | Convert-Temperature and confirm the three lines match.🎉 Lesson Complete!
- ✅
function Verb-Nounnames reusable code; call it with space-separated, dash-named args - ✅
param()declares typed parameters with defaults;[Parameter(Mandatory)]makes one required - ✅
ValueFromPipeline+ aprocess {}block let a function stream the pipeline, once per item - ✅ Everything a function emits is returned;
returnonly stops it early - ✅ Use
Write-Host/Write-Verbosefor messages to avoid output pollution - ✅
[CmdletBinding()]makes an advanced function with free common parameters - ✅ Next lesson: File System and Registry — navigate and automate Windows storage
Sign up for free to track which lessons you've completed and get learning reminders.