Skip to content
Published on

Nix 2.35 Copies Flake Sources to the Store More Lazily — a 6-Year-8-Month Issue, and the Path Upstream Chose Instead of Lazy Trees

Share
Authors

Introduction — Why Does nix build Copy Your Entire Repository?

If you've ever fixed a single file in a flake-using repository and run nix build, you've seen this behavior.

$ vim README.md          # fix a single character
$ nix build .#myapp
copying '/home/me/big-monorepo' to the store...

Whether or not the build actually needs README, the entire source tree gets copied to /nix/store. Fix one more character next time, and it copies again. If the tree is a multi-gigabyte monorepo, this is no joke.

What matters is that this was not a bug — it was the design. To quote Nix 2.35.0's release notes directly: historically, flake source trees have been eagerly fetched into the store and evaluated from there, in order to guarantee deterministic, hermetic evaluation, even when the resulting store object was never used as a derivation input. The implementation stayed simple, but it made flakes unusable on large repositories and wrote unnecessarily every time the source tree changed.

This post is about why fixing that design took 6 years and 8 months, what Nix 2.35.0 actually fixed, and what it still hasn't fixed. That last part matters most.

A 2,437-Day Issue — #3121

The issue NixOS/nix#3121 "Copy local flakes to the store lazily" was opened on October 7, 2019. It was opened by Eelco Dolstra — the person who created Nix.

The issue closed on June 9, 2026. 2,437 days, roughly 6 years and 8 months, 69 comments. The person who closed it wasn't Dolstra, and the closing comment was five words — "Completed in #15711."

#15711 is not Dolstra's PR. That's where the story starts.

Lazy Trees — Two Attempts

Dolstra's answer was lazy trees. The idea: instead of copying the source tree into the store and reading it from there, build a virtual filesystem that presents itself to the evaluator "as if it were in the store." Then read only what's actually needed, only when it's actually needed. There's no reason to copy all of nixpkgs just to build nixpkgs#hello.

PR NixOS/nix#6530 "Lazy trees" opened on May 12, 2022. It grew to 402 commits, 56 files, +1363/-364. Then, on May 18, 2025, it closed without being merged. 1,102 days — 3 years.

The day before it closed, on May 17, 2025, Dolstra opened PR #13225 "Lazy trees v2". It's much smaller — 1 commit, 41 files, +539/-133. As of this writing, July 16, 2026, the PR is still open, GitHub reports its mergeable state as dirty (conflicting), and it was last updated on October 9, 2025. It's been stalled for nine months with 16 comments and 17 review comments attached.

One thing worth being careful about here: I don't know why the PR isn't moving. Summarizing the substance of the review debate and writing "so-and-so blocked it" is outside the scope of this post, and it isn't a verifiable fact either. The only verifiable facts are these — it's open, it's in a conflicted state, and it hasn't been updated in nine months.

Meanwhile, Determinate Nix Just Shipped It

Dolstra is a co-founder of Determinate Systems. And Determinate Systems ships its own Nix distribution, Determinate Nix.

On May 15, 2025, Determinate Systems' Luc Perkins announced that lazy trees had landed in Determinate Nix 3.5.2. It was based on upstream Nix 2.28.3, and it was opt-in at first.

# /etc/nix/nix.custom.conf
lazy-trees = true

The same post has this line — "our co-founder Eelco Dolstra currently has a PR open to bring lazy trees to upstream Nix, and we're hoping it lands soon." That PR is #13225. It's still open 1 year and 2 months later.

Two months later, on July 11, 2025, Graham Christensen announced Determinate Nix 3.8.0, writing that the prior release had rolled lazy trees out to 5 percent of Determinate Nix users, and that since the 5 percent rollout went well, it was now expanding to 20 percent.

Shipping an open-source package manager via a percentage-staged rollout — that's a fairly novel sight. Users can check whether they've landed in that 20 percent by running determinate-nixd version, and the same post says to contact support@determinate.systems to opt out.

There are performance numbers too. But these are vendor-measured. The 3.5.2 announcement, using evaluation inside the Nixpkgs repository as its example, said it had "frequently seen" more than a 3x reduction in wall time and more than a 20x reduction in disk usage, adding that "sometimes the reduction is far larger." I'll state plainly what's missing here — hardware, measurement methodology, the baseline version compared against, how many runs, and what percentage "frequently" means are all absent. The direction is credible, but you shouldn't carry those multipliers over to your own repository as-is.

The Core — lazy-locks, and the Price of Total Laziness

Now for the technical heart of this story. Look at the settings definitions in the Determinate Nix source (as of v3.21.7), and you'll find two settings side by side.

