nixpkgs-multiverse

Design

Nixpkgs history already is the multiverse. Every version that ever existed is already built, already cached, already reachable, it was just addressed by commit hash instead of by version number, which is exactly backwards from how anyone thinks about it.

This project does not build old packages. It builds an address book.

Why

The problem starts the first time a version bump breaks something. The usual fix is a second nixpkgs input pinned to the commit before the bump, and it works. The trouble is what it costs, because every pin is a whole extra nixpkgs in the file:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    nixpkgs-for-vscode.url = "github:NixOS/nixpkgs/8b8c811c7c25";
    nixpkgs-for-ripgrep.url = "github:NixOS/nixpkgs/967d40bec14b";
    # ...and one more every time something else breaks
  };
}

Each of those is a separate input to update, a separate line to explain, and a separate thing to forget the reason for. The file grows a pin per incident and never loses one.

It is also slow, for a reason that is not obvious: flake inputs are fetched eagerly, whether or not anything references them as of Nix 2.34.

How is this possible?

Nothing here is a trick.

Every package in the store describes its dependencies exactly, by hash. The hash uniquely identifies the set of inputs a build actually used, so two builds of the same package from different revisions, different sources, different dependency trees, different compilers, etc., are simply two different paths. They coexist. There is no global version to conflict over, no "installed" version to displace, and nothing to resolve.

The second half is that the work is already done. We purposefully only leverage bumps to Nix's unstable channel. Hydra built these revisions when they were current and pushed them to cache.nixos.org, where they remain. Every version this index names is a cache hit.

So the missing piece was never building or storing. It was addressing: a way to say "python3 3.6.2" instead of "nixpkgs at 967d40bec14b", and to say it without paying for the other revisions you did not ask about — 1,531 of them, as of 2026-08-16.

Lazy trees

flake.nix has inputs = { } on purpose, and that is the whole design in one line: flake.nix really is a function outputs = f(inputs) where the inputs are resolved from a lock file flake.lock. However, nothing what this repository's flake can reach is an input, so nothing is eager.

Revisions are fetched with builtins.fetchTree, pinned by narHash, at the moment a derivation is forced and not before:

fetchRevision r      # defaults to builtins.fetchTree { type = "github"; ... }

Multiverse expopses fetchRevision which receives the record naming the revision and returns a builtins.fetchTree result. For a deployment behind a mirror, one can specify this function which would return a fetcher of its own. Multiverse checks the returned narHash against the one recorded for that revision — the index records digests for the trees those hashes name. See mirrors.

Everything upstream of that call: enumerating versions, resolving a date to a revision, reading a lifetime is a Nix evaluation over JSON that ships with the flake. It fetches nothing. A revision becomes a real tree only when you force a derivation out of it.

Cost is therefore per revision touched, not per package. Revisions are memoised, so:

Each revision actually used is a one-time ~378 MB which is the size of the Nixpkgs tree.

There is a second reason inputs could not work even if they were lazy: nixpkgs had no flake.nix before 20.03. Revisions older than that cannot be flake inputs at all, and roughly the first third of this index predates it.

The index

Four files, none of which grows with the number of revisions in the way the obvious encoding would. Every count below is a measurement taken on 2026-08-10 and left there; the index grows hourly, and the status block in the README is what carries the current figures.

The revisions.json file is the spine: 1,532 nixos-unstable channel bumps from 2012-07-05 to 2026-08-16, each with its commit, date, channel name and narHash. Everything else refers to a revision by its offset into this array, which is why the other files stay small, e.g jq ".[0] revisions.json:

[
  {
    "rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
    "date": "2026-08-31",
    "name": "nixos-26.11pre1064949.34ab99075ac4",
    "narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo="
  }
]

The channel name spells its nixpkgs commit two ways, and the archive holds both. From the 17.03 era on it is the last dot-separated field, as in nixos-26.11pre1064949.34ab99075ac4. Before that, nixos and nixpkgs were separate repositories and a name carried one commit from each — nixos-13.07pre4909_b32ef4d-2238a23 is the nixos commit and then the nixpkgs one. Only the trailing hash names a tree worth indexing; the leading one is from a repository whose history was later grafted into nixpkgs, so it resolves against NixOS/nixpkgs just as happily and would quietly index the wrong tree.

Every entry is a bump the nix-releases archive published, which is what makes the whole file uniform: each one has a channel name, a store-paths listing, and a Hydra build on cache.nixos.org. The file used to also carry the 22 commits each release branch was cut from. Those were on no channel, so they had none of the three, and every consumer of a revision name carried a branch for them; they were dropped rather than special-cased. The versions that lived only at those commits went with them — all of them eval-only records, since a revision with no listing can have no store path.

The index/versions.json maps each (attribute, version) to the single newest revision in revision.json that shipped it. For example (jq ".attrs.hello" index/versions.json) returns:

{
  "revisionCount": 1540,
  "attrs": {
    {
      "2.7": 0,
      "2.8": 13
      "2.10": 728,
      "2.12": 822,
      "2.12.1": 1369,
      "2.12.2": 1486,
      "2.12.3": null,
    }
  }
}

null is not "unknown" — it is the newest revision the file covers, revisionCount - 1, and it is how the file says a version is still current. See the open tip for why it is not written out.

An attribute key is usually a top-level attribute name, and is otherwise the path to a child of one of the few package sets nix/nested-sets.nix lists — jetbrains.idea. Readers walk that path rather than asking nixpkgs for an attribute whose name contains a dot. The list is an allow-list because nixpkgs marks 285 package sets recurseForDerivations and they hold 74,000 children between them, three times the rest of the index; see building the index.

Storing one integer rather than every revision a version appeared in is what keeps the file flat as revisions accumulate; otherwise a package that never changes version gains an entry per revision forever. Measured across encodings at 109 revisions:

