June 22, 2026·6 min read·engineeringnixevaluationperformance

Nix 2.35 separates source hashing from copying

Nix 2.35 can identify and evaluate source trees without immediately copying them into the store. The change preserves store-path semantics while delaying physical materialization until an operation needs it.

A flake can import package definitions from a repository without using the entire repository as a build input. Historically, Nix still copied that source tree into the store first. Nix 2.35, released today on June 22, separates computing the source identity from performing that copy. The evaluator keeps the expected store-path view while materializing the source only when an operation requires it.

The distinction matters most when the source tree is large and changes often. Copying an input merely to read its Nix expressions creates work even if no resulting derivation uses the whole tree. Avoiding that copy reduces unnecessary store writes. It does not mean evaluation no longer reads or hashes the source.

Nix manages the desired state of my personal infrastructure, and I value knowing which inputs describe a machine. This change preserves that account while reducing a cost imposed by the implementation. The source identity remains meaningful even when the evaluator can postpone creating the corresponding store object.

The problem predates this release by years

Eelco Dolstra opened issue 3121 in October 2019. Its example compared building a package through a local flake with using the non-flake file interface. The flake route copied the source tree before evaluation. The issue also identified a constraint, since lazy access still needed to respect which files belonged to the source tree.

That constraint explains why simply reading arbitrary files from the working directory would be an inadequate fix. The evaluator needs a defined source view. If changing the copy behavior also changed which files were visible, the performance improvement would alter evaluation semantics. The desired optimization has to preserve the source boundary as well as its path identity.

Nix 2.35 implements this through the distinction between its evaluator view and the physical store. A source accessor can supply file contents at the expected store location from the evaluator perspective. The actual store object can remain absent until another operation needs it. This gives the system a place to defer work without making the language invent a different path spelling.

The main implementation change in pull request 15711 makes that mechanism visible. mountInput switches its fetch-to-store operation from copying to a dry run. The dry run still computes the content hash and resulting store path. The accessor is mounted under that identity for evaluation.

Hashing remains eager for compatibility

The release notes explain that flake integrity checks still require narHash validation and that outPath must retain its established store-path representation. Computing those values still requires touching and hashing the tree. The optimization removes a copy that was previously coupled to that computation.

This is a useful distinction when interpreting the expected benefit. A repository can still incur a source traversal even when it avoids writing a second tree. If the cost is dominated by hashing, this change cannot remove all of it. If repeated store materialization was expensive, the deferred copy has more work to eliminate.

The implementation comments discuss making path identity and hashing lazier as a possible further direction, but describe that as a larger change. The current release preserves the existing interface. A caller asking for a flake path continues to receive the familiar content-addressed store-path string, with the associated dependency context where required.

That compatibility choice has a cost when the source ultimately must become a build input. The system first hashes the tree and later performs the copy, so the deferred path can require additional work compared with knowing at the start that copying was necessary. The benefit depends on avoiding copies in the cases where evaluation alone consumes the source.

A package-set import is a natural example of that distinction. The definitions can describe how to build packages whose sources come from elsewhere. That repository need not itself become the source archive of every package described within it. Separating those uses avoids an ingestion request when only the definitions are needed.

Dependency context identifies when bytes are needed

The implementation adds ensureLazyPathCopied and a helper that applies it to opaque store references carried in string context. This is where the path view meets an operation needing a real object. The code looks up the mounted source and invokes copying, then checks that the resulting store path matches the identity computed earlier.

That equality check is significant. A deferred copy must produce the object the evaluator already identified. If the accessor or caching relationship supplies different content, continuing under the old identity would violate the optimization assumptions. The patch treats a mismatch as an internal error instead of quietly replacing the expected path.

Derivation construction is one place that forces materialization. When an opaque reference becomes an input source of the derivation, the patch ensures it is copied before adding it to the input set. The build can then use a real store object with the dependency relationship the expression declared.

Other consumers also need physical materialization. The patch handles application execution and native loading, along with operations that write context-bearing results outside the evaluator. These cases explain why lazy copying has to be integrated across consumers. Changing the fetcher alone would leave some code expecting physical paths that only existed in the evaluator view.

By contrast, the flake metadata implementation explicitly avoids forcing a copy merely to describe the source. It reports the expected store path from the mounted view. A metadata query and a build-input request therefore have different materialization requirements even when both refer to the same source identity.

Tarball imports use the same deferred path

The follow-up change in pull request 15920 applies the mechanism to unpacked tarball fetching. For the unpacking path, it creates a tarball fetcher input, supplies the expected hash when present, obtains an accessor through the input cache, and mounts that input into the evaluator view.

That replaces a path which fetched the unpacked tree to the store directly. The non-unpacking file download retains a separate path in the implementation. This is a specific change to how a source tree enters evaluation, and should not be described as a universal deferral of every download performed by Nix.

The distinction is useful for code using builtins.fetchTarball to import definitions. Such an import can benefit from reading the tree without eagerly copying it as a store object. If a later expression uses that tree as a derivation source, the normal materialization boundary still applies. The identity and dependency requirements continue to determine what the build needs.

I find this more interesting than a broad speed claim because the optimization follows actual use. The evaluator can read definitions through an accessor while the build system receives store objects at its own boundary. The code now represents that distinction instead of paying the build-input cost at the first source read.

The patch also propagates context through value printing. That is relevant when a command exports a result for another process to use. A source reference that remains entirely inside evaluation can use the mounted view, while an exported path may need to be usable outside it. Context collection gives the command a way to identify those references and force the required copy. The implementation therefore changes both source ingestion and the places that consume its results. It cannot rely on a single fetch operation to know every later use.

A virtual view still has compatibility obligations

The release notes preserve behavior for path conversion and file reads through a flake outPath. Existing expressions can depend on those operations without explicitly requesting a source copy. The evaluator therefore has to maintain a coherent relationship between its mounted view and the physical filesystem.

That compatibility requirement is also why a reported path cannot be interpreted solely as evidence that a new physical directory was just written. In this implementation, it can identify a mounted source awaiting materialization. The same string serves the established language interface while the underlying operation performs less immediate work.

The practical gain is reduced unnecessary copying during ordinary package-set evaluation. Packages that genuinely need the source tree still obtain it, and source identity still has to be computed. Nix 2.35 changes when the bytes enter the store, with explicit checks at the points where later operations depend on their physical presence.