PowerShell Script Blocks: Use Code as a First-Class Object

Script blocks are the secret sauce behind Where-Object, ForEach-Object, Invoke-Command, and nearly every dynamic PowerShell pattern — yet many scripters only use them implicitly without understanding them as first-class objects. A PowerShell script block is a reusable chunk of code you can store in a variable, pass as an argument, invoke on demand, and even capture variables from the surrounding scope. Understanding script blocks unlocks higher-order programming patterns that make your code more flexible and composable.
Define and Invoke a Script Block
A script block is defined with curly braces and stored in a variable like any other object. Invoke it with the & call operator or the .Invoke() method.
# Define a script block
$greet = { Write-Output "Hello from a script block!" }
# Invoke with the call operator
& $greet
# Invoke with the .Invoke() method
$greet.Invoke()
Hello from a script block!
Hello from a script block!
# Script block with multiple statements
$summary = {
$proc = (Get-Process | Measure-Object).Count
$svc = (Get-Service | Where-Object Status -eq Running).Count
Write-Output "Processes: $proc — Running services: $svc"
}
& $summary
Pass Script Block as Parameter
Script blocks are the mechanism behind -FilterScript, -ScriptBlock, and similar parameters in PowerShell cmdlets. You can also define functions that accept script blocks as parameters.
# Script blocks passed to cmdlets
Get-Process | Where-Object { $_.CPU -gt 10 }
Get-ChildItem "C:\Logs" | ForEach-Object { $_.Name.ToUpper() }
# Function that accepts a script block parameter
function Measure-ScriptBlock {
param(
[scriptblock]$ScriptBlock,
[int]$Iterations = 3
)
$times = for ($i = 0; $i -lt $Iterations; $i++) {
(Measure-Command { & $ScriptBlock }).TotalMilliseconds
}
[PSCustomObject]@{
Iterations = $Iterations
AvgMs = [math]::Round(($times | Measure-Object -Average).Average, 2)
MinMs = [math]::Round(($times | Measure-Object -Minimum).Minimum, 2)
}
}
Measure-ScriptBlock -ScriptBlock { Get-Process | Measure-Object } -Iterations 5
Script Blocks with Arguments
Pass arguments to a script block using the param() declaration inside it, then supply them with the call operator or .Invoke().
# Script block with parameters
$addNumbers = { param($a, $b) $a + $b }
# Call with arguments
& $addNumbers 5 10 # Returns 15
& $addNumbers -a 100 -b 200 # Returns 300
# Script block that processes a path
$getFileCount = {
param([string]$Path, [string]$Filter = "*")
(Get-ChildItem -Path $Path -Filter $Filter -File).Count
}
& $getFileCount "C:\Logs" "*.log"
Using .Invoke() Method
The .Invoke() method calls the script block and returns a collection. Note that .Invoke() always returns a collection (System.Collections.ObjectModel.Collection), not a scalar — even for single-value outputs.
# .Invoke() always returns a collection
$double = { param($n) $n * 2 }
$result = $double.Invoke(5)
Write-Output $result # Outputs: 10
Write-Output $result.GetType().Name # Collection`1
# Access the first element
$value = $result[0] # 10
# Contrast with & operator (returns scalar or array naturally)
$value2 = & $double 5 # Returns scalar 10
Closures: Capturing Variable Values
A script block can capture variables from its enclosing scope at the time it’s defined, creating a closure-like behavior. Use GetNewClosure() to explicitly capture the current values — otherwise variable references in the block resolve at invocation time.
# Without GetNewClosure — variable resolves at invocation time
$blocks = @()
for ($i = 0; $i -lt 3; $i++) {
$blocks += { $i } # $i is referenced, not captured
}
& $blocks[0] # Returns 3 (current value of $i after loop)
# With GetNewClosure — value captured at definition time
$closures = @()
for ($i = 0; $i -lt 3; $i++) {
$closures += { $i }.GetNewClosure()
}
& $closures[0] # Returns 0
& $closures[1] # Returns 1
& $closures[2] # Returns 2
Common Uses: ForEach-Object, Where-Object
Script blocks are the argument type for the most-used pipeline filtering and transformation cmdlets. Understanding this makes pipelines feel natural.
# Store filter logic in a named script block for reuse
$isHighCpu = { $_.CPU -gt 20 }
$largeProcess = { $_.WorkingSet -gt 100MB }
# Reuse the same script block in multiple places
Get-Process | Where-Object $isHighCpu
Get-Process | Where-Object { $isHighCpu.Invoke($_)[0] } # Alternative
# Dynamic dispatch — choose logic based on parameter
function Get-FilteredProcesses {
param([ValidateSet("HighCPU","LargeMem","All")][string]$Filter = "All")
$filters = @{
"HighCPU" = { $_.CPU -gt 20 }
"LargeMem" = { $_.WorkingSet -gt 100MB }
"All" = { $true }
}
Get-Process | Where-Object $filters[$Filter]
}
Get-FilteredProcesses -Filter HighCPU
Common Errors and Fixes
- Variable capture at definition time vs execution time: A script block stored in a variable captures variable names, not values, unless you use
GetNewClosure(). When you run the script block later, it looks up the variable in the scope where it runs — not where it was defined. This causes confusing behavior in loops. If you need a script block to remember a value from when it was created, call.GetNewClosure()on it immediately after creation. - .Invoke() returns collection not scalar:
$sb.Invoke()always returns aCollection[PSObject], even if the script block produces one value. If you assign the result to a typed variable like[int]$n = $sb.Invoke(5), the implicit conversion may fail or produce unexpected results. Either use& $sb 5(call operator returns natural types) or access the first element with$sb.Invoke(5)[0].
Related Cmdlets / See Also
Wrapping Up
Script blocks are one of PowerShell’s most powerful features — once you think of them as first-class objects you can store, pass, and invoke, a whole range of dynamic and reusable code patterns becomes available. As a next step, refactor a complex Where-Object filter in your most-used pipeline into a named script block variable — you’ll be able to reuse the same filter logic in multiple places and make your code considerably more readable.