encoding size grows with revision count?
full revision list, names 63.9 MB yes
[first, last], offsets 4.1 MB no
newest only, offset 3.3 MB no

Newest is also the build-correct choice: it is the most patched build of that version, and the one Hydra produced most recently, so the most likely to still substitute from the cache.

As of 2026-08-10 that file is 5.45 MB and covers 304,758 (attribute, version) pairs across 31,798 attributes.

index/history.json answers the question versions.json deliberately cannot: not "where can I get this version" but "when did this version exist". It stores each version's lifetime as runs of revision offsets, nesting only when a version left and came back (jq .attrs.hello index/history.json):

{
  "2.10": [
    [14, 723],
    [728, 728]
  ],
  "2.12": [
    [724, 727],
    [729, 822]
  ],
  "2.12.1": [823, 1369],
  "2.12.2": [1370, 1486],
  "2.12.3": [1487, null],
  "2.7": [0, 0],
  "2.8": [1, 13]
}

A run that is still open ends in null, the same claim versions.json makes with a null offset: this version is current as of revisionCount - 1.

The open tip

Both files are appended to hourly, and both are in git, so what matters is not only how large they are but how much of them a single revision changes.

Closing those runs literally — writing [1495, 1539] and bumping it to [1495, 1540] next hour — makes an append rewrite the entry of every version that did not change. On a typical revision that is 24,854 of 31,819 attributes: about 3 KB of real news, scattered as thousands of four-byte edits across a 5.4 MB file. Git stores that as a ~140 KB delta per file per revision, roughly 50× the size of the change, and it accumulates in the history forever.

Leaving the tip open costs a subtraction at read time and takes the two files from ~284 KB of pack per indexed revision to ~6 KB:

pack growth per revision
versions.json, tip written out 141 KiB
versions.json, tip left open 3 KiB
history.json, tip written out 143 KiB
history.json, tip left open 3 KiB

Readers resolve a null against the revisionCount of the file that carries it, never against length revisions. The two disagree in the ordinary window between fetch-unstable-revisions.sh appending a revision and build-index.sh indexing it, and in that window the newly appended revision is one the index has never evaluated — resolving to it would claim a version was current in a tree nobody looked at.

The file releases.json is separate and indexed by nothing. It contains 25 release channels, each holding the current tip of its branch. Releases move as backports are applied, so a release is a channel, not a snapshot, and it lives outside the revision array for that reason. See releases move, revisions do not.

Minimising

Cost is per revision touched, not per package. Ten pins resolved independently are ten nixpkgs fetches and ten evaluations; the same ten grouped onto the revisions they can share might be two. mvs solve, mv.solvePins and multiverse.pins all ask the same question to get there: what is the smallest set of revisions that ships every version asked for?

Every pin is one contiguous block on the revision axis — the stretch of revisions where nixpkgs shipped exactly that version. A revision serves a pin if it lies inside that pin's block. So the question is: fewest points touching every block.

How minimising picks revisions

The whole algorithm is one sweep. Sort the pins by where their block ends. Take the pin that ends earliest, put a revision at the last revision in its block, delete every pin that revision serves, repeat.

Why the sweep is optimal

Let A be the pin whose block ends earliest, at l(A). Every valid plan holds some revision q inside A's block. Swap q for l(A): any pin B that q served has first(B) ≤ q ≤ last(B), and since l(A) is the smallest endpoint of anything left, last(B) ≥ l(A); combined with first(B) ≤ q ≤ l(A), the point l(A) is inside B too. Nothing is lost by the swap, so some optimal plan contains l(A). Fix it, delete what it serves, and the argument repeats on what is left. Sort plus one pass: O(n log n).

You do not have to take that on trust. For each revision it places, the sweep names the pin that forced it, and those pins are pairwise disjoint — pin j's block must start after pin i's ended, or i's endpoint would already have served it. k disjoint blocks need k distinct revisions, so the plan carries its own proof that nothing smaller exists. That is what certificate and why report, and it is checkable from the dates alone:

$ mvs solve python3@3.8 nodejs@14 ripgrep@14
2 revisions · minimal
...
  minimal: python3 3.8.x and ripgrep 14.x never overlapped

Where this would have been NP-hard

The swap above needed first(B) ≤ l(A) ≤ last(B) to imply that l(A) serves B, which holds only because B is one unbroken block.

Versions do not always come in one block. A version can leave nixpkgs and come back. Let a pin be served by any of its blocks and the problem stops being interval stabbing and becomes hitting set over unions of intervals, which is NP-hard: lay a graph's vertices on the line, turn each edge {u, v} into a pin served by exactly revisions u and v, and hitting every pin is a minimum vertex cover.

So a pin takes the newest of its blocks and only that one. This is not a new rule: index/versions.json records only the newest revision shipping each version, and mv.version, mvs lock add and multiverse.pins already resolve through it. It costs something in rare cases: of random pin sets containing a holed pin, roughly 8% come out one revision larger than the true optimum over all blocks, never more than one. What it buys is that the answer is always exactly minimal for the blocks it considered, in n log n, with no solver.

What minimising costs

A pin that shares a revision with another can end up on an older revision inside its own version's run: the same version, an older build of it, carrying that revision's closure.

Two details keep that cost as low as it can be. Each revision the sweep places is the newest one that can serve its whole group, and each pin then joins the newest placed revision that serves it rather than the first. pinPlan and mvs solve report the displacement per pin, in days and in revisions, before anything is fetched.

Minimising is not useful the fast path. A pin the store-path index knows costs no fetch at all, so there is nothing to group. Under fastFallback = "eval" the two compose without any special handling.


For the longer version of this story, see the blog post: nixpkgs-multiverse: every version that ever existed.

Edit this page on GitHub