|
| 1 | +--- |
| 2 | +title: "Metadata requests no longer tracked in PyPI download counts" |
| 3 | +description: PyPI stopped counting requests that were never artifact downloads. Historical counts were inflated, and comparisons across 2026-08-24 will not line up. |
| 4 | +authors: |
| 5 | + - miketheman |
| 6 | +date: 2026-08-31 |
| 7 | +tags: |
| 8 | + - infrastructure |
| 9 | + - transparency |
| 10 | +meta: |
| 11 | + - name: fediverse:creator |
| 12 | + content: "@miketheman@hachyderm.io" |
| 13 | +--- |
| 14 | + |
| 15 | +On 2026-08-24 I [shipped a change to the configuration that emits PyPI's download logs](https://github.com/pypi/infra/pull/242) |
| 16 | +so that only requests for actual distribution artifacts are counted. |
| 17 | +A request has to end in `.whl`, `.tar.gz`, or `.zip` to produce a download record. |
| 18 | + |
| 19 | +The counts are more accurate now, |
| 20 | +and existing systems based on [PyPI's BigQuery dataset](https://docs.pypi.org/api/bigquery/), |
| 21 | +like [pypistats.org](https://pypistats.org/) |
| 22 | +will display a different shape going forward. |
| 23 | + |
| 24 | +<!-- more --> |
| 25 | + |
| 26 | +If you maintain something that reads these numbers from the public dataset, |
| 27 | +the discontinuity at 2026-08-24 is expected and permanent. |
| 28 | + |
| 29 | +This effort would not be possible without the continued support from [Alpha-Omega](https://alpha-omega.dev/). |
| 30 | + |
| 31 | +## Background |
| 32 | + |
| 33 | +The logs are generated by Fastly's Edge, shipped to an AWS S3 bucket, |
| 34 | +parsed and anonymized by our [`linehaul`](https://github.com/pypi/linehaul-cloud-function) functions in Google Cloud, |
| 35 | +and published to a Google-managed [public BigQuery dataset](https://docs.cloud.google.com/bigquery/public-data). |
| 36 | + |
| 37 | +## Other objects |
| 38 | + |
| 39 | +Everything PyPI serves for a release lives under the same `/packages/<xx>/<yy>/<hash>/` prefix. |
| 40 | +Matching on that prefix alone counted a fetch of any part of a release |
| 41 | +as a download of the distribution itself. |
| 42 | + |
| 43 | +- [PEP 658](https://peps.python.org/pep-0658/) `.metadata` sidecars. (estimated at 40%) |
| 44 | + Installers may fetch these to read a wheel's metadata *without* downloading the wheel. |
| 45 | + Every metadata fetch was being logged as a distribution download. |
| 46 | +- `.asc` GPG signatures. PyPI [stopped accepting these in 2023](2023-05-23-removing-pgp.md), |
| 47 | + but the ones uploaded before then are still served, and no longer counted. |
| 48 | +- `.egg` uploads were separately [deprecated in 2023](2023-06-26-deprecate-egg-uploads.md). |
| 49 | + All of these remain downloadable, and none of them are counted. |
| 50 | +- Formats frozen for upload since 2016 under [PEP 527](https://peps.python.org/pep-0527/), |
| 51 | + such as `.exe`, `.msi`, and `.rpm`, are about 0.2% of the files on PyPI, and are no longer counted. |
| 52 | + |
| 53 | +## The correction |
| 54 | + |
| 55 | + |
| 56 | +*Daily download quantity across all of PyPI, 60-day window ending 2026-08-31, from [pypistats.org](https://pypistats.org/).* |
| 57 | + |
| 58 | +Before the change, daily totals cycled between roughly 4.5 billion on weekends |
| 59 | +and a little over 7 billion midweek. |
| 60 | +The curve steps down after 2026-08-24. |
| 61 | +The last few days of that window were still settling when the chart was captured, |
| 62 | +so read the shape of the change rather than the final data point. |
| 63 | + |
| 64 | +## Impact on existing data |
| 65 | + |
| 66 | +If you compare download counts across 2026-08-24, the numbers will not line up. |
| 67 | +The counts after that date are lower and more accurate. |
| 68 | +Nothing was lost from the historical record - the older rows in BigQuery are unchanged, |
| 69 | +they were just measuring something broader than "someone downloaded this package" - |
| 70 | +they measured "someone downloaded a given file from PyPI". |
| 71 | +Most consumers of this dataset today do not filter specific to filename extensions, |
| 72 | +leading to inflated download counts for a given package. |
| 73 | + |
| 74 | +However, if you wanted to query the dataset yourself, you can write your own SQL queries |
| 75 | +to be able to distinguish a single project's distribution file downloads |
| 76 | +from others with a BigQuery statement that looks a bit like this: |
| 77 | + |
| 78 | +```sql |
| 79 | +SELECT |
| 80 | + DATE(timestamp) AS download_date, |
| 81 | + COUNT(*) AS all_objects, |
| 82 | + COUNTIF(REGEXP_CONTAINS(file.filename, r'\.(whl|tar\.gz|zip)$')) AS distributions_only, |
| 83 | + COUNT(*) - COUNTIF(REGEXP_CONTAINS(file.filename, r'\.(whl|tar\.gz|zip)$')) AS others, |
| 84 | + ROUND(100 * (COUNT(*) - COUNTIF(REGEXP_CONTAINS(file.filename, r'\.(whl|tar\.gz|zip)$'))) / COUNT(*), 1) AS others_pct |
| 85 | +FROM `bigquery-public-data.pypi.file_downloads` |
| 86 | +WHERE project = 'urllib3' |
| 87 | + AND timestamp >= TIMESTAMP('2026-08-18') |
| 88 | + AND timestamp < TIMESTAMP('2026-08-26') |
| 89 | +GROUP BY download_date |
| 90 | +ORDER BY download_date |
| 91 | +``` |
| 92 | + |
| 93 | +Results: |
| 94 | + |
| 95 | +| download_date | all_objects | distributions_only | others | others_pct | |
| 96 | +| ------------- | ----------- | ------------------ | ------ | ---------- | |
| 97 | +| 2026-08-18 | 77477790 | 47268787 | 30209003 | 39\.0 | |
| 98 | +| 2026-08-19 | 74742551 | 45808710 | 28933841 | 38\.7 | |
| 99 | +| 2026-08-20 | 73495894 | 45011207 | 28484687 | 38\.8 | |
| 100 | +| 2026-08-21 | 69652309 | 42467783 | 27184526 | 39\.0 | |
| 101 | +| 2026-08-22 | 49665300 | 30080582 | 19584718 | 39\.4 | |
| 102 | +| 2026-08-23 | 50965479 | 30344490 | 20620989 | 40\.5 | |
| 103 | +| 2026-08-24 | 64241532 | 44012651 | 20228881 | 31\.5 | |
| 104 | +| 2026-08-25 | 46619401 | 46619401 | 0 | 0\.0 | |
| 105 | + |
| 106 | +For this particular query and time range, ~39% of the download counts are |
| 107 | +not the actual distribution files. |
| 108 | + |
| 109 | +## Downloads are not a popularity metric |
| 110 | + |
| 111 | +Download counts are tricky to get right, |
| 112 | +and should not be used as a proxy for criticality or popularity. |
| 113 | +Volumes are often driven by misconfiguration of proxies or caches, CI systems running wild, |
| 114 | +or any variety of problems that occur with software. |
| 115 | +There's even folks out there who drive these numbers up artificially. |
| 116 | + |
| 117 | +This change removes one source of potential confusion and misinterpretation, |
| 118 | +but does not mean download counts are an accurate measure of how many people use a package. |
| 119 | + |
| 120 | +## Future work: range requests |
| 121 | + |
| 122 | +An HTTP response code `206 Partial Content` counts exactly the same as a `200 OK` right now. |
| 123 | +Reading two bytes out of a wheel to inspect its ZIP central directory |
| 124 | +is recorded as a download of the whole wheel, |
| 125 | +alongside a client that pulled all 30 MB. |
| 126 | + |
| 127 | +We want to record the request type and the bytes actually transferred, |
| 128 | +so that analysis can distinguish bytes served from downloads counted. |
| 129 | +That work is not scheduled yet. |
| 130 | +Follow <https://github.com/pypi/linehaul-cloud-function/issues/232> |
| 131 | +and <https://github.com/pypi/linehaul-cloud-function/issues/252> |
| 132 | +for that. |
| 133 | + |
0 commit comments