Flake.lock, duplicates and transitive dependencies - why?

tl;dr flake dependency graphs get turned into trees unless you use .follows and I can’t figure out why, I also don’t know why flake.lock contains transitive dependencies

Current behavior

Diagram meaning

Since this question is only about dependencies between flakes, the diagram represents a flat directory structure of flakes with no outputs, like this:

Represents the following repository:

/a/flake.nix
/b/flake.nix
/c/flake.nix

/a/flake.nix:

{
  inputs = {
    b.url = "path:../b";
    c.url = "path:../c";
  };
  outputs = { ... }: { };
}

/b/flake.nix, /c/flake.nix:

{
  inputs = { };
  outputs = { ... }: { };
}

Currently, flake evaluation constructs its dependency graph by recursively traversing inputs from the current flake and creating a tree of dependencies (unless overridden by .follows). When the same flake is encountered multiple times while building the graph, each encounter results in a separate instance of that flake.

Here’s a dependency graph that illustrates this:

A user’s natural expectation would be that the final dependency graph would only contain one instance of d and e. Instead, flake.lock from the perspective of a looks like this:

And if we were to add f and g following the same pattern, each of those would have four instances, and so on. It gets extra fun with cycles, as it’ll work if you only use one .follows, but one of the lockfiles will also contain the entire contents of the other. This isn’t a problem in many real use cases, and the assumption that the dependency graph resembles a tree mostly holds. A notable exception is that almost everything depends on nixpkgs, which is addressed by .follows.

However, this is not the case in a monorepo, where all your flakes use the same version of your internal dependencies. Dependency graphs can get quite tangled and there’s a real possibility that the exact same version of the exact same flake might be instantiated several times. Flake evaluation is (supposed to be) pure, so this is not a correctness problem, but a performance one.

A bigger problem is that the entire dependency graph gets written to flake.lock, including transitive dependencies. Conceptually, the job of the lockfile is to pin exact versions of more broadly specified dependencies. Including transitive dependencies does not fit into that and causes additional problems. It’s not particularly bothersome for external dependencies, where transitive dependencies can simply be thought of as part of that external dependency, but when depending on path: flakes within the same repo it goes from redundant to harmful. That part of the lockfile is automatically generated and should not be committed to source control, but it’s contained within the same file that contains pinned external dependencies. If you update a flake’s inputs, you also have to remember to manually update all the flakes that depend on it.

Workarounds

Don’t have a rat’s nest of internal dependencies between flakes in a monorepo. Instead, just have a single flake containing all your external dependencies. No more nix build .#executable for you, now it’s nix build ../deps#whatwouldvebeenaflake-executable.

An alternative would be to have that centralized /deps/flake.nix, but also have per-project flakes that contain only one input that refers to path:../deps. All flake.locks except /deps/flake.lock would be gitignored, but then you’d still have the problem of nix not automatically picking up changes to dependencies of path: dependencies without manually running nix flake update. At least rm is shorter to type, and it wouldn’t spam your git logs with generated JSON.

But why?

Implementation detail? Because other languages do it this way? Maybe, but I think I’m missing something. I can’t see a good reason why transitive dependencies couldn’t just be picked up from /nix/store, utilizing the same lazy fetching as the inputs themselves. And if we’re hypothetically changing the lockfile to contain only external pins, then we might as well get rid of the tree-ification by deduplicating the flakes before evaluation, since it doesn’t impact correctness.

So my question is, why not do it this way? I highly doubt this is something that was overlooked during the design process, so what feature of flakes necessitates the current design?

1 Like

What nix with flakes does is the same as most any other language with lockfiles.
Only difference is the dependency injection (.follows).

Of course. That’s what a lockfile is meant to do.

It does. Why wouldn’t it??

Of course there is 3rd party tooling to address this, e.g. GitHub - NotAShelf/flint: Stupid simple utility for linting your flake inputs which indicates which inputs have some opportunity for dedup. Or you can check nix flake metadata itself.

1 Like

I think I did a poor job at explaining. In this scenario (deps between flakes in a monorepo), the locking is partial - the direct path: dependency doesn’t even have a hash. Its entry in flake.lock looks like this:

"a": {
  "locked": {
    "path": "../a",
    "type": "path"
  },
  "original": {
    "path": "../a",
    "type": "path"
  },
  "parent": []
}

It’s referring directly to the files inside the monorepo, not something external with different versions. What is locked are the transitive dependencies. You can get a scenario where you’re working with a new flake and old transitive dependencies.

I just checked, and with DetSys Nix 2.32.1, it doesn’t get deduplicated. Two entries in flake.lock result in two prints when I put a builtins.trace, and if I link them with a .follows it results in one print. Unless you were referring to the “doesn’t impact correctness part”, in which case I can’t see how it would unless you’re using --impure in a contrived scenario.

Thanks for this! Seems to solve the duplication problem, but not the redundant lockfile problem. For comparison, pnpm’s workspaces default to using a shared lockfile at the root of the workspace precisely to avoid that sort of thing. The workaround I posted is just doing it manually since Nix is flexible enough for that.

Thank you for making this thread! I was similarly surprised when I recently learned that this is the behavior.

I don’t think that’s true. The three ecosystems I’m most familiar with that use lockfiles are JavaScript (e.g. npm and Bun), Rust (Cargo), and Python (e.g. uv). Unless I’m mistaken, each of those tools deduplicates transitive dependencies when possible, only resorting to duplication when necessary due to non-overlapping semver ranges.

