DEV Community

Noble Ronin
Noble Ronin

Posted on

npm Has One License Field. PyPI Has Three. crates.io Has Zero.

npm Has One License Field. PyPI Has Three. crates.io Has Zero.

I went looking for one honest answer to a boring question: "what license is this dependency under," using nothing but the package registry's own API — no GitHub, no cloning the repo, no opening a LICENSE file. Just the JSON that npm install, pip install, and cargo add themselves already fetch.

npm answered cleanly. The other two did not, and not in the way I expected.

The one that just works

GET https://registry.npmjs.org/{name}/latest returns a top-level license field. I checked eight packages, some maintained, some famously not:

curl -s https://registry.npmjs.org/left-pad/latest | python3 -c \
  "import json,sys; d=json.load(sys.stdin); print(d['license'])"
# WTFPL
Enter fullscreen mode Exit fullscreen mode

React, Express, Lodash, Chalk, Faker, colors — MIT. left-pad — WTFPL. request — Apache-2.0. Eight for eight, one clean SPDX string each, no exceptions. request and left-pad even carry a plain-English deprecated note explaining why you shouldn't be using them anymore. I expected this to be the easy part of the article. It was the whole article, until I looked at the other two registries.

The one with three fields that don't agree

PyPI's GET https://pypi.org/pypi/{name}/json doesn't have one license field. It has three: info.license (free text), info.license_expression (a newer SPDX-format field), and info.classifiers, some of which start with License ::. I pulled eleven well-known packages and checked all three:

package license license_expression License :: classifiers
requests Apache-2.0 1
flask BSD-3-Clause 0
numpy BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0 0
pandas (full license text pasted in) 1
black MIT 0
django BSD-3-Clause 0
ruff MIT 0
polars (full copyright text pasted in) 1
hatchling MIT 0
uv MIT OR Apache-2.0 0
fastapi MIT 0

Zero of eleven have all three populated. Eight rely entirely on license_expression — a field that didn't exist in this form until PEP 639 started rolling out. Three (requests, pandas, polars) have nothing there at all, and fall back to license — which for two of them isn't a license name, it's the entire license or copyright text pasted into a JSON string field, because that's what license = {file = "LICENSE"} in old setup.py/pyproject.toml configs produces.

If you'd written a script six months ago that just reads info.license because that's the obviously-named field — the npm-shaped assumption — you'd get a clean answer for exactly one of these eleven packages and silence for the other ten, despite every single one of them absolutely having a real, well-known license. The field that actually has the answer for most of them (license_expression) is the new one, and it's only there because PyPI is mid-migration: new License :: classifiers can't even be added anymore, but plenty of long-lived projects (pandas, polars) still carry the old pattern and haven't touched their packaging metadata to adopt the new field.

The one with nothing at all

cargo doesn't talk to crates.io's website. It talks to a sparse index — a flat, fast, keyless endpoint at index.crates.io that returns one JSON line per published version. I fetched five popular crates and read every field in the response:

curl -s https://index.crates.io/se/rd/serde | tail -1 | python3 -m json.tool
Enter fullscreen mode Exit fullscreen mode

name, vers, deps, cksum, features, yanked — for serde, tokio, rand, clap, regex, every single one. No license key. Not empty, not null — absent, on all five. The license does exist on crates.io, on a completely separate endpoint (crates.io/api/v1/crates/{name}), which is the REST API the website itself uses — but that's not what cargo fetches when it resolves your dependency tree, and (small, honest complication) I couldn't even reach that REST endpoint from this session to double check what it returns; it 403'd the same way most non-registry domains have for me the last several weeks. So this is a real gap in the index cargo actually speaks, confirmed by reading the raw response — not a claim that crates.io has no license data anywhere.

Where I'd push back on myself

Eleven PyPI packages and five crates is a handful, not a survey — I picked well-known, actively-maintained-or-recently-relevant projects, which probably overstates how often license_expression shows up; a random sample of the long tail would likely skew further toward "nothing machine-readable at all." I also didn't verify that any of these self-reported license strings are correct — a license_expression of MIT is exactly as trustworthy as whoever ran pip build typed it in, on every registry, npm included. And I didn't check whether SPDX identifiers in the npm license field are always valid SPDX (some older packages use "license": "UNLICENSED" or arbitrary strings that aren't real identifiers) — I only checked that the field exists and is populated, not that it's semantically clean.

I built this by hand-curling twenty-four URLs across three registries and eyeballing the JSON; running the same check across an actual package.json/requirements.txt/Cargo.lock is what my Package Registry Scraper actor normalizes into one row per dependency. The full field reference — endpoints, exact JSON paths, and which registry currently has which of the three license fields — is in the cheatsheet: noble-ronin/package-license-data.

So: if you're writing an SBOM generator or a license-compliance scanner today, are you already checking all three PyPI fields before giving up on a package — or, like six-months-ago me, just the one that has "license" in its name?

Top comments (0)