Setting<bool> lazyTrees{
    this, false, "lazy-trees",
    R"(
      If set to true, flakes and trees fetched by builtins.fetchTree are only
      copied to the Nix store when they're used as a dependency of a derivation.
      This avoids copying (potentially large) source trees unnecessarily.
    )"};

Setting<bool> lazyLocks{
    this, false, "lazy-locks",
    R"(
      If enabled, Nix only includes NAR hashes in lock file entries if they're
      necessary to lock the input (i.e. when there is no other attribute that
      allows the content to be verified, like a Git revision).
      This is not backward compatible with older versions of Nix.
      If disabled, lock file entries always contain a NAR hash.
    )"};

That line in the lazy-locks docs — "This is not backward compatible with older versions of Nix" — explains everything.

The logic goes like this. Every entry in flake.lock includes a narHash. That's the hash of the input source tree serialized as a NAR. Computing that hash requires reading the entire tree. In other words, as long as you keep putting narHash in the lock file, there's a ceiling on laziness — even if you only need one file, you still have to walk the whole tree.

So to get genuinely, all-the-way lazy, you have to drop narHash from the lock file. That's lazy-locks = true. And a lock file built that way can't be read by older Nix. If one team member turns that option on and commits flake.lock, everyone else's build who hasn't turned it on breaks.

Worth noting too: both settings default to false in the code. The 20 percent Determinate rolled out to weren't getting the code default — they were turned on by settings their own daemon manages.

Lix's Answer — Don't Fix It, Freeze It

There's a third implementation too. The fork Lix, which split off in 2024.

The Lix wiki's Flakes feature freeze document (written October 5, 2025, updated February 1, 2026) states that the core team decided to freeze the flake feature set and semantics as they currently stand — no new features beyond bug fixes, no new input types, no new features on existing fetchers, no flake-focused evaluator changes. The reason given is that the flake implementation code is extremely fragile and carries a heavy maintenance burden.

The same document contains the sharpest sentence for this post's purposes. It's a claim that the flake locking design is unsound — the gist being that even if what's in the lock file is fetchable, lock comparison fails unless it's the value this particular Nix implementation would have generated for the same input. So any divergence in libfetchers' input format or the lock file format produces incompatibility across implementations.

Determinate's lazy-locks is exactly that kind of divergence. The Lix wiki named that class of problem in the abstract, Determinate put it behind an opt-in flag, and upstream decided not to do it at all. Three different answers to the same problem.

What Nix 2.35 Actually Did

Nix 2.35.0 was tagged and announced on the same day, July 13, 2026. (The date at the top of the release notes document reads 2026-06-22 — a three-week gap between when the notes were cut and the actual tag. The actual release date is July 13, 2026, when the tag and the announcement coincide.)

What got merged is PR #15711 "Don't copy flakes to the store unnecessarily (maybe 3rd time's the charm)". Its author is Sergei Zimmerman (@xokdvium); it opened on April 19, 2026, and merged on April 27. 3 commits, 14 files, +180/-37.

Recall that lazy trees v1 was 402 commits, 56 files, +1363/-364 — this is a different order of magnitude.

The PR body states the approach plainly.

3rd time's the charm! (...) This repurposes a slightly less lazy (but also more deterministic) approach than determinate nix has taken. We do still pay to cost of hashing an input once to compute the store path and narHash daemon-client-side.

"A slightly less lazy but more deterministic approach than the one Determinate Nix took." And, "the cost of hashing an input once" is still paid.

How it actually works is in the release notes — to cut down on the number of copies, Nix now hashes before copying, assuming that .outPath will not end up in a derivation attribute and therefore won't actually need to be brought into the store. If that assumption is wrong, it costs a bit of extra work, but for the typical use case, it means less work overall.

In other words, the copy is gone, not the walk-and-hash.

Nor did this happen independently of Dolstra. Zimmerman wrote on issue #3121 on April 19, 2026 — "Gave it a shot in #15711. @edolstra did a lot of great groundwork that made that PR possible, I just put the pieces together." Indeed, the PR this one stands on, PR #14050 "Fix fetchToStore caching", is one Dolstra merged upstream on October 6, 2025.

The same pattern shows up on the fetchTarball side. Determinate Nix 3.21.0 (May 22, 2026) shipped "Lazy fetchTarball," and five days later, on May 27, PR #15920 "fetchTarball: Use the tarball fetcher" merged upstream. Both are authored by Dolstra. (I won't claim the two patches are the same thing — all I've confirmed is the author and the dates.)