Of course, the main difference there is that Nix flakes don’t have semver as a concept; but also, deduplication doesn’t even happen when inputs have the exact same input url specification, e.g. two transitive inputs specified as github:NixOS/nixpkgs/nixpkgs-unstable or two transitive inputs specified as github:numtide/flake-utils.

This is true, and this is actually the thing that confused me the most. Specifically, when I learned that flake inputs do this tree-ification by default, I was surprised, but then my updated assumption was that they must do it to prioritize reproducibility by having the locked versions in that subtree match the versions from the dependency’s flake.lock (unlike other ecosystems). That seems like a cool property that may be worth the tradeoff of duplicating dependencies.

But then I learned that that isn’t the case either: Nix flakes give each input its own tree of transitive dependencies by default (modulo follows), but also ignore transitive dependencies’ flake.lock files, which seems to me (as a bit of a Nix novice) like it provides the maximum possible opportunities for inconsistent/incorrect dependencies to be pinned.

That’s what I was referring to.

Okay so you’re talking about entries where the narHash are identical? Sure I guess those could be deduplicated. But if they’re pointing at different revs then they should not be deduped. That will impact correctness.

And as far as subflakes go, they are basically broken. Currently not usable IMO.

Correct. Dedup should not happen, either. Otherwise it’s not a lockfile!

Unless they have the same narHash, implying they are pointing at the same commit - that would be a useful case to try to dedup (but one that nix currently doesn’t handle).

Some issues related to this topic:

2 Likes

Could you clarify what you mean by this? As I mentioned, the four other lockfile-using package managers I’m most familiar with (across three ecosystems) do deduplication. Are you saying those are not “real” lockfiles in some sense?

Can you give a concrete example? From my experience, lockfiles of dependencies are respected, but not required (if a dependency doesn’t have a lockfile or that lockfile lacks an entry for one of its dependencies, nix doesn’t abort but instead runs the same locking logic as it would if it were a dependency of the current flake).

So I’m not going crazy after all. And yes, with regards to dedup I only meant it for entries with the same narHash.

That first one is a bit too much spooky action at a distance IMO, but ergonomic improvements to specifying the dependency graph are definitely necessary. Things like inputs.*.{follows, inputs.*.follows = ...} (that is, creating synthetic inputs that don’t exist in any flake’s top-level inputs).

And a bit of a tangent, but since we’re creating synthetic inputs, we might as well also make it possible to create flakes inline to act as parameters (since currently passing parameters to flakes is done by creating a subflake). Though this would require the restrictions on syntax inside inputs to be relaxed if we don’t want it to be janky, and I’m not quite sure why those are in place.

That second issue definitely sounds like what I’m asking about, though. Shame it seems to have stalled.

1 Like

Okay, yes, then I 100% agree. It’s redundant to reproduce what will be the same tree X times. .follows is a practically a bandaid for this.

If I wrote code for nixpkgs-unstable in January and then another project uses my code and also depends on nixpkgs-unstable in June, they are pointing at different commits. Dedup-ing that would lead to breakage and be ultimately surprising.
i.e., if you cut out the transitive dependency’s nixpkgs dependency, then the dependency will break with basically no way to fix it without forking the dependency.

There’s really only one case where de-duping is fine:

Sure, here’s an example. As it turns out, I was somewhat incorrect: as you pointed out, Nix doesn’t completely ignore flake.lock, and seems to be inconsistent about when it’s ignored and when it’s not. But it’s good that it at least respects it in some cases.

Start with this flake.nix:

{
  inputs = {
    rust-overlay.url = "github:oxalica/rust-overlay";
  };
  outputs = { ... }: { };
}

Currently the latest rust-overlay commit is 1d1c8a0, and its Nixpkgs commit is 18dd725. If I run nix flake lock, those are exactly what I get in my flake.lock. So far so good.

But of course, a real flake.nix wouldn’t look like this; it would also depend directly on Nixpkgs. So let’s add that:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
  };
  outputs = { ... }: { };
}

Now the Nixpkgs entry for rust-overlay in flake.lock is renamed to nixpkgs_2, but otherwise remains the same; and a new nixpkgs entry is added at cb82756. Still fine so far.

Next, make rust-overlay use our same version of Nixpkgs:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    rust-overlay = {
      url = "github:oxalica/rust-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs = { ... }: { };
}

Now there’s only one Nixpkgs entry in flake.lock, with commit cb82756. Still fine.

Now, revert flake.nix back to what it looked like before we manually deduplicated Nixpkgs:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
  };
  outputs = { ... }: { };
}

After this change, the rust-overlay flake.lock is completely ignored, and now we have two Nixpkgs entries in flake.lock that both point to commit cb82756.

Yeah that sounds like a bug… you have to nix flake update just to get the coherent locking back.

OK, in that case I opened a GitHub issue about this: Removing `follows` does not respect dependency's `flake.lock` · Issue #14339 · NixOS/nix · GitHub

1 Like

lockFlake(): When updating a lock, respect the input's lock file by edolstra · Pull Request #13437 · NixOS/nix · GitHub fixes this apparently, just needs a backport to 2.28 or whatever the stable version will be for the 25.11 release.

I mentioned this in the GitHub issue thread as well, but that doesn’t seem to be the case: I tried the same repro on the latest Nix commit 36ee38e via nix shell nix, and got the same result.

1 Like