Why this matters
Every flake so far hardcoded x86_64-linux. The moment you open the repo on a Mac or an ARM VPS, nix develop says the output does not exist. The fix is a one-line helper. Knowing it is a plain function also lets you read the many flakes that use flake-utils or flake-parts without treating them as magic.
Concept
The problem
{
packages.x86_64-linux.default = "built for x86_64";
packages.aarch64-linux.default = "built for aarch64";
devShells.x86_64-linux.default = "shell for x86_64";
devShells.aarch64-linux.default = "shell for aarch64";
}Four attributes, two of each, and every new output doubles again. Nix cannot know your system at evaluation time in pure mode, so you must enumerate.
lib.genAttrs
lib.genAttrs names f builds an attribute set with one key per name and f name as the value. Verified:
nix eval --impure --expr 'let pkgs = import (builtins.getFlake "nixpkgs") {}; systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ]; forAllSystems = pkgs.lib.genAttrs systems; in forAllSystems (system: "pkgs for ${system}")'{ aarch64-darwin = "pkgs for aarch64-darwin"; aarch64-linux = "pkgs for aarch64-linux"; x86_64-linux = "pkgs for x86_64-linux"; }That is the whole trick. Applied to a flake:
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system};
in { default = pkgs.hello; }
);
devShells = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system};
in { default = pkgs.mkShellNoCC { packages = [ pkgs.hello ]; }; }
);
};
}Laziness means the aarch64-darwin branch is never evaluated on your machine unless something asks for it. No cost.
flake-utils
flake-utils.lib.eachDefaultSystem inverts the nesting: you write packages.default once and it inserts the system level for you.
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
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.default = pkgs.hello;
devShells.default = pkgs.mkShellNoCC { packages = [ pkgs.hello ]; };
}
);
}Cost: one more input in the lock, and the "default systems" list lives in a third flake (nix-systems/default). Outputs that have no system level (overlays, nixosConfigurations, templates) must go outside the eachDefaultSystem call with //, which is where mistakes happen.
flake-parts
flake-parts applies the NixOS module system to flakes: perSystem = { pkgs, ... }: { ... } plus flake = { ... } for system-independent outputs. Powerful when a flake grows modules, many packages and shared options. Overkill for one dev shell. You meet the module system in The module system; come back to flake-parts after that.
Recommendation
Use plain genAttrs. It is six lines, zero inputs, and every output stays where nix flake show expects it. Reach for flake-parts only when a flake becomes a project in itself.
Try it
Write the genAttrs flake, git add, and run:
nix flake showgit+file:///tmp/lx18-IqCq
├───devShells
│ ├───aarch64-darwin
│ │ └───default omitted (use '--all-systems' to show)
│ ├───aarch64-linux
│ │ └───default omitted (use '--all-systems' to show)
│ └───x86_64-linux
│ └───default: development environment 'nix-shell'
└───packages
├───aarch64-darwin
│ └───default omitted (use '--all-systems' to show)
├───aarch64-linux
│ └───default omitted (use '--all-systems' to show)
└───x86_64-linux
└───default: package 'hello-2.12.3'"omitted" confirms laziness: nothing for other systems was evaluated. Then:
nix eval .#packages --apply builtins.attrNames[ "aarch64-darwin" "aarch64-linux" "x86_64-linux" ]Exercise
Refactor the repeated let pkgs = nixpkgs.legacyPackages.${system}; into a helper so each output body receives pkgs directly: packages = forAllPkgs (pkgs: { default = pkgs.hello; });.
Solution
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
forAllPkgs = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
packages = forAllPkgs (pkgs: { default = pkgs.hello; });
devShells = forAllPkgs (pkgs: { default = pkgs.mkShellNoCC { packages = [ pkgs.hello ]; }; });
};
}forAllPkgs composes genAttrs with the lookup. Same output, less noise.
Trap
Combining eachDefaultSystem with a system-independent output using //:
{
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: {
packages.default = nixpkgs.legacyPackages.${system}.hello;
}) // {
overlays.default = final: prev: { };
};
}This one is correct. The trap is writing packages.default = ...; overlays.default = ...; inside the eachDefaultSystem body: you get overlays.x86_64-linux.default, and nix flake check fails with:
error: overlay is not a function, but a set insteadRecognise it by an unexpected system name under overlays or templates in nix flake show. Remember // is shallow: it replaces whole top-level keys, see Attribute sets.