DEV Community

Noble Ronin
Noble Ronin

Posted on

npm's "latest" Is a Label a Maintainer Sets. PyPI's and crates.io's Is Just Math.

npm's

I build a scraper that reads npm, PyPI and crates.io for a living (side project —
package-registry-scraper
on Apify). Last week I was diffing version lists for something unrelated and
noticed a number that didn't make sense: vue's "latest" was 3.5.43, but
sitting in the same JSON payload was 3.6.0-rc.10 — a higher version number,
published, live, installable, and not what you get when you type
npm install vue.

My first assumption was a scraper bug. It wasn't. It's npm working exactly as
designed, and once I understood why, I realized the other two registries I
scrape don't even have the concept that caused my confusion.

The thing I didn't know

npm's "latest" isn't computed. It's a label. Every npm package carries a
dist-tags object — a small set of names, each one a maintainer-settable
pointer into the list of published versions. latest is just the tag npm's
own CLI happens to install by default. Nothing stops a maintainer from
pointing it anywhere, including backward, including nowhere near the highest
version number that exists.

Here's react's full tag set, live, right now:

curl -s https://registry.npmjs.org/react | python3 -c "
import json,sys
d = json.load(sys.stdin)
print(d['dist-tags'])
"
# {'beta': '19.0.0-beta-26f2496093-20240514', 'rc': '19.0.0-rc.1',
#  'next': '19.3.0-canary-d5736f09-20260507', 'backport': '19.0.8',
#  'latest': '19.3.0',
#  'experimental': '0.0.0-experimental-278794d7-20261002',
#  'canary': '19.3.0-canary-278794d7-20261002'}
Enter fullscreen mode Exit fullscreen mode

Seven independent pointers into one version list. vue runs the same
pattern, plus a v2-latest tag permanently parked on 2.7.16 for people who
never migrated off Vue 2. If you only ever read dist-tags.latest — which is what
most tooling does, including, until last week, mine — you will never see the
other six.

I pulled 12 well-known packages to see how common the mismatch actually is.
Only vue had it. I'd have bet on react too, and I'd have lost: its canary
builds are all 19.3.0-canary-…, and semver ranks a prerelease below the
release it precedes, so 19.3.0 really is the top. angular, left-pad,
node-sass, request, moment, express, lodash, is-odd, is-even and
colors matched exactly too. So this isn't registry-wide chaos. It's a specific thing that happens once a package
maintains a canary/rc/next channel under the same name instead of a separate
package, and it happens on some of the most-depended-on code on the internet.

What the other two registries do instead

I expected PyPI to have something similar — a latest you could override.
It doesn't. pip install black resolves to whatever PEP 440 says is the
highest stable version number, full stop:

curl -s https://pypi.org/pypi/black/json | python3 -c "
import json,sys
d = json.load(sys.stdin)
print(d['info']['version'])
"
# 26.10.0
Enter fullscreen mode Exit fullscreen mode

black has 74 release entries on record, several of them prereleases
(21.8b0, 23.1a1, 26.1a1...). None of them can become info.version
unless they're literally the highest-numbered stable release. There's no
sibling field to set, no second opinion to query. I checked five packages
with real prerelease history (black, urllib3, Django, pip, numpy)
and every single one resolved the same deterministic way. A PyPI maintainer
cannot do what the Vue maintainers are doing right now.

crates.io goes one step further and doesn't even store the concept. The
sparse index — the actual file cargo downloads, not a convenience API —
is newline-delimited JSON, one line per published version, each line
carrying vers, yanked, a cksum, and a pubtime:

curl -s https://index.crates.io/se/rd/serde | tail -1
# {"name":"serde","vers":"1.0.229", ... "yanked":false,
#  "pubtime":"2026-07-18T23:05:13Z"}
Enter fullscreen mode Exit fullscreen mode