There's a single performance figure in the release notes — this change is expected to significantly reduce the disk usage needed for a typical evaluation, and it delivers roughly a 2x speedup for fetching and unpacking the nixpkgs tarball, whether through the fetchTree/flake path or the fetchTarball path. This is a project self-measurement, and hardware, methodology, and the comparison version aren't specified. I'm also carrying over the temperature difference in the original wording — disk usage is "expected," while speed is stated as a plain fact.

There are no lazy-trees or lazy-locks-equivalent settings upstream. Searching the NixOS/nix repository on GitHub, both strings return zero hits. There's nothing to flip on or off — the default behavior itself changed. That's the practical difference from Determinate's approach — instead of adding more flags, it got as lazy as it could without breaking compatibility.

What's Still Not Fixed

This is the most important section of this post. The release notes state their own limits, and sentences like this tend to disappear from summaries.

The original text says that touching the whole source tree and hashing its NAR serialization is unavoidable, and gives two reasons.

  1. For flake inputs, narHash integrity has to be checked eagerly.
  2. A flake's outPath attribute has to be known in advance, and for backward compatibility it has to be a content-addressed store path string.

So if you fix one file in a 3-gigabyte monorepo, nix build no longer copies 3 gigabytes to the store, but it still reads and hashes 3 gigabytes. On large trees, that's still a visible cost. What disappeared is the store write and the disk footprint, not the filesystem walk.

The release notes also point out that half of this was already cleaned up in the previous release, 2.32 — since Nix 2.32, all I/O for reading sources during evaluation (except for path: and hg+:-style inputs) has been routed to its original filesystem location, and tarball-based inputs to a bare git repository at ~/.cache/nix/tarball-cache-v2. What remained was the fetch needed to compute the store path, and that's what 2.35 stripped out.

And there's a case where the assumption is wrong. If .outPath really is an expression that ends up in a derivation attribute — in the release notes' own words, "if that assumption is wrong" — you pay the cost of extra work. The notes call this "slight" and give no specific figure. Since no such figure has ever been published, I won't invent one here.

Last point: this is not a change that improves reproducibility. It's a performance change. The resulting store path has to stay the same, and from the evaluator's point of view, it still behaves as if the copy happened — toString ./. still produces a context-free content-addressed store path string, and code that reads files from a flake's outPath still works and isn't treated as IFD. There's a reason the notes list out this compatibility list at such length. Backward compatibility was the constraint this whole design had to fit inside. If you want to look at this same theme — "the cost of reproducibility" — from a different angle, from the distribution side, the story of Debian blocking unreproducible packages from migrating to testing went up on the same day.

So, What Should You Do

When upgrading pays off

  • You use flakes on a large repository or monorepo, and copying '...' to the store is the felt cost of your iteration loop. Just losing the copy is a win.
  • You repeatedly fetch and unpack nixpkgs tarballs in CI. This is exactly where the release notes cite roughly 2x (project self-measured).
  • Disk pressure on /nix/store is a real problem. Fewer source-tree copies pile up per evaluation.

When it makes little difference, or there's no rush

  • Your tree is small. The walk and the hash remain, and if the copy was cheap to begin with, there isn't much left to gain.
  • You don't use flakes. This change is about the flake and fetchTree/fetchTarball paths.
  • Your monorepo's bottleneck is the build itself, not evaluation. The fact that you still read and hash 3 gigabytes doesn't change.

If you're considering turning on Determinate Nix's lazy-trees — that's a separate thing from 2.35. It's lazier, but getting all the way lazy requires lazy-locks, and the docs explicitly say that breaks backward compatibility. Don't turn on lazy-locks unless you're guaranteed that your whole team is on the same Nix implementation. flake.lock is a file your team shares, and lock incompatibility across implementations is a known pitfall the Lix wiki has already flagged in the abstract.

If you use Lix, no new features are coming to flakes. That's not an accident — it's stated policy, and the long-term plan is to move flakes out into a plugin outside the codebase.

Closing

To sum up: an issue titled "let's copy sources lazily," opened by Nix's creator in 2019, closed in 2026. What closed it wasn't his 402-commit PR — it was someone else's 180-line PR, built on top of his groundwork. In between, the same feature shipped downstream first, on a commercial distribution, via a percentage rollout, and another fork froze this entire area.

The technical lesson is simple. The difficulty in this problem was never implementing laziness — Determinate proved in 2025 that that part works. The difficulty was getting lazy without breaking backward compatibility, and so the answer that landed upstream was "slightly less lazy, but more deterministic." As long as narHash keeps going into the lock file, hashing the whole tree stays — exactly as the release notes themselves put it, "unavoidable."

And this feels like the sentence from this story that will stick around longest: a big diff doesn't solve the problem — a small diff that accepts the constraint does. The PR title already knew it — maybe 3rd time's the charm.

References