Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

List Installed Software on Windows with PowerShell

A quick, safe software inventory from the registry, local or remote, that returns objects you can filter and export instead of a wall of colored text.

AT A GLANCEGet-InstalledSoftware.ps1
What it does
Reads the 64-bit, 32-bit and (optionally) per-user Uninstall registry keys and returns each installed application's name, version, publisher, install date and architecture, for one computer or many.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • No modules
  • PowerShell remoting (WinRM) enabled on remote computers
Permissions
Any user can read the local Uninstall keys. For remote computers you need rights to connect with PowerShell remoting, which usually means local admin.
Runs on
Windows 10/11, Windows Server 2016+
Tested
Parse-checked and dry-run with mocked cmdlets in PowerShell 7.4

Part 5 of the thread PowerShell craft

Somebody asks "which machines still have the old Java on them?" and you realize there's no quick answer. Maybe there's an inventory tool, but its last scan was Tuesday and the machine in question was off on Tuesday.

Windows keeps its own list. Every well-behaved installer writes an entry under the Uninstall registry keys, and that's exactly what Programs and Features (and Settings > Apps) reads to build its list. Read those keys yourself and you've got the same inventory in a second or two, with no agent and nothing to install.

The old version of this script printed a colorful list to the screen, which looked nice and was impossible to do anything with. This one returns objects, works against remote machines, hides the same system components Windows hides, and has a name filter so you can go straight to the thing you're hunting for.

Get-InstalledSoftware.ps1Download
<#
.SYNOPSIS
    Lists installed software on one or more Windows computers, straight from the registry.