No latest key anywhere in that file. cargo computes "the newest usable
version" itself, client-side, as the highest non-yanked semver that
satisfies whatever range your Cargo.toml asked for. The registry has no
stored opinion for a maintainer to bend.

So you get three different philosophies for the exact same question —
"what version do I get if I don't ask for one in particular" — and they're
not minor implementation details. npm hands the answer to the publisher as
a lever. PyPI computes it from a spec nobody can override. crates.io doesn't
even track the question; it pushes the computation to your own machine.

Where this actually bites

The boring version: if you're writing tooling that audits dependencies —
license scanners, SBOM generators, "what's the newest version of X"
dashboards — and you built it against PyPI or crates.io first, your mental
model is "latest is the biggest number." Port that model to npm unexamined
and you'll either silently ignore legitimate prerelease channels (fine, if
that's the intent) or, worse, misreport dist-tags.latest as "the newest
code that exists" when six other tags, and sometimes a materially newer
canary build, say otherwise.

The sharper version: dist-tags.latest is writable by anyone with publish
access to the package. It's a flag on an API response, not a cryptographic
fact. If you've ever written a script that trusts "latest" as a proxy for
"the version most people are currently running" or "the version the
maintainer currently endorses," you were trusting a string a human can
change at any time, for any reason, with no version bump and no changelog
entry required.

Where I was wrong going in

I went looking for a dirtier story than the one I found. My first guess was
that I'd find a case of a maintainer quietly rolling latest backward
below a version with a known vulnerability — a kind of retroactive
"un-shipping" that the registry's version history wouldn't even hint at.
I checked the obvious candidates (ua-parser-js, coa, node-ipc — all
real incident packages) and didn't find it: when npm packages flag a bad
release, they use the separate per-version deprecated string, not a
dist-tags rewrite, and I'd already covered that mechanism in a piece a
few weeks back. So the "lever" I found this week is real and verifiable,
but it's a UX/tooling-assumption footgun, not (as far as I could confirm
live) a documented attack pattern. I'm flagging the gap between what I set
out to find and what I actually found, rather than quietly rounding my
original hunch up to a conclusion.

I keep the endpoint + field reference for all three registries, including
the full dist-tags shape, in
noble-ronin/package-latest-tag-semantics
if you want the cheatsheet version of this without the narrative.

If you've written anything that reads dist-tags.latest and treats it as
a timestamp-equivalent fact — did you know about the other six tags, or did
you find out the way I did?

Top comments (1)

Collapse
 
mickyarun profile image
arun rajkumar •

The honest part of this is "Only vue had it." You went looking for registry-wide chaos across twelve of the most depended-on packages and found one, said so, and named the two you would have bet on and lost. That is a smaller finding than the headline and it is the reason I believe the rest of the post.

Where I would argue is that computed is not the better design, it is a different one with the same name. A computed latest cannot ship a security backport to an old line. react's backport tag parked on 19.0.8 and vue's v2-latest on 2.7.16 exist precisely because someone needs to publish a fix for people who have not migrated, and a registry where latest is just max() forces that fix to either jump to the top or live in a separately named package. PyPI and crates.io did not solve the problem, they moved it to naming. Mutable pointer and computed value are the same trade I spend my working life on in payments: a record that stores a resolved value is unambiguous and silently reinterprets history when the thing it resolved from changes, and a record that stores a pointer stays correct and can dangle.

The number I would want from your scraper is not how many packages mismatch today. It is how many times a latest tag has ever moved backward. A maintainer repointing latest at an older version is the case that breaks installs for everyone without a lockfile, and it is invisible to any one-time diff — your snapshot would show a perfectly ordinary tag pointing at a perfectly real version. You are already pulling dist-tags on a schedule. Keeping the previous value and alarming when the new one sorts lower is close to free, and it catches the only version of this that actually hurts anyone.

One thing I would not have known to check without this post: that most tooling reads dist-tags.latest and therefore cannot see the other six pointers at all. Including, as you say, yours until last week.