Skip to content

Creating Custom Objects

Note

Learn how to build your own PowerShell objects from scratch using PSCustomObject, Add-Member, and other techniques for structured data.

Overview

PowerShell lets you create your own objects — structured containers with named properties and values — rather than relying only on objects returned by cmdlets. Custom objects are essential for organizing data, building reports, and passing structured results through the pipeline.

[PSCustomObject] with a hashtable is the modern, preferred syntax for creating custom objects. It's fast, readable, and produces a fully-formed object immediately.

Basic Syntax

$person = [PSCustomObject]@{
    Name       = "Alice"
    Age        = 30
    Department = "IT"
}

$person.Name           # Alice
$person.Age            # 30
$person.Department     # IT

Why PSCustomObject vs. Just a Hashtable?

# Hashtable — properties are unordered, no dot notation by default
$hash = @{ Name = "Alice"; Age = 30 }
$hash.Name         # Works, but...
$hash | Get-Member  # Shows Hashtable, not custom object type

# PSCustomObject — looks and behaves like a real object
$obj = [PSCustomObject]@{ Name = "Alice"; Age = 30 }
$obj | Get-Member   # Shows NoteProperty members
$obj | Format-Table # Renders as a table with column headers

PSCustomObject works seamlessly with Format-Table, Export-Csv, ConvertTo-Json, and the pipeline.

Ordered Properties

Wrap your hashtable in [ordered] to preserve the property order as declared:

$server = [PSCustomObject][ordered]@{
    Hostname  = "WEB-01"
    IPAddress = "192.168.1.10"
    OS        = "Windows Server 2022"
    Status    = "Online"
}

$server | Format-Table  # Columns appear in declared order

Always use [ordered] for reports

Without [ordered], hashtable keys may appear in unpredictable order in your output. Use [ordered] whenever column order matters.

Building Collections of Custom Objects

Custom objects shine when you build lists (arrays) for pipeline use.

Building with ForEach

$computers = "PC-01", "PC-02", "PC-03"

$results = $computers | ForEach-Object {
    [PSCustomObject]@{
        Computer  = $_
        Online    = Test-Connection -ComputerName $_ -Count 1 -Quiet
        CheckTime = Get-Date -Format "yyyy-MM-dd HH:mm"
    }
}

$results | Format-Table -AutoSize

Building with a Loop

$report = @()   # Empty array to collect results

foreach ($user in Get-LocalUser) {
    $report += [PSCustomObject]@{
        Name      = $user.Name
        Enabled   = $user.Enabled
        LastLogon = $user.LastLogon
    }
}

$report | Sort-Object Name | Format-Table -AutoSize

Use [System.Collections.Generic.List] for large collections

Adding to an array with += inside a loop is slow for large sets because it copies the array every iteration. For better performance:

$report = [System.Collections.Generic.List[PSCustomObject]]::new()

foreach ($user in Get-LocalUser) {
    $report.Add([PSCustomObject]@{
        Name    = $user.Name
        Enabled = $user.Enabled
    })
}

Adding Properties Dynamically with Add-Member

Add-Member lets you attach new properties or methods to an existing object after it's been created — including objects returned by cmdlets.

NoteProperty (Simple Value)

$obj = [PSCustomObject]@{ Name = "Alice" }

# Add a new property
$obj | Add-Member -MemberType NoteProperty -Name "Role" -Value "Admin"

$obj.Role   # Admin

Add-Member in a Pipeline

# Enrich objects coming from a cmdlet
Get-Process | Select-Object -First 5 Name, CPU |
    ForEach-Object {
        $_ | Add-Member -MemberType NoteProperty -Name "Flagged" -Value ($_.CPU -gt 10) -PassThru
    }

-PassThru returns the modified object so it continues down the pipeline.

ScriptProperty (Calculated Property)

A ScriptProperty computes its value from a script block each time it's accessed:

$file = Get-Item "C:\logs\app.log"

$file | Add-Member -MemberType ScriptProperty -Name "SizeMB" -Value {
    [math]::Round($this.Length / 1MB, 2)
}

$file.SizeMB   # Returns calculated value

Note

Inside ScriptProperty blocks, use $this to refer to the object itself.

ScriptMethod (Custom Action)

