PowerShell Best Practice: Emit PSCustomObject and Format at the Edge
If you want your PowerShell scripts to scale from quick one-liners to reusable automation, stop printing strings and start emitting objects. When your functions return PSCustomObject instead of pre-formatted text, callers can sort, filter, export, and compose pipelines without scraping output. You get predictable behavior, cleaner logs, and tooling that just works.
Why Output Objects, Not Text
PowerShell is an object shell. Emitting objects is the difference between code that looks good on a demo and code that survives CI jobs, scheduled tasks, and operations dashboards.
- Predictable pipelines: Upstream changes don't break downstream consumers relying on property names instead of string parsing.
- Easier reuse: A single function supports multiple use cases (CLI tables, JSON for APIs, CSV for analysts) without modification.
- Better tooling:
Sort-Object,Where-Object,Group-Object,Export-Csv, andConvertTo-Jsonoperate naturally on objects. - Cleaner logs: Log structured data, not ephemeral formatting.
- Safer upgrades: Adding properties is additive; changing string formats is a breaking change.
Here's a simple, object-first function you can drop into your toolbox:
function Get-DiskUsage {
[CmdletBinding()]
param([string]$Drive = 'C')
$d = Get-PSDrive -Name $Drive -ErrorAction Stop
$total = $d.Used + $d.Free
[pscustomobject]@{
Drive = $d.Name
FreeGB = [math]::Round($d.Free/1GB,2)
UsedGB = [math]::Round($d.Used/1GB,2)
PctFree = if ($total -gt 0) { [int](($d.Free/$total)*100) } else { 0 }
}
}
$info = Get-DiskUsage -Drive 'C'
# Caller decides presentation
$info | Format-Table -AutoSize
# Or machine-friendly
# $info | ConvertTo-Json -Depth 3The function returns a predictable object. The caller decides how to render it, whether as a table, JSON, or CSV.
Designing Object-First Functions
Build advanced functions with CmdletBinding
Advanced functions behave like built-in cmdlets and give you standard features (common parameters, pipeline binding, support for -WhatIf/-Confirm):
function Get-Widget {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateSet('Small','Medium','Large')]
[string]$Size,
[switch]$PassThru
)
process {
if ($PSCmdlet.ShouldProcess("Widget of size $Size", 'Create')) {
$obj = [pscustomobject]@{
PSTypeName = 'Contoso.Widget'
Size = $Size
CreatedAt = [datetime]::UtcNow
}
if ($PassThru) { $obj }
}
}
}- OutputType helps tooling and readers understand what you emit.
- Parameter metadata improves usability and validation.
- ShouldProcess enables safe "what if" operations.
Return PSCustomObject, not strings
Emit objects with stable, intention-revealing property names. Keep data types consistent (numbers as numbers, dates as DateTime, Booleans as [bool]):
[pscustomobject]@{
PSTypeName = 'System.Disk.Usage'
Drive = $d.Name
FreeGB = [double][math]::Round($d.Free/1GB, 2)
UsedGB = [double][math]::Round($d.Used/1GB, 2)
PctFree = [int]([Math]::Round(($d.Free/($d.Free+$d.Used))*100, 0))
}Tip: In modern PowerShell, property order is preserved as inserted, but if you want to be explicit when constructing a hashtable first, use [ordered]@{...} before casting to [pscustomobject].
Use the right streams (and reserve Write-Host for progress)
- Output (success) stream: return your data by placing the object on the pipeline (simply by writing it or leaving it as the last expression). Avoid
Write-Outputunless you need clarity; returning is idiomatic. - Verbose:
Write-Verbosefor developer diagnostics (-Verboseopt-in). - Information:
Write-Informationfor structured, routable informational messages. - Warning:
Write-Warningfor non-terminating issues. - Error:
Write-Errorfor non-terminating errors;throwfor terminating exceptions when you can't continue. - Progress/Host: Use
Write-Progress(preferred) orWrite-Hostfor user-facing progress/status only. Don't put data on these streams.
Write-Verbose "Starting scan..."
Write-Progress -Activity 'Disk Scan' -Status 'Enumerating drives' -PercentComplete 42
if ($problem) { Write-Warning 'Drive X is offline' }
try {
# Work that may fail
}
catch {
Write-Error -ErrorRecord $_
}Don't format inside your function
Never call Format-Table, Format-List, or Out-Host from within a function that others will consume. Formatting is a terminal operation that converts objects to format data; once formatted, they can't be sorted, filtered, or exported reliably.
Format at the Edge: Pipelines, Sorting, and Exporting
CLI-friendly formatting
Let the caller decide how the data should look in a terminal:
Get-DiskUsage C, D, E | Sort-Object PctFree | Format-Table Drive, FreeGB, UsedGB, PctFree -AutoSizeNeed more detail?
Get-DiskUsage -Drive C | Format-List *Machine-friendly output
Export to common formats without changing your function:
# JSON for APIs and logs
Get-DiskUsage -Drive C | ConvertTo-Json -Depth 3 | Out-File disk.json
# CSV for spreadsheets or data pipelines
Get-DiskUsage C, D, E | Export-Csv disk.csv -NoTypeInformation
# CLIXML for fidelity (preserves types)
Get-DiskUsage -Drive C | Export-Clixml disk.xmlComposing with other tools
Because you emit objects, composition is natural:
# Alert on low space
Get-DiskUsage C, D, E |
Where-Object PctFree -lt 15 |
ForEach-Object {
# Route to whatever you need: email, Teams, webhook
$_ | ConvertTo-Json -Compress | Invoke-RestMethod -Method Post -Uri https://example/api/alerts -Body $_
}
# Summaries by threshold
Get-DiskUsage C, D, E |
Group-Object { if ($_.PctFree -lt 10) { 'Critical' } elseif ($_.PctFree -lt 20) { 'Warning' } else { 'Healthy' } } |
Select-Object Name, CountWant parallelism? PowerShell 7 makes it easy to fan out work while still returning objects:
'C','D','E' | ForEach-Object -Parallel {
Get-DiskUsage -Drive $_
} | Sort-Object PctFreePractical Patterns and Tips
Stabilize your schema
- Choose stable, descriptive property names (
Drive,FreeGB,PctFree). - Use consistent types across all outputs. Don't mix strings and numbers in the same property.
- Be additive: add new properties instead of changing existing ones.
Surface metadata when helpful
Consider stamping a PSTypeName to enable custom formatting views and type extensions without polluting your function:
[pscustomobject]@{ PSTypeName = 'System.Disk.Usage'; Drive = 'C'; FreeGB = 42; UsedGB = 58; PctFree = 42 }Later, you can ship a formatting file (.format.ps1xml) to define default table/list views for this type—still formatting at the edge.
Validate inputs and fail well
- Validate parameters with
[ValidateSet],[ValidatePattern],[ValidateRange], and[ValidateScript]. - Use terminating errors (
throw) when the function cannot proceed; useWrite-Errorwhen it can continue processing other pipeline items. - Add
-ErrorActionsupport by not swallowing errors; let callers decide.
Test as data
Object-first design simplifies tests. With Pester, assert on properties, not text:
It 'emits usable properties' {
$o = Get-DiskUsage -Drive 'C'
$o | Should -BeOfType psobject
$o.PSObject.Properties.Name | Should -Contain 'PctFree'
$o.PctFree | Should -BeGreaterThanOrEqual 0
}When (and how) to show progress
For long-running tasks, report progress without contaminating the pipeline:
$drives = 'C','D','E'
for ($i = 0; $i -lt $drives.Count; $i++) {
$drive = $drives[$i]
Write-Progress -Activity 'Collecting disk usage' -Status "Drive $drive" -PercentComplete (($i/$drives.Count)*100)
Get-DiskUsage -Drive $drive
}Reserve Write-Host for simple, human-only status in interactive sessions. For automation, Write-Progress, Write-Verbose, and Write-Information are better choices because they're routable and controllable.
End-to-end example: object-first to report
Here's a small end-to-end script that collects data as objects, logs JSON to disk, and renders a human-friendly table at the very end:
$data = Get-DiskUsage C, D, E
# Log for machines
$data | ConvertTo-Json -Depth 3 | Out-File disk-usage.json -Encoding utf8
# Human-friendly output (format at the edge)
$data | Sort-Object PctFree | Format-Table Drive, FreeGB, UsedGB, PctFree -AutoSizeReal-world benefits
- DevOps pipelines: Publish JSON to an artifact store and let dashboards ingest it.
- Observability: Feed structured metrics into your log pipeline without brittle scraping.
- Compliance reports: Export CSVs for audit teams without changing function code.
- Tooling synergy:
Import-Csv,Measure-Object, and custom modules compose naturally on objects.
Build object-first scripts in PowerShell and you'll unlock predictable pipelines, easier reuse, cleaner logs, and better tooling. For deeper patterns and advanced techniques, explore the PowerShell ecosystem and consider resources like the PowerShell Advanced Cookbook. You can find it here: PowerShell Advanced Cookbook.