zerforschen.plus/content/posts/why-i-do-not-use-flake-utils.md
Vinzenz Schroeter 948fe426f7 tweak first post
- examples
- link to anatomy
2025-06-01 13:37:39 +02:00

4.7 KiB
Raw Blame History

+++ date = '2025-04-06T14:49:03+02:00' draft = false title = 'Why I do not use flake-utils' tags = ['nix'] +++

I have been using Nix for a while now. Around a year ago, I switched everything from the servicepoint library to my machine configuration over to flakes.

For me the biggest advantage flakes bring is not additional functionality. Instead, they are an easier and semi-standardized way to do what you could before.

When learning flakes, you often see flake-utils being used. With it, you can shorten your flakes by not having to specify everything per system.

Edit: The patterns described here as well as flake-parts are also compared in Practical Nix flake anatomy.

Without anything

{
  description = "Flake utils demo - without flakes";

  outputs = { self, nixpkgs, flake-utils }:
  {
    packages."x86_64-linux" = rec {
          hello = pkgs.hello;
          default = hello;
    }
    packages."aarch64-linux" = rec {
          hello = pkgs.hello;
          default = hello;
    }

    # more systems ...
  }
}

With flake-utils

{
  description = "Flake utils demo";

  inputs.flake-utils.url = "github:numtide/flake-utils";

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = nixpkgs.legacyPackages.${system}; in
      {
        packages = rec {
          hello = pkgs.hello;
          default = hello;
        };
      }
    );
}

With function in flake

To make a long story short, here is what I usually do instead:

{
  description = "forAllSystems demo";

  outputs = { self, nixpkgs }:
    let
      supported-systems = [ # [1]
        "x86_64-linux"
        "aarch64-linux"
      ];
      forAllSystems =
        f:
        nixpkgs.lib.genAttrs supported-systems /* [2] */ (
          system:
          f rec {
            pkgs = nixpkgs.legacyPackages.${system}; # [3]
            inherit system;
          }
        );
    in
    {
      packages = forAllSystems ({ pkgs, ... }: rec {
        hello = pkgs.hello;
        default = default;
      });
    }
}

Thats definitely more code! - Yes, but it also includes more information in the flake, while getting rid of an external dependency. While more code can be intimidating for beginners, it actually helps remove a barrier to understanding how the flake works in this case. For me, it wasnt a problem to ignore boilerplate like this at first, slowly learning more language features until I finally understood everything.

At [1], the supported systems are specified. I personally use x86_64-linux and aarch64-linux, but I also usually support x86_64-darwin and aarch64-darwin in public projects. If you want to support any system, you can use nixpkgs.lib.system.flake-exposed at [2] instead of defining your own list.

Example variations

Because the definition is right inside the flake, you can tweak what gets passed to the function. By doing that, you have to explicitly add the new identifier to each part that uses it, instead of having a global let binding that is implicitly used. system being available here is another bonus, as otherwise this would require duplicate lets everywhere.

An example for how to do it is already right there: at [3], pkgs is provided. Some real-world usages I wrote or encountered:

Another possible tweak: You may want to define separate supported systems for each output. This is useful, for example, if the target environment you're developing for cannot support a development shell.

Conclusion

For me, the trade-offs are worth it, as they provide greater transparency and control over the flake configuration. For you, this may be different, in which case keep using it! Ultimately, it's also matter of personal preference.