Functions & Script Files (.ps1)

PowerShell Fundamentals

Chapter 7 · Functions & Script Files (.ps1)

Everything so far has run one line, or one pipeline, at a time in an interactive session. This chapter is where PowerShell starts looking like a real programming language rather than a shell: packaging logic into reusable functions, giving them real typed parameters, and saving them into .ps1 script files — plus one rule that catches almost everyone coming from a C-family language off guard, because it means return doesn't work quite the way you'd expect.

Basic Function Syntax

function Get-Greeting { param($Name) "Hello, $Name!" } Get-Greeting -Name "Philip" # named parameter Get-Greeting "Philip" # positional — works too, in declaration order

Your own functions aren't required to follow Chapter 3's Verb-Noun convention, but it's worth adopting anyway — it's the difference between a function that reads clearly next to every built-in cmdlet, and one that stands out as obviously home-grown. Once functions start shipping inside a real module (Chapter 11), PowerShell will actually warn you if a function name uses an unapproved verb.

Parameters: Types, Defaults & Mandatory

function New-Greeting { param( [string]$Name = "World", [Parameter(Mandatory)] [int]$Age ) "Hello, $Name — you are $Age years old." } New-Greeting -Age 42 # Hello, World — you are 42 years old. New-Greeting -Name "Philip" -Age 42 New-Greeting "Philip" 42 # positional — same result, order matches param() New-Greeting # PowerShell PROMPTS you for -Age — Mandatory guarantees it's never missing

[Parameter(Mandatory)] doesn't throw immediately if you omit the value — it interactively prompts for it, which is a genuinely different failure mode from most languages simply erroring on a missing argument.

$args: Every Extra Positional Argument, With No param() at All

A function with no param() block isn't a function that takes no arguments — every positional argument you pass still lands somewhere, in the $args automatic variable flagged back in Chapter 5:

function Show-Args { $args } Show-Args 1 2 3 # 1 # 2 # 3

Return Values — The Single Biggest Habit to Unlearn

In most languages, only what follows an explicit return comes back from a function — everything else is just "stuff that ran." PowerShell doesn't work that way:

function Get-Doubled { param([int]$n) Write-Output "about to double $n" # this ALSO becomes part of the return value $n * 2 } $result = Get-Doubled -n 5 $result # about to double 5 # 10 $result.Count # 2 — $result is an array holding BOTH values, not just 10
The central fact this chapter is built on
Every un-suppressed line of output inside a function joins that function's return value — not just whatever follows return. Write-Output "about to double $n" was meant as a progress message, but because it was never captured, assigned, or redirected, it became a real, permanent part of what Get-Doubled actually returned. This is exactly the same object-pipeline behavior Chapter 1 built the whole course around, applied to a function body instead of a command line: anything that reaches the end of a statement uncaptured flows onward, whether that "onward" is the next pipeline stage or the function's own caller.
Use Write-Host for messages that must never pollute the return value
Write-Host writes straight to the console and never enters the output stream at all — unlike Write-Output (or a bare, uncaptured expression, which behaves identically to Write-Output), it can never accidentally become part of what a function returns. Reach for Write-Host specifically for progress/status messages a human should see but a caller should never receive as data. (Write-Verbose, suppressed by default unless -Verbose is passed, is another safe option — covered properly alongside error handling in Chapter 9.)

return: Exits Early, Optionally With One More Value

function Test-Positive { param([int]$n) if ($n -le 0) { return $false } return $true }

return's real job is stopping execution at that point — the value after it is just one more thing added to the output stream, exactly like any other uncaptured expression. It doesn't erase or override anything that was already output earlier in the function.

Script Files: Running vs. Dot-Sourcing

Save a function into a .ps1 file, and how you invoke that file changes whether its contents are actually usable afterward:

# tools.ps1 function Get-Square { param([int]$n) $n * $n } # Running it normally executes in a CHILD scope — Get-Square disappears once the script ends .\tools.ps1 Get-Square 4 # error — Get-Square isn't recognized out here # Dot-sourcing (note the leading ". " with a space) runs it in the CURRENT scope instead . .\tools.ps1 Get-Square 4 # 16 — now it's genuinely available in this session

Dot-sourcing is how you'd load a personal library of reusable functions into your current session — Chapter 11's module system is the properly packaged, shareable version of the exact same idea.

A first practical habit
Before trusting any function's return value, assign it and check .Count the way this chapter's $result.Count example did. If it's more than 1 and you only expected one value back, an uncaptured Write-Output call (or a bare expression) is almost always the cause.

Hands-On Exercises

Exercise 1

A function does Write-Output "Starting..." and then return 42. Explain exactly what $x = MyFunc assigns to $x — is it just 42? Use this chapter's own Get-Doubled example to support your answer.

📄 View solution
Exercise 2

Explain the difference between running .\script.ps1 normally and dot-sourcing it with . .\script.ps1. Specifically, why is a function defined inside the script only callable afterward in the dot-sourced case?

📄 View solution
Exercise 3

Write a function Get-Square that takes a mandatory [int] parameter and returns its square, with zero extra output polluting the return value. Explain how you'd actually verify — not just assume — that your function returns exactly one clean value.

📄 View solution

Chapter 7 Quick Reference

  • function Verb-Noun { param(...) ... } — basic function shape; Verb-Noun is a convention here, not enforced
  • [Parameter(Mandatory)] — prompts interactively for a missing value, rather than erroring immediately
  • $args — captures every positional argument when there's no param() block at all
  • Every uncaptured line joins the return value — not just what follows return; this is the single biggest habit to unlearn
  • Write-Host — console-only, never enters the output stream, safe for status messages
  • return — exits early; the value after it is just one more thing added to the output, not a replacement for earlier output
  • .\script.ps1 — runs in a child scope; functions/variables don't persist afterward
  • . .\script.ps1 (dot-sourcing) — runs in the current scope; functions/variables persist in your session
  • Next chapter: Working with Files, Text & Formatting Output