Software inventory with built-in tools: winget, PowerShell, dpkg — and the one column none of them can fill

A query against Win32_Product returns only part of what is installed, and initiates a consistency check of the installed packages while doing it — verifying and repairing the install. Microsoft documents that in the class reference itself. For a Windows software inventory there are four usable routes; that is not one of them.

Route 1: the registry, which is what Programs and Features reads

Add or Remove Programs has no database. It reads the uninstall keys, and there are three of them: the machine-wide 64-bit key, the 32-bit view WOW64 creates automatically under Wow6432Node, and the per-user key for installs that apply to one profile only.

$paths = @(
  'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
  'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
  'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
  Where-Object DisplayName |
  Select-Object DisplayName, DisplayVersion, Publisher, InstallDate |
  Sort-Object DisplayName

Output: one PowerShell object per uninstall entry, carrying exactly the values Windows Installer writes there — DisplayName, DisplayVersion, Publisher and the rest. Two traps. DisplayVersion is a free-form string, so sorting it like a version number returns nonsense. And InstallDate is not the install date: Microsoft documents it as the last time the product received service, replaced whenever a patch is applied or removed. The HKCU branch only ever covers the account the script runs under — as a scheduled task under SYSTEM it does not cover the profiles of the people using the machine.

Route 2: winget, the list that also looks outward

winget list shows apps installed through the Windows Package Manager as well as apps installed by other means, and adds a column the registry does not have: whether an update is available and which source it would come from. If nothing has an update, that column is not shown at all.

winget list --accept-source-agreements
winget list --upgrade-available
winget export -o inventory.json --include-versions

Output: a table on screen, or JSON from winget export with one package identifier per entry. Microsoft documents the catch: export attempts every installed app, and where it cannot match one to a package from a configured source it shows a warning. The JSON file only holds packages that have an identifier, so what cannot be matched is not in it. Matching depends on manifest metadata and on what the installer left behind in Add or Remove Programs. winget is a package manager with an inventory side effect, not an inventory tool.

Route 3: Store and MSIX apps live somewhere else

Windows manages packaged apps separately from the uninstall keys — as a rule they are not listed there. Get-AppxPackage lists the packages in a user profile; -AllUsers covers every account on the machine and requires administrator rights.

Get-AppxPackage -AllUsers | Select-Object Name, Version, PackageFullName

Route 4: on Linux, dpkg and rpm are the honest sources

Both accept a format string — dpkg-query(1) and rpm(8) — so you get a machine-readable file instead of a table you have to take apart again.

# Debian, Ubuntu — the state filter is not optional
dpkg-query -W -f='${db:Status-Abbrev}\t${binary:Package}\t${Version}\n' | grep '^ii'

# RHEL, Rocky, SUSE
rpm -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\n'

Output: tab-separated lines, one package each. The filter is not cosmetic — dpkg also tracks packages that are no longer installed: status rc means removed with config files still on disk. Without the ii filter those sit in your inventory as though the software were running.

The expensive trap: Win32_Product

Microsoft puts the warning in its own class documentation: Win32_Product is not query optimised. Any query forces WMI to enumerate all installed products through the MSI provider and walk the list sequentially — and that process also initiates a consistency check of the installed packages, verifying and repairing the install. Under an account without sufficient rights it can delay application launch and produce an event 11708 reporting an installation failure.

Coverage is the second problem. Win32_Product only knows what Windows Installer installed; anything that shipped as an Inno Setup, NSIS or bespoke EXE installer is simply absent. You pay for a repair pass across the estate and get an incomplete list for it. Route 1 reads the same values out of the registry with no side effects at all.

What each route systematically misses

RouteDoes not see
Registryprograms unpacked from a ZIP, installs in other profiles, packaged Store apps
wingetanything with no match in a configured source
Get-AppxPackageevery classic Win32 program
Win32_Producteverything Windows Installer did not install
dpkg, rpmSnap, Flatpak, pip, npm, tarballs under /opt, container images
all of themdevices with no shell to run them in: firewalls, switches, hypervisors, NAS, printers

The last row decides how much the exercise is worth. The devices you cannot query are often the ones facing the internet.

Merging it: one CSV, five columns

Host, product, version, source, date. The source column looks like bookkeeping and is worth the extra work — six months later it is the only thing that explains why an entry is missing.

Get-ItemProperty $paths -ErrorAction SilentlyContinue |
  Where-Object DisplayName |
  Select-Object @{n='Host';e={$env:COMPUTERNAME}},
                @{n='Product';e={$_.DisplayName}},
                @{n='Version';e={$_.DisplayVersion}},
                @{n='Source';e={'registry'}},
                @{n='Date';e={Get-Date -Format 'yyyy-MM-dd'}} |
  Export-Csv -Path .\inventory.csv -NoTypeInformation -Encoding UTF8

On the Linux side, pipe dpkg-query through awk into the same five fields and append to the same file. That is the whole trick: the value is not in the command, it is in every host writing the same shape.

The honest limitation

Names are not standardised, and merging fails quietly on that. Mozilla Firefox (x64 en-GB) from the registry, Mozilla.Firefox from winget and firefox-esrfrom dpkg are the same product under three different keys; join on the display name and the duplicates look like extra coverage. Doing it properly needs a mapping table you maintain yourself, and nobody maintains one on the side. Beyond that, a CSV written on Tuesday describes Tuesday — built-in tools answer “what is on this machine”, never “is it still current”.

The column that is not on any of your machines

Four routes, five columns — and the sixth stays empty: the current version at the vendor. No command on your own machine can fill it, because the answer is not stored there.

Two routes seem to contradict that. winget list shows an update column, and apt list --upgradeable does the same. Neither asks the machine; both ask a catalogue outside it — the configured winget source, the distribution’s package repositories. Where that catalogue does not know the product, the column is empty again, and that is exactly the part of the estate that matters: the backup tool, the firewall firmware, the Java runtime somebody unpacked into C:\tools, the Confluence on a VM.

For that part you need a catalogue that is not tied to a package manager. patchletter keeps one: per tracked product, the latest version it has seen, with the vendor’s date. For winget itself that is 1.29.280, dated 24/06/2026 (as of 19/08/2026); Chocolatey sits at 2.7.3, dated 10/06/2026. Tick the products from your CSV in the catalogue and you get an email as soon as a new version of one of them appears — free, no account, one-click unsubscribe. The column your inventory cannot fill then arrives next to it instead of inside it.

Commands and behaviour checked against vendor documentation, retrieved 19/08/2026: winget list and export, the Win32_Product class, the uninstall registry key, the WOW6432Node redirection and Get-AppxPackage on Microsoft Learn; dpkg-query(1) and apt(8) in the Debian manual pages; rpm(8) on man7.org. The two patchletter version figures are from our own dataset as of 19/08/2026 and are deliberately left dated; the live values are on the product pages.