PowerShell PSCustomObject: Build Custom Output Objects

PowerShell PSCustomObject: Build Custom Output Objects

PowerShell Tips Editor 2 min read
PowerShell PSCustomObject: Build Custom Output Objects

A function that returns a formatted string is nearly useless in a pipeline — you can’t sort it, filter it, or export it to CSV. A function that returns a PowerShell PSCustomObject works with everything: Sort-Object, Where-Object, Export-Csv, Format-Table, and every other downstream cmdlet in the PowerShell ecosystem. Learning to build custom objects is the single upgrade that makes the most difference to the quality and reusability of your PowerShell functions.

Create a PSCustomObject

The modern syntax for creating a custom object uses the [PSCustomObject] type accelerator with a hashtable. This approach is concise, readable, and available in PowerShell 3.0+.

$server = [PSCustomObject]@{
    Name      = "server01"
    OS        = "Windows Server 2022"
    IPAddress = "10.0.0.5"
    Online    = $true
}

$server
Name     OS                    IPAddress  Online
----     --                    ---------  ------
server01 Windows Server 2022   10.0.0.5   True

Access individual properties with dot notation: $server.Name, $server.Online.

Add Properties with [PSCustomObject] Cast

The cast syntax ensures property order is preserved. Properties appear in the output in the order you define them, which matters for Format-Table and Export-Csv column ordering.

$diskInfo = [PSCustomObject]@{
    Drive   = "C:"
    SizeGB  = [math]::Round((Get-PSDrive C | Select-Object -Expand Used) / 1GB +
               (Get-PSDrive C | Select-Object -Expand Free) / 1GB, 1)
    FreeGB  = [math]::Round((Get-PSDrive C).Free / 1GB, 1)
    FreePct = [math]::Round((Get-PSDrive C).Free / ((Get-PSDrive C).Used + (Get-PSDrive C).Free) * 100, 1)
}

$diskInfo | Format-Table

PSCustomObject vs Hashtable Output

A hashtable looks similar to a PSCustomObject when printed but behaves differently in the pipeline. Hashtables don’t display as table rows — they display as key-value pairs. PSCustomObjects display as table rows with column headers, format properly, and integrate with all pipeline cmdlets.

# Hashtable output (harder to read, doesn't export to CSV nicely)
$ht = @{ Name = "server01"; Status = "Online" }
$ht  # Displays as Name/Value list

# PSCustomObject output (clean table, full pipeline compatibility)
$obj = [PSCustomObject]@{ Name = "server01"; Status = "Online" }
$obj  # Displays as row with column headers

# PSCustomObject exports to CSV correctly
$obj | Export-Csv -Path "C:\Logs\status.csv" -NoTypeInformation

Add Calculated Properties

Include computed values alongside raw data by including expressions directly in the object definition. This is cleaner than modifying the object after creation.

$proc = Get-Process | Sort-Object CPU -Descending | Select-Object -First 1

$report = [PSCustomObject]@{
    ProcessName   = $proc.Name
    PID           = $proc.Id
    CPUSec        = [math]::Round($proc.CPU, 2)
    MemoryMB      = [math]::Round($proc.WorkingSet / 1MB, 1)
    RunTimeMin    = [math]::Round(((Get-Date) - $proc.StartTime).TotalMinutes, 0)
    ThreadCount   = $proc.Threads.Count
}

$report

Build an Array of PSCustomObjects

The most common pattern is building a collection of custom objects from a loop, then working with the collection as a whole. Use foreach and assign the result to capture all iterations.

$servers = @("server01", "server02", "server03")

$inventory = foreach ($server in $servers) {
    $os   = Get-CimInstance -ComputerName $server -ClassName Win32_OperatingSystem
    $bios = Get-CimInstance -ComputerName $server -ClassName Win32_BIOS

    [PSCustomObject]@{
        Computer = $server
        OS       = $os.Caption
        Serial   = $bios.SerialNumber
        LastBoot = $os.LastBootUpTime
    }
}

$inventory | Format-Table -AutoSize

Export PSCustomObjects to CSV

PSCustomObjects export to CSV perfectly — each object becomes a row, each property becomes a column. This is the primary reason to use them in reporting scripts.

$report = foreach ($svc in Get-Service) {
    [PSCustomObject]@{
        Name      = $svc.Name
        Status    = $svc.Status
        StartType = $svc.StartType
    }
}

$report | Export-Csv -Path "C:\Logs\services.csv" -NoTypeInformation
Write-Output "Exported $($report.Count) services."

Common Errors and Fixes

  • Property order not preserved without [ordered]: In PowerShell 2.0, plain hashtables don’t preserve insertion order. In PowerShell 3.0+ with the [PSCustomObject] cast syntax, order is preserved. If you’re building the object from a plain @{} hashtable and seeing columns in the wrong order, use an ordered hashtable: [ordered]@{ First = 1; Second = 2 } before casting.
  • Adding properties after creation requires Add-Member: Once a PSCustomObject is created, you cannot simply assign a new property with dot notation. Use Add-Member: $obj | Add-Member -NotePropertyName "NewProp" -NotePropertyValue "value". For objects built in a loop, include all properties at creation time rather than adding them afterward.

Related Cmdlets / See Also

Wrapping Up

[PSCustomObject] is the building block of good PowerShell output — structured, filterable, sortable, and CSV-exportable without any extra work. As a next step, find a function in your scripts that currently returns a formatted string or a hashtable and refactor it to return a PSCustomObject — the pipeline compatibility improvement alone makes it worthwhile.

Send-Item -To