Stop Writing Text to the Pipeline: Why PSCustomObject Pays Off in PowerShell Scripts
Ad‑hoc text output dies at the console. Emitting [pscustomobject] instead makes PowerShell scripts reusable by Format-*, ConvertTo-*, and Export-* cmdlets, with a few allocation and serialization trade‑offs worth understanding.
13 Sept 2025, 17:14 UTC

Every PowerShell script eventually grows a second consumer. The report you wrote for your own console gets piped into someone else’s CSV export, a scheduled job, or a monitoring system. If that script emitted formatted strings with Write-Host or hand‑built text, the second consumer gets nothing usable — the data died at the console.
The fix is cheap: emit [pscustomobject] instances instead of text. Structured objects flow through Format-*, ConvertTo-*, and Export-* cmdlets natively, so the same script serves a human at a console and a machine downstream without modification. This post shows what that looks like in practice, where it costs you, and how to verify you got it right.
The problem with ad‑hoc output
Ad‑hoc output usually starts innocently:
Write-Host "Server: $name CPU: $cpu% Status: $status"
That line is fine until someone asks for the same data as JSON, or wants to filter to servers over 80% CPU, or needs it in a CSV. Now you’re parsing your own output with regex, or maintaining two code paths – one for humans, one for machines. Write-Host is the worst offender because in modern PowerShell it writes to the information stream intended for display, not the success output stream, so piping it downstream gives the next command nothing at all.
A PSCustomObject is a lightweight typed object whose properties you define up front. Because each value is a named property rather than a position in a string, every downstream cmdlet can select, sort, filter, and serialize it without knowing anything about your script.
A worked example: a disk report that survives reuse
Here’s a small inventory function written the structured way. Run it in any PowerShell 5.1+ or PowerShell 7+ session; no special permissions are needed for the local machine, though querying remote machines via Get-CimInstance -ComputerName requires admin rights on the target.
function Get-DiskReport {
[CmdletBinding()]
param(
[string[]]$ComputerName = $env:COMPUTERNAME
)
foreach ($computer in $ComputerName) {
$disks = Get-CimInstance -ClassName Win32_LogicalDisk \
-ComputerName $computer \
-Filter "DriveType=3"
foreach ($disk in $disks) {
[pscustomobject]@{
ComputerName = $computer
Drive = $disk.DeviceID
SizeGB = [math]::Round($disk.Size / 1GB, 2)
FreeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
PercentFree = [math]::Round(($disk.FreeSpace / $disk.Size) * 100, 1)
}
}
}
}
Because the output is structured, all of these work with zero changes to the function:
# Human‑readable table
Get-DiskReport | Format-Table -AutoSize
# Machines with less than 15% free space
Get-DiskReport | Where-Object PercentFree -lt 15
# Hand off to another system
Get-DiskReport | ConvertTo-Json
Get-DiskReport | Export-Csv -Path .\disk-report.csv -NoTypeInformation
That last point is the payoff: ConvertTo-Json serializes the objects deterministically – property names become JSON keys, values keep their types – with no custom parsing script. Try that with the Write-Host version and you’re writing a string splitter.
Verify the object is what you think it is
Don’t trust the shape of your output by eyeballing the console – formatting can hide the truth. Pipe to Get-Member instead:
Get-DiskReport | Get-Member -MemberType NoteProperty
You should see each property listed as a NoteProperty with the expected name and type (double for the rounded numbers, string for the drive letter). If you see String objects instead of a PSCustomObject, something upstream converted your output to text – a stray Out-String or string interpolation is the usual suspect.
A second quick check: pipe to Select-Object -First 1 | ConvertTo-Json and confirm the JSON keys match your property names exactly. This catches accidental property renames before a downstream consumer does.
The trade‑offs worth knowing
PSCustomObject isn’t free, and two limitations come up regularly.
Allocation cost in tight loops. Every [pscustomobject]@{…} allocates a new object on the heap. In a script that emits a few hundred objects, you’ll never notice. In a long‑running collector emitting hundreds of thousands of objects per run, the allocation and garbage‑collection pressure becomes measurable. If profiling shows it matters, options include emitting plain hashtables (cheaper, but unordered in older versions and less friendly to Format-Table) or reusing a typed class instance defined with class syntax. Measure before optimizing – the readability of the custom‑object version is usually worth more than the microseconds.
Nested objects and JSON round‑trips. A flat PSCustomObject serializes cleanly. A nested one – say, a Disks property containing an array of more custom objects – serializes fine on the way out, but ConvertFrom-Json returns generic nested PSCustomObjects, and ConvertTo-Json has a default -Depth of 2. Deeper structures get silently truncated to string representations unless you pass -Depth explicitly. If your data is genuinely hierarchical, decide on a depth limit deliberately and test the round‑trip: $obj | ConvertTo-Json -Depth 5 | ConvertFrom-Json, then compare a leaf value.
Where to draw the line
The rule of thumb: if the output is data – anything a person or program might filter, sort, store, or transform – emit PSCustomObjects. Reserve plain text for genuine human‑only messages (progress notes, warnings, prompts), and route those through Write-Verbose, Write-Warning, or Write-Host so they never contaminate the output stream.
The actionable step for this week: pick one script in your repo that builds output with string concatenation or Write-Host, convert its core output to [pscustomobject], and confirm with Get-Member that the pipeline now carries typed properties. The first time someone pipes your script into Export-Csv without asking you to change anything, the pattern will have paid for itself.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.