Reading the winget Source Index: source2.msix and Its SQLite Package Table
Windows Package Manager publishes its package index as a downloadable artifact, and reading it directly is more reliable than shelling out to winget search when you need name-to-ID resolution at server scale. This post documents the artifact's location and format, the SQLite table that matters, the name-normalization needed to match real-world display names against it, and the refresh workflow ET Ducky uses to keep a local copy current. Everything here was learned building the software-catalog feature that maps installed-application inventory to winget package IDs.
Where the index lives
The winget client itself fetches its source index from a public CDN. Two artifacts matter:
https://cdn.winget.microsoft.com/cache/source2.msix https://cdn.winget.microsoft.com/cache/source.msix
source2.msix is the current-generation index; source.msix is the older artifact that remains published. A robust fetcher tries source2.msix first and falls back — the ET Ducky harvester does exactly that, with a plain User-Agent and a generous timeout, because the artifact is tens of megabytes and the CDN occasionally serves slowly. No authentication is required; this is the same download every winget client performs.
What is inside the msix
An .msix is a ZIP archive. Inside it, alongside the package manifest and signature files, is a SQLite database (the file ends in .db; locating it by extension rather than by hard-coded path survives layout changes between index revisions). That database is the entire searchable index the winget client uses.
The table that matters for name resolution is packages. The columns worth reading:
SELECT id, name, moniker, latest_version FROM packages
id— the winget package identifier (Publisher.Productform, e.g.Mozilla.Firefox).name— the display name (Firefox).moniker— the short alias where one exists (firefox); stored as the literal string"None"in some revisions rather than NULL, which a reader must normalize.latest_version— the current version string in the index.
Earlier index generations normalized data across more tables (separate name/ID/moniker tables joined by rowid maps); the flat packages table in the current index makes a single SELECT sufficient. If you target both artifacts, verify the table layout per file rather than assuming.
The matching problem
The reason to read the index at all is matching: you have display names from installed-software inventory ("Notepad++ (64-bit x64)", "Microsoft Visual C++ 2015-2022 Redistributable (x64)") and you want winget IDs. Exact string equality fails constantly — inventory names carry architecture suffixes, registered trademarks, version fragments, and inconsistent spacing that the index's clean display names do not.
The approach that works is key folding: strip every character that is not a lowercase letter or digit, and match on the folded key.
NORM = re.compile(r'[^a-z0-9]+')
def norm(s):
return NORM.sub('', (s or '').lower())
Folding both sides collapses "Notepad++" and "notepadplusplus"-adjacent variants toward each other and makes punctuation, casing, and spacing differences irrelevant. The harvester computes folded keys for both name and moniker at ingest time and stores them as indexed columns, so lookups at match time are a single indexed equality rather than a scan with normalization on the fly. Folded-key collisions exist (two products whose names differ only in punctuation) but in practice are rare enough to resolve by preferring the entry whose unfolded name is closest.
The refresh workflow
The index changes as packages are added and updated, so a local copy needs scheduled refresh. The ET Ducky harvester runs weekly from Task Scheduler and is written to be safe to run at any time: it downloads the msix to a temp directory, extracts and reads the SQLite packages table, computes the folded keys, and then atomically replaces the contents of the serving table (Postgres, in our case) in one transaction. Atomic replacement rather than incremental merge keeps the logic trivial and makes a partially-failed refresh impossible — either the new snapshot lands complete or the old one keeps serving.
Sizing expectations: the packages table carries thousands of rows, trivially small once extracted; the cost is the msix download, not the data. There is no delta mechanism — every refresh is a full snapshot, which is also why weekly is a reasonable cadence and hourly would be wasteful.
What this does not cover
The index maps names to IDs and latest versions; it does not carry installer URLs, hashes, or switches. Those live in the per-package manifests in the winget-pkgs repository, keyed by the ID you just resolved — a second lookup if you need install detail. Packages outside the community repository (private sources, vendor-direct installers) are absent by definition; in ET Ducky, apps that no package manager tracks land in a separate per-organization software catalog with vendor-script installs, and the winget index answers only the "is this managed, and by what ID" question. Note also that the msix's signature covers the artifact you downloaded; if your threat model includes the CDN path, verify the msix signature after download rather than trusting transport alone.
Summary
Download source2.msix (fall back to source.msix), unzip, find the .db, read id, name, moniker, latest_version from packages, normalize "None" monikers to NULL, fold match keys to [a-z0-9], and replace your serving table atomically on a weekly schedule. The whole harvester is under a hundred lines of Python, and it removes both the fragility of scraping CLI output and the per-query latency of shelling out to winget on demand.