Shore Up
A clerk walking down a row of filing cabinets, opening each drawer and copying its contents onto one master list on a clipboard.
Windows

Export Installed Software Inventory From Many PCs With PowerShell

Ketan Aagja8 min read
No ratings yet

Before you run this

This guide builds a script that connects to a list of remote Windows machines over PowerShell remoting (WinRM), reads the "installed programs" data out of each machine's registry, and writes one combined CSV inventory. It is read-only: it queries registry keys and creates a CSV on your admin workstation. It does not install, uninstall, or change anything on the target machines.

That said, treat it with normal care:

  • Privileges. You need to run PowerShell as a user with local administrator rights on the target machines (a domain admin, or an account in each machine's local Administrators group), because PowerShell remoting defaults to requiring admin membership. You do not need to elevate on your own workstation for the query itself, though enabling WinRM does need elevation.
  • WinRM must be enabled and reachable on the targets — in most domains it is turned on by Group Policy, but if it is not, Invoke-Command will simply fail to connect to those hosts. That is a connectivity problem, not a data-loss one.
  • Test on one machine first. Read the script, then run it against a single test host (or your own machine) and open the CSV before you point it at hundreds of computers. The registry uninstall keys are messy, so confirm the output looks sane on one box first.
  • Nothing here is destructive, so there is no rollback to worry about on the targets. The only thing the script creates is the CSV file, which you can delete.

What I'm assuming

  • An Active Directory domain, running the script from a domain-joined admin workstation.
  • Windows PowerShell 5.1 (the version shipped with Windows 10/11 and Server 2016+). The script also works in PowerShell 7, but 5.1 is the safe baseline everywhere.
  • PowerShell remoting (WinRM) is enabled on the targets. In a domain this is commonly done via the GPO setting Allow remote server management through WinRM; if you manage a workgroup, WinRM setup and TrustedHosts is a separate job I'm not covering here.

Why the registry, and not Win32_Product

The obvious-looking approach is Get-CimInstance -ClassName Win32_Product. Don't. Querying that WMI class triggers a consistency check that can cause Windows Installer to reconfigure every MSI package, which is slow and can generate a flood of events. It also only sees MSI-installed software.

The reliable, standard method is to read the Uninstall registry keys — the same source the "Programs and Features" list uses. There are two per machine (three if you count per-user installs):

  • HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\* — 64-bit apps
  • HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* — 32-bit apps on a 64-bit OS

Per-user installs live under each user's HKCU hive and are awkward to read remotely, so this script covers the machine-wide keys, which is what most audits actually need. I'll note the per-user gap again at the end.

The script

Save this as Get-SoftwareInventory.ps1. Replace the two obvious placeholders: the path to your computer list and the output path.

# --- Settings you edit ---
# A plain text file with one computer name per line
$ComputerListPath = 'C:\path\to\computers.txt'
# Where the combined CSV is written
$OutputCsv        = 'C:\path\to\software-inventory.csv'
# --------------------------

# Read and clean the target list (skip blank lines)
$computers = Get-Content -Path $ComputerListPath |
    Where-Object { $_.Trim() -ne '' }

# This block runs ON each remote machine and returns objects
$scriptBlock = {
    $uninstallPaths = @(
        'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
        'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
    )

    Get-ItemProperty -Path $uninstallPaths -ErrorAction SilentlyContinue |
        # Keep only real applications: they have a DisplayName,
        # and skip Windows updates/patches which set SystemComponent = 1
        Where-Object { $_.DisplayName -and $_.SystemComponent -ne 1 } |
        Select-Object DisplayName, DisplayVersion, Publisher, InstallDate
}

# Fan out to all machines. PSComputerName is added automatically
# so we know which row came from which host.
$results = Invoke-Command -ComputerName $computers -ScriptBlock $scriptBlock `
    -ErrorAction SilentlyContinue

# Tidy up, sort, and write one CSV
$results |
    Select-Object PSComputerName, DisplayName, DisplayVersion, Publisher, InstallDate |
    Sort-Object PSComputerName, DisplayName |
    Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8

Write-Host "Done. Rows written to $OutputCsv"

A few notes on the non-obvious lines:

  • Get-ItemProperty accepts the two paths as an array and expands the * to every subkey, so each installed app comes back as an object with its registry values as properties.
  • The Where-Object filter drops entries with no DisplayName (leftover or malformed keys) and skips SystemComponent = 1 entries, which are typically OS components and updates rather than user-facing programs.
  • PSComputerName is a property that Invoke-Command stamps on every returned object automatically — that's how one flat CSV can still tell you which host each program is on.
  • -ErrorAction SilentlyContinue on Invoke-Command means offline or unreachable hosts are skipped rather than halting the run. See the verification section for how to catch which ones failed.

Run it:

# From an elevated OR normal PowerShell session as an account
# that is local admin on the targets
.\Get-SoftwareInventory.ps1

If your current logon isn't admin on the targets, prompt for a credential and pass it. Add a -Credential parameter to the Invoke-Command call:

$cred = Get-Credential   # enter DOMAIN\adminuser
# then add   -Credential $cred   to the Invoke-Command line

Test against one machine first

Before the full run, point it at a single host to confirm the shape of the output:

Invoke-Command -ComputerName TESTPC01 -ScriptBlock $scriptBlock |
    Select-Object PSComputerName, DisplayName, DisplayVersion |
    Format-Table -AutoSize

Replace TESTPC01 with a real hostname. If that returns a readable table of programs, the full script will work the same way at scale.

Verify it worked

The script itself doesn't tell you which machines it couldn't reach. Check coverage by comparing the hosts in the CSV against your input list:

$expected = Get-Content 'C:\path\to\computers.txt' | Where-Object { $_.Trim() }
$got      = (Import-Csv 'C:\path\to\software-inventory.csv').PSComputerName |
                Sort-Object -Unique

# Machines that returned no data (offline, WinRM off, or access denied)
Compare-Object $expected $got |
    Where-Object SideIndicator -eq '<=' |
    Select-Object -ExpandProperty InputObject

Any names listed are the ones that didn't report in. Chase those separately — usually WinRM not running, a firewall blocking TCP 5985, or your account lacking admin rights there. You can test one with:

Test-WSMan -ComputerName PROBLEMPC01

Open the CSV in Excel or with Import-Csv and confirm you see the expected columns and a plausible number of rows per machine.

Undo

There is nothing to roll back on the target machines — the script never wrote to them. The only artifact is the CSV, which you remove if you no longer need it:

Remove-Item 'C:\path\to\software-inventory.csv'

Known limitation: per-user installs

This inventory covers machine-wide (HKLM) installs, which is what most software audits need. Applications installed per-user (some browsers, Teams, various user-scoped MSIX/Store apps) live under each user's HKCU/HKU hive and won't appear here. Reading those remotely means loading each loaded user hive under HKEY_USERS, which is a meaningfully more complex job. If you need that data, treat it as a separate exercise and confirm the exact hive paths against Microsoft's documentation on the registry Uninstall keys before you rely on it.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.