Object‑First Output in PowerShell: Return Objects, Format at the Edge
PowerShell is an object shell, not a text shell. When you embrace an object-first mindset, your scripts become safer, composable, and easy to automate across CI/CD, APIs, and UIs. The rule is simple: return objects from functions and format only at the presentation edge. When you need files, serialize those objects with ConvertTo-Json, Export-Csv, or Export-Clixml. In this post, you will learn why this approach matters, how to design functions that emit clean PSCustomObject results, and how to present and persist data without breaking pipelines.
Why Object-First Output Wins
Keeping data as objects until the very last moment pays off across reliability, automation, and maintainability:
- Safer pipelines: Objects retain types (DateTime, Int32, Boolean), eliminating fragile string parsing and locale issues.
- Predictable formatting: You choose presentation only at the edge (console, UI, report), so intermediate steps never break on column widths, wrapping, or alignment.
- Composability: Objects flow through Where-Object, Group-Object, Sort-Object, and Select-Object without information loss.
- Automation-ready: Convert objects to JSON for APIs, CSV for spreadsheets, or CLIXML to retain types; use the same function output everywhere.
- Testability: Pester assertions against properties are precise (e.g., $result.CPU -gt 10) compared to brittle regex or substring checks.
- Performance and clarity: You skip expensive, repeated formatting during intermediate steps and keep business logic separate from presentation logic.
Design Functions to Emit PSCustomObject
Build advanced functions that return objects and never call Format-* inside. Shape properties intentionally, add types when helpful, and keep side effects optional and explicit.
A clean pattern
function Get-ProcessStats {
[CmdletBinding()]
param([int]$Top = 5)
Get-Process |
Sort-Object CPU -Descending |
Select-Object -First $Top Name, Id,
@{ Name = 'CPU'; Expression = { [math]::Round($_.CPU, 2) } },
@{ Name = 'WS_MB'; Expression = { [math]::Round($_.WS / 1MB, 2) } }
}
# Compose without losing type info
$data = Get-ProcessStats -Top 3
# Save machine-readable output
$data | ConvertTo-Json -Depth 5 | Set-Content -Path './proc.json' -Encoding utf8
# Present to console only at the edge
$data | Format-Table -AutoSizeThe function returns objects with predictable properties: Name (String), Id (Int32), CPU (Double), and WS_MB (Double). You can pipe to Where-Object, Group-Object, Export-Csv, or ConvertTo-Json without surprises.
Anti-pattern: formatting inside a function
# Don't do this: it returns formatting records, not your data
function Get-ProcessStatsBad {
Get-Process | Select-Object Name, Id | Format-Table -AutoSize
}
# This breaks because Export-Csv can't consume format output
Get-ProcessStatsBad | Export-Csv bad.csv -NoTypeInformation
Rule: never return the output of Format-Table/Format-List/Out-String from a function intended for automation. Those commands produce formatted views (formatting objects), not your business objects.
Practical tips for object-first functions
- Use [CmdletBinding()] for common parameters and pipeline semantics. Add [OutputType([pscustomobject])] for discoverability.
- Return PSCustomObject with a stable property contract and clear names. Consistent casing helps downstream consumers.
- Don't write host output (Write-Host) for data. Prefer Write-Verbose/Write-Debug for diagnostics.
- Emit one thing: either return objects or do output formatting/logging; avoid mixing both.
- Validate inputs with attributes (ValidateRange, ValidateSet) so your output is consistent.
- Use types when they help (e.g., [datetime] for timestamps, [int] for counts) to keep comparisons and sorting reliable.
function Get-WebsiteHealth {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string[]]$Uri,
[int]$TimeoutSeconds = 5
)
foreach ($u in $Uri) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
try {
$resp = Invoke-WebRequest -Uri $u -Method Head -TimeoutSec $TimeoutSeconds -ErrorAction Stop
$status = $resp.StatusCode
$ok = $true
}
catch {
$status = 0
$ok = $false
}
finally { $sw.Stop() }
[pscustomobject]@{
Uri = $u
StatusCode = [int]$status
IsUp = [bool]$ok
ResponseMs = [int]$sw.Elapsed.TotalMilliseconds
CheckedAt = [datetime]::UtcNow
}
}
}
# Reuse everywhere
$health = Get-WebsiteHealth -Uri @('https://example.com','https://contoso.com')
$health | Where-Object { -not $_.IsUp } | Export-Csv -Path './down.csv' -NoTypeInformation -Encoding utf8
$health | ConvertTo-Json -Depth 4 | Set-Content './health.json' -Encoding utf8
$health | Sort-Object ResponseMs -Descending | Format-Table -AutoSizeFormat at the Edge, Serialize for Machines
Once your functions return objects, you can decide how to present or persist them depending on the audience and channel.
Console presentation
- Format-Table and Format-List belong at the edge. Use -AutoSize and -Wrap if needed.
- Out-GridView (Windows/compatible environments) provides an interactive viewer without altering the objects.
- PSStyle (PowerShell 7+) can colorize edge-only output without changing data.
# Edge-only formatting
Get-ProcessStats -Top 8 |
Sort-Object WS_MB -Descending |
Format-Table Name, Id, CPU, WS_MB -AutoSizeFiles and interop
- JSON for APIs and structured logging. Preserve nested objects with -Depth and keep it compact with -Compress if you like.
- CSV for spreadsheets and tabular data; ideal for ops handoffs and BI tools.
- CLIXML for round-tripping types within PowerShell (Import-Clixml restores types).
$data = Get-ProcessStats -Top 5
# JSON for APIs / logs
$json = $data | ConvertTo-Json -Depth 5 -Compress
Set-Content -Path './proc.json' -Value $json -Encoding utf8
# CSV for analysts
$data | Export-Csv -Path './proc.csv' -NoTypeInformation -Encoding utf8
# CLIXML to preserve types exactly
$data | Export-Clixml -Path './proc.xml'Calling REST APIs
$payload = [pscustomobject]@{
timestamp = [datetime]::UtcNow
node = $env:COMPUTERNAME
top = 3
metrics = Get-ProcessStats -Top 3
}
Invoke-RestMethod -Uri 'https://api.example.com/ingest' -Method Post \
-ContentType 'application/json' -Body ($payload | ConvertTo-Json -Depth 6)CI/CD and automation
Use the same functions across local scripts, scheduled tasks, and pipelines. In CI, publish JSON artifacts that downstream jobs can validate or visualize.
# Pseudo GitHub Actions step (pwsh)
$results = Get-WebsiteHealth -Uri @('https://example.com','https://contoso.com')
$path = Join-Path $env:GITHUB_WORKSPACE 'health.json'
$results | ConvertTo-Json -Depth 4 | Set-Content $path -Encoding utf8
# Later, another job can parse the JSON to fail the build if anything is down
if (($results | Where-Object IsUp -eq $false)) { exit 1 }Shaping output for consumers
- Project only what you need before serialization to keep files slim and stable over time.
- Name columns intentionally; stable names are a contract with dashboards, notebooks, and reports.
- Redact secrets and ephemeral tokens before writing logs or artifacts.
# Create a stable, slim contract for dashboards
Get-WebsiteHealth -Uri 'https://example.com' |
Select-Object Uri, IsUp, ResponseMs, @{n='CheckedAtUtc'; e={$_.CheckedAt}} |
Export-Csv './uptime.csv' -NoTypeInformation -Encoding utf8Composition patterns
- Fan-out, fan-in: Merge objects from multiple sources, then group and aggregate.
- Enrichment: Join additional metadata by key (e.g., via hashtables or Import-Csv lookup) and emit new objects.
- Streaming filters: Filter early, format late.
# Aggregate by process name and compute totals
Get-ProcessStats -Top 20 |
Group-Object Name |
ForEach-Object {
[pscustomobject]@{
Name = $_.Name
Count = $_.Count
CPU = ($_.Group | Measure-Object CPU -Sum).Sum
WS_MB = ($_.Group | Measure-Object WS_MB -Sum).Sum
}
} |
Sort-Object CPU -Descending |
Format-Table -AutoSizeChecklist: Keep Data as Objects
- Return PSCustomObject (or domain objects), not formatted text.
- Don't call Format-* inside reusable functions. Format only at the edge.
- Use ConvertTo-Json, Export-Csv, or Export-Clixml to persist data for machines.
- Keep a stable property contract and typed fields.
- Use Write-Verbose/Write-Debug for diagnostics, not data.
- Compose with the pipeline: Where-Object, Group-Object, Select-Object, Sort-Object.
Adopting an object-first output strategy makes your PowerShell code more reliable, easier to test, and ready for automation. You get clean outputs, safer pipelines, easier reuse, and predictable formatting—without sacrificing readability at the console when you need it.
Further reading: Refine how you shape and present results in your scripts. Explore advanced patterns and tooling in resources like the PowerShell Advanced CookBook.