$server = [PSCustomObject]@{
    Hostname = "WEB-01"
    IP       = "192.168.1.10"
}

$server | Add-Member -MemberType ScriptMethod -Name "Ping" -Value {
    Test-Connection -ComputerName $this.IP -Count 1 -Quiet
}

$server.Ping()   # Returns True or False

New-Object (Legacy Syntax)

New-Object PSObject is the older way to create custom objects. You'll see it in older scripts — it produces the same result as [PSCustomObject].

# Old way
$obj = New-Object PSObject -Property @{
    Name = "Alice"
    Age  = 30
}

# Modern equivalent
$obj = [PSCustomObject]@{
    Name = "Alice"
    Age  = 30
}

Prefer [PSCustomObject] in new code — it's faster and more concise.

New-Object for .NET Types

New-Object is still useful for creating instances of .NET classes:

# Create a .NET object (not a PSCustomObject)
$timer  = New-Object System.Timers.Timer
$client = New-Object System.Net.WebClient
$sb     = New-Object System.Text.StringBuilder

$sb.Append("Hello")
$sb.Append(", World")
$sb.ToString()   # Hello, World

Practical Examples

Example: System Inventory Report

Scenario: Collect basic system info into a structured object

function Get-SystemInfo {
    [PSCustomObject]@{
        Hostname    = $env:COMPUTERNAME
        OSVersion   = (Get-CimInstance Win32_OperatingSystem).Caption
        TotalRAM_GB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)
        CPUName     = (Get-CimInstance Win32_Processor).Name
        Uptime      = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
    }
}

Get-SystemInfo | Format-List

Example: Disk Usage Summary

Scenario: Build a report of drive usage across all drives

$diskReport = Get-PSDrive -PSProvider FileSystem | ForEach-Object {
    [PSCustomObject]@{
        Drive      = $_.Name
        UsedGB     = [math]::Round($_.Used / 1GB, 2)
        FreeGB     = [math]::Round($_.Free / 1GB, 2)
        TotalGB    = [math]::Round(($_.Used + $_.Free) / 1GB, 2)
        UsedPct    = if (($_.Used + $_.Free) -gt 0) {
                         "{0:P0}" -f ($_.Used / ($_.Used + $_.Free))
                     } else { "N/A" }
    }
}

$diskReport | Format-Table -AutoSize

Example: CSV Import with Enrichment

Scenario: Import a CSV, then add a calculated field to each row

$data = Import-Csv "employees.csv"

$enriched = $data | ForEach-Object {
    $obj = [PSCustomObject]@{
        Name       = $_.Name
        Department = $_.Department
        Salary     = [decimal]$_.Salary
        Bonus      = [math]::Round([decimal]$_.Salary * 0.10, 2)
        TotalComp  = [math]::Round([decimal]$_.Salary * 1.10, 2)
    }
    $obj
}

$enriched | Export-Csv "employees-with-bonus.csv" -NoTypeInformation

Example: Function Returning a Custom Object

Scenario: Write a function that returns structured data instead of plain text

function Test-ServiceHealth {
    param([string[]]$ServiceNames)

    foreach ($name in $ServiceNames) {
        $svc = Get-Service -Name $name -ErrorAction SilentlyContinue

        [PSCustomObject]@{
            Service   = $name
            Status    = if ($svc) { $svc.Status } else { "Not Found" }
            StartType = if ($svc) { $svc.StartType } else { "N/A" }
            Healthy   = ($svc -and $svc.Status -eq "Running")
        }
    }
}

Test-ServiceHealth -ServiceNames "wuauserv", "spooler", "FakeService" |
    Format-Table -AutoSize

Exporting Custom Objects

Custom objects work directly with export cmdlets:

$data = Get-Process | Select-Object -First 10 | ForEach-Object {
    [PSCustomObject]@{
        Name      = $_.Name
        CPU       = [math]::Round($_.CPU, 2)
        MemoryMB  = [math]::Round($_.WS / 1MB, 2)
    }
}

# Export to CSV
$data | Export-Csv "processes.csv" -NoTypeInformation

# Export to JSON
$data | ConvertTo-Json | Out-File "processes.json"

# Display as table
$data | Format-Table -AutoSize

# Display as list
$data | Format-List