.DESCRIPTION
    Reads the Uninstall keys (64-bit, 32-bit, and optionally the current user's) and returns one
    object per application with name, version, publisher, install date and architecture. Works
    locally, or remotely over PowerShell remoting. Doesn't touch Win32_Product.
.PARAMETER ComputerName
    One or more computers. Defaults to this one. Remote computers need PowerShell remoting enabled.
.PARAMETER Name
    Wildcard filter on the application name, for example '*Chrome*'. Default: everything.
.PARAMETER IncludeUser
    Also read HKEY_CURRENT_USER, for per-user installs. Only covers the account running the query.
.PARAMETER IncludeSystemComponents
    Include entries flagged as system components or updates, which Programs and Features hides.
.PARAMETER Credential
    Credentials for the remote connection.
.EXAMPLE
    .\Get-InstalledSoftware.ps1 -Name '*Java*'
.EXAMPLE
    .\Get-InstalledSoftware.ps1 -ComputerName PC-0142, PC-0187 | Export-Csv .\software.csv -NoTypeInformation
#>
[CmdletBinding()]
param(
    [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [Alias('CN', 'DNSHostName')]
    [string[]]$ComputerName = $env:COMPUTERNAME,
    [string]$Name = '*',
    [switch]$IncludeUser,
    [switch]$IncludeSystemComponents,
    [pscredential]$Credential
)

begin {
    # This block runs on the target machine, so it can't use anything from outside it except its arguments.
    $collector = {
        param([string]$NameFilter, [bool]$User, [bool]$System)

        $keys = @(
            @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'; Arch = '64-bit' }
            @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'; Arch = '32-bit' }
        )
        if (-not [Environment]::Is64BitOperatingSystem) { $keys[0].Arch = '32-bit'; $keys = @($keys[0]) }
        if ($User) { $keys += @{ Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'; Arch = 'User' } }

        foreach ($key in $keys) {
            Get-ItemProperty -Path $key.Path -ErrorAction SilentlyContinue |
                Where-Object { $_.DisplayName -and $_.DisplayName -like $NameFilter } |
                Where-Object { $System -or (-not $_.SystemComponent -and -not $_.ParentKeyName) } |
                ForEach-Object {
                    $installed = $null
                    if ($_.InstallDate -match '^\d{8}$') {
                        $installed = [datetime]::ParseExact($_.InstallDate, 'yyyyMMdd', $null)
                    }
                    [pscustomobject]@{
                        ComputerName    = $env:COMPUTERNAME
                        Name            = $_.DisplayName.Trim()
                        Version         = $_.DisplayVersion
                        Publisher       = $_.Publisher
                        InstallDate     = $installed
                        Architecture    = $key.Arch
                        UninstallString = $_.UninstallString
                    }
                }
        }
    }
}

process {
    foreach ($computer in $ComputerName) {
        $isLocal = $computer -in @('.', 'localhost', $env:COMPUTERNAME)
        try {
            $argsList = @($Name, $IncludeUser.IsPresent, $IncludeSystemComponents.IsPresent)
            $apps = if ($isLocal) {
                & $collector @argsList
            }
            else {
                $remote = @{ ComputerName = $computer; ScriptBlock = $collector; ArgumentList = $argsList; ErrorAction = 'Stop' }
                if ($Credential) { $remote.Credential = $Credential }
                Invoke-Command @remote | Select-Object * -ExcludeProperty PSComputerName, RunspaceId, PSShowComputerName
            }
            Write-Verbose "$computer : $(@($apps).Count) application(s)"
            $apps | Sort-Object Name, Architecture
        }
        catch {
            Write-Warning "$computer : $($_.Exception.Message)"
        }
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-ComputerNamestring[]$env:COMPUTERNAMEOne or more computers. Takes pipeline input, including objects from Get-ADComputer.
-Namestring*Wildcard filter on the application name, like '*Chrome*'.
-IncludeUserswitch—Also read HKEY_CURRENT_USER for per-user installs. Only covers the account doing the reading.
-IncludeSystemComponentsswitch—Show entries that Programs and Features hides, like runtimes flagged as system components and child updates.
-Credentialpscredential—Credentials for remote computers.

Run it

Everything on this machine.

.\Get-InstalledSoftware.ps1

Is Java anywhere on these three machines, and which version?

.\Get-InstalledSoftware.ps1 -ComputerName PC-0142, PC-0187, PC-0203 -Name '*Java*' | Select-Object ComputerName, Name, Version

A software list for every computer in an OU, saved to CSV.

Get-ADComputer -Filter * -SearchBase 'OU=Workstations,DC=contoso,DC=com' | .\Get-InstalledSoftware.ps1 | Export-Csv .\software.csv -NoTypeInformation

What got installed in the last 30 days?

.\Get-InstalledSoftware.ps1 | Where-Object InstallDate -gt (Get-Date).AddDays(-30)

What you'll see

Example outputvalues are illustrative
ComputerName Name                               Version        Publisher             InstallDate Architecture
------------ ----                               -------        ---------             ----------- ------------
PC-0142      7-Zip 24.08 (x64)                  24.08          Igor Pavlov           3/14/2026   64-bit
PC-0142      Google Chrome                      129.0.6668.90  Google LLC            9/24/2026   64-bit
PC-0142      Microsoft Visual C++ 2015-2022 ... 14.40.33810.0  Microsoft Corporation 1/9/2026    32-bit
PC-0142      Notepad++ (64-bit x64)             8.7            Notepad++ Team        5/2/2026    64-bit
PC-0142      Zoom Workplace                     6.2.3          Zoom Communications               User

How it works

  1. Pick the right keys. On 64-bit Windows, 64-bit apps register under HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall and 32-bit apps under the WOW6432Node copy of it. The script reads both and tags each result with its architecture. -IncludeUser adds the HKCU version.
  2. Filter like Programs and Features does. Entries without a display name are skipped. So are ones marked SystemComponent or with a ParentKeyName (usually child updates), unless you ask for them with -IncludeSystemComponents.
  3. Run it where the data is. The collector is a single script block. Locally it just runs; for remote computers it goes through Invoke-Command, so the registry is read on the target and only the results come back.
  4. One bad machine doesn't stop the run. Each computer has its own try. An offline machine gets a warning and the script moves on to the next one.

Take it further

  • Fan out faster. The script visits one computer at a time, which keeps the error handling simple. For hundreds of machines, lift the script block out and call Invoke-Command once with the whole list; it works through them 32 at a time by default.
  • Compare two machines. Compare-Object (.\Get-InstalledSoftware.ps1 -ComputerName PC-0142) (.\Get-InstalledSoftware.ps1 -ComputerName PC-0187) -Property Name shows what one has that the other doesn't. Great for "it works on her machine."
  • Pull the uninstall command. The UninstallString is in every object. It's what Windows runs when you click Uninstall, which makes it a good starting point for a cleanup script.

Things that'll trip you up

  • Don't use Win32_Product for this. It's the first thing people reach for, and it's slow, only sees MSI installs, and makes Windows Installer run a consistency check on every MSI package, which can trigger repairs. The registry is faster and doesn't change anything.
  • Per-user installs are hit and miss. Apps like Zoom or VS Code can install into a single user's profile and only register in that user's HKCU. -IncludeUser reads the hive of whoever's running the script, which remotely is you, not the person who uses the machine.
  • Store apps aren't here. Microsoft Store and other MSIX/AppX packages don't use the Uninstall keys. Get-AppxPackage -AllUsers covers those.
  • InstallDate is often missing. Plenty of installers never write it, and some write it in the wrong format. The script only converts proper yyyyMMdd values and leaves the rest blank rather than guessing.