~/ ~/documents ~/software ~/pictures github (opens in new tab)

The Self-Referential Let: Orchestrating Scripts with passthru and finalAttrs

In the Nix ecosystem, we emphasize the purity and isolation of derivations. But once software is compiled, you almost always need companion tooling to operate it: scripts for database migrations, infrastructure provisioning, deployment orchestration, or Software Bill of Materials (SBOM) generation.

The traditional pattern was to define these scripts as separate packages or bundle them directly into a package’s passthru attribute using a recursive let ... in block. While the self-referential let solved the initial problem of scripts referencing the package and each other, it introduced a subtle defect when packages were overridden.

Understanding why the recursive let fails under .overrideAttrs, and how modern Nix solves it with open recursion, is key to writing robust, maintainable Nix expressions.

The Circular Dependency Problem

Imagine a derivation pkg that builds a web service. You want to bundle operational scripts alongside it in passthru: an init script for database setup, a plan script for infrastructure dry-runs, and a deploy script that executes the plan and uploads the built artifact (pkg).

If you try to declare this inside stdenv.mkDerivation using a naive rec { ... } attribute set, Nix will fail because rec operates on raw input attributes rather than evaluated outputs.

To work around this, developers historically defined the package in a recursive let block, referencing pkg inside its own passthru definition:

let
  pkg = stdenv.mkDerivation {
    pname = "my-service";
    version = "1.0.0";
    # ...

    passthru = {
      init = pkgs.writeShellScriptBin "init" ''
        echo "Initializing database..."
      '';

      plan = pkgs.writeShellScriptBin "plan" ''
        ${pkg.init}/bin/init
        echo "Planning deployment..."
      '';

      deploy = pkgs.writeShellScriptBin "deploy" ''
        ${pkg.plan}/bin/plan
        echo "Deploying artifact from ${pkg}..."
      '';
    };
  };
in
pkg

This works for basic use cases: passthru.deploy correctly bakes in the exact store paths for pkg.plan, pkg.init, and pkg itself.

Lexical Scope Breaks Overrides

The fatal flaw of the self-referential let appears when a downstream user attempts to override the package using .overrideAttrs:

myCustomService = myService.overrideAttrs (oldAttrs: {
  version = "2.0.0";
  src = ./new-source;
});

Because let bindings are lexically closed, the variable pkg inside the passthru definitions remains permanently bound to the original, un-overridden derivation.

When myCustomService is evaluated, Nix produces a new derivation for version 2.0.0, but myCustomService.passthru.deploy still points back to the version 1.0.0 store path. The override silently fails to propagate to the bundled scripts.

Open Recursion with finalAttrs

To support open recursion and ensure overrides propagate correctly, modern Nixpkgs introduced the finalAttrs pattern for stdenv.mkDerivation. Instead of passing a plain attribute set, you pass a function receiving finalAttrs:

stdenv.mkDerivation (finalAttrs: {
  pname = "my-service";
  version = "1.0.0";
  # ...

  passthru = {
    init = pkgs.writeShellScriptBin "init" ''
      echo "Initializing database..."
    '';

    plan = pkgs.writeShellScriptBin "plan" ''
      ${finalAttrs.passthru.init}/bin/init
      echo "Planning deployment..."
    '';

    deploy = pkgs.writeShellScriptBin "deploy" ''
      ${finalAttrs.passthru.plan}/bin/plan
      echo "Deploying artifact from ${finalAttrs.finalPackage}..."
    '';
  };
})

finalAttrs relies on fixed-point evaluation (lib.fix). Calling .overrideAttrs re-evaluates the fixed point, dynamically re-binding finalAttrs.finalPackage and finalAttrs.passthru so all script paths remain synchronized.

While finalAttrs supersedes let inside stdenv.mkDerivation, the recursive let remains essential outside derivation definitions, such as linking packages, apps, and devShells in flake.nix, or structuring non-derivation attribute sets and private helper functions.

When structuring operational scripts across a codebase: