Skip to main content
PowerShell · lesson 10 of 12
PowerShell advanced Lesson 10 of 12

A System Inventory Report: Putting the Pipeline to Work

Build a real automation script from Get-CimInstance, a function with error handling, and JSON/CSV export — and watch the same nested-object flattening from the CSV lesson show up again, on data that actually matters this time.

By SkillByExample · Published

powershell cim automation reporting capstone

Every idea from this track lands in one script: an object pipeline, a function with error handling, and the two export formats that don’t treat structured data the same way.

Querying real system data

Get-CimInstance -ClassName Win32_OperatingSystem |
    Select-Object Caption, Version, OSArchitecture, @{Name="FreeMemoryMB";Expression={[math]::Round($_.FreePhysicalMemory/1KB,0)}}
Caption                                   Version    OSArchitecture FreeMemoryMB
-------                                   -------    -------------- ------------
Microsoft Windows 11 Home Single Language 10.0.26200 64-bit              2279.00
Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
    Select-Object DeviceID, @{Name="SizeGB";Expression={[math]::Round($_.Size/1GB,1)}}, @{Name="FreeGB";Expression={[math]::Round($_.FreeSpace/1GB,1)}}
DeviceID SizeGB FreeGB
-------- ------ ------
C:       460.00 122.40

Get-CimInstance is the modern cmdlet for this — it talks the standard WS-Management protocol rather than the older, Windows-only WMI plumbing behind Get-WmiObject. -Filter "DriveType=3" narrows to fixed local disks at the query itself, before any data comes back.

A function that assembles a report, with real error handling

function Get-SystemReport {
    [CmdletBinding()]
    param()

    try {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
        $disk = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction Stop |
            Select-Object DeviceID, @{Name="SizeGB";Expression={[math]::Round($_.Size/1GB,1)}}, @{Name="FreeGB";Expression={[math]::Round($_.FreeSpace/1GB,1)}}
        $topProcess = Get-Process | Sort-Object WorkingSet64 -Descending |
            Select-Object -First 1 -Property ProcessName, @{Name="MemoryMB";Expression={[math]::Round($_.WorkingSet64/1MB,1)}}

        [pscustomobject]@{
            GeneratedAt  = Get-Date
            OSCaption    = $os.Caption
            FreeMemoryMB = [math]::Round($os.FreePhysicalMemory/1KB,0)
            Disks        = $disk
            TopProcess   = "$($topProcess.ProcessName) ($($topProcess.MemoryMB) MB)"
        }
    } catch {
        Write-Error "Failed to build system report: $($_.Exception.Message)"
    }
}

Get-SystemReport
GeneratedAt  : 13-09-2026 17:02:33
OSCaption    : Microsoft Windows 11 Home Single Language
FreeMemoryMB : 2225
Disks        : @{DeviceID=C:; SizeGB=460; FreeGB=122.4}
TopProcess   : node (882.9 MB)

-ErrorAction Stop on both CIM calls is what makes the try/catch here actually useful — without it, per the error-handling lesson earlier in this track, a failed query would just print a warning and let the function limp forward with a half-built report.

The same flattening problem, on data that matters

$report = Get-SystemReport
$report | ConvertTo-Json
{
  "GeneratedAt": "2026-09-13T17:02:53.4884045+05:30",
  "OSCaption": "Microsoft Windows 11 Home Single Language",
  "FreeMemoryMB": 2209.0,
  "Disks": {
    "DeviceID": "C:",
    "SizeGB": 460.0,
    "FreeGB": 122.4
  }
}
$report | Export-Csv -Path "$env:TEMP\sysreport.csv" -NoTypeInformation
Get-Content "$env:TEMP\sysreport.csv"
"GeneratedAt","OSCaption","FreeMemoryMB","Disks"
"13-09-2026 17:02:53","Microsoft Windows 11 Home Single Language","2209","@{DeviceID=C:; SizeGB=460; FreeGB=122.4}"

Same lesson as the CSV/JSON track earlier: JSON keeps Disks as a real nested object; CSV renders it as a single flattened text blob. On a report headed for a dashboard or another program, that difference decides which format to reach for — and it’s exactly why this script exports both rather than picking one.

Confirming the error handling actually works

function Get-BadReport {
    try {
        Get-CimInstance -ClassName Win32_NotARealClass -ErrorAction Stop
    } catch {
        Write-Error "Failed to build system report: $($_.Exception.Message)"
    }
}

Get-BadReport
Get-BadReport: Failed to build system report: Invalid class

A typo’d or nonexistent CIM class name is a real, easy-to-hit failure — confirming it produces a clear, one-line message instead of a raw stack trace is worth doing before this ever runs unattended on a schedule.

Practice

1. Build a lightweight sample function and take two snapshots a second apart.
function Get-QuickSample {
    [pscustomobject]@{
        Time = Get-Date
        FreeMemoryMB = [math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1KB,0)
    }
}

$samples = @()
$samples += Get-QuickSample
Start-Sleep -Seconds 1
$samples += Get-QuickSample
$samples | Export-Csv -Path "$env:TEMP\samples.csv" -NoTypeInformation
Import-Csv "$env:TEMP\samples.csv"
Time                FreeMemoryMB
----                ------------
13-09-2026 17:03:08 4122
13-09-2026 17:03:09 4140

This is the shape a real monitoring script takes: append each run’s sample to the same file instead of overwriting it, and a single snapshot becomes a trend.

2. Trigger the bad-class error again and confirm it doesn't throw an unhandled exception.
Get-BadReport
"script kept running after the error"
Get-BadReport: Failed to build system report: Invalid class
script kept running after the error

The catch block turns a failure into a message and lets the calling code continue — a production script would log that message somewhere durable instead of just printing it.

3. Add process count (grouped by name) to the report, reusing Group-Object from earlier in this track.
$topByCount = Get-Process | Group-Object ProcessName | Sort-Object Count -Descending | Select-Object -First 1
"$($topByCount.Name) has $($topByCount.Count) running instances"
svchost has 98 running instances

Every cmdlet in this final script — Get-CimInstance, Sort-Object, Group-Object, Select-Object, Export-Csv, ConvertTo-Json — is one this track already covered on its own; the only thing new here is combining them into something that answers a real question.

That closes the track. The thread through all ten lessons: PowerShell trusts the type of whatever came down the pipeline — which is what makes the object model so capable, and exactly what breaks the moment a value is a string, a null, or a nested structure pretending to be something flatter than it is.

Frequently Asked Questions

Why Get-CimInstance instead of Get-WmiObject for system information?
Get-CimInstance is the actively maintained cmdlet — it works over the standard WS-Management protocol, which is what modern remoting and cross-platform tooling build on, where the older WMI-specific plumbing behind Get-WmiObject is Windows-only legacy infrastructure. New scripts should default to Get-CimInstance; Get-WmiObject mainly still shows up in code written before Get-CimInstance existed.
Why does Win32_LogicalDisk need -Filter "DriveType=3"?
DriveType 3 means a fixed local disk — without the filter, the same query also returns removable drives, network shares, and CD/DVD drives (types 2, 4, and 5), most of which don't have a meaningful Size or FreeSpace to report on. Filtering at the CIM query is also faster than pulling everything and filtering afterward with Where-Object.
Why did the nested Disks property flatten in the CSV export, same as the earlier CSV lesson?
The same reason as before: CSV has no way to represent a nested object, so Export-Csv falls back to a text rendering of it — here, PowerShell's default @{Key=Value; ...} format instead of System.Object[], because the underlying type is different, but the underlying problem is identical. Anything with real internal structure needs JSON, or its own flattened columns, to survive a CSV round trip.
Should a script like this always wrap CIM calls in try/catch?
For anything meant to run unattended, yes — a CIM query can fail for reasons entirely outside the script's control (the WMI service isn't running, a typo'd class name, insufficient permissions), and per the error-handling lesson, none of those are terminating by default. -ErrorAction Stop plus a catch turns a query failure into a clear, loggable message instead of a silently incomplete report.
What would make this a real production monitoring script instead of a one-off?
Running it on a schedule (Windows Task Scheduler, or a CI runner's own cron equivalent) and appending each run's report to a growing CSV or a real time-series store, rather than overwriting one file — that's what turns a single snapshot into a trend a human or an alert rule can act on.