Skip to content

Anti-Patterns

Overview

Language-level habits that hurt readability or reproducibility. Tracked against nix.dev best practices, the Nixpkgs overlays chapter, and Nix builtins docs—not ops mistakes (channels, nixos-rebuild flags). Prefer explicit scope (let / inherit), pinned inputs, shell-safe interpolation, and deep merges only when you mean them.

Details

Unquoted URLs

The language still accepts bare URLs (https://example.com). RFC 45 deprecated them; always quote URL strings.

Wide with

with pkgs; at file top (or nested with) dumps a huge attribute set into lexical scope. Readers cannot see where bare names come from; tools cannot resolve them without evaluating. Nested with makes origin ambiguous.

# avoid
with import <nixpkgs> { };
# … bare names from the whole set

# prefer
let
  pkgs = import <nixpkgs> { };
  inherit (pkgs) curl jq;
in
  # use curl, jq, or pkgs.foo

Small list scopes (buildInputs = with pkgs; [ … ]) are less bad but still surprising under shadowing rules. To avoid with in lists: builtins.attrValues { inherit (pkgs) curl jq; }. Prefer let / inherit.

rec overuse

rec { … } puts every attribute in scope of every other. Shadowing an outer name with itself (let a = 1; in rec { a = a; }) yields infinite recursion that is hard to debug.

# infinite recursion
let a = 1; in rec { a = a; }

# prefer
let
  a = 1;
in {
  inherit a;
  b = a + 2;
}

Or name the set and refer through that binding (argset.a). See rec and fixed points.

<nixpkgs> / lookup paths

<nixpkgs> resolves via $NIX_PATH — host state, often a channel tip that differs per machine. Fine for tiny examples; for real configs pin nixpkgs (flakes, fetchTarball + hash, npins, or a fixed NIX_PATH under VCS). See flake and purity boundaries.

Impure default Nixpkgs config

Even with a pinned path, import nixpkgs { } still reads host config/overlays from the filesystem by default. For reproducible imports, pass empty sets explicitly:

import nixpkgs { config = { }; overlays = [ ]; }

Unpinned fetchers

fetchTarball / tip-of-branch URLs without a content hash can change across runs (tarball-ttl, caches). Pin with sha256 (or use flake inputs). Details: import and fetch, purity boundaries.

src = ./. and directory names

Copying a path literal like ./. into the store names the store path after the parent directory. Different checkout directory names → different store paths and needless rebuilds.

# avoid
src = ./.;

# prefer
src = builtins.path { path = ./.; name = "myproject"; };

See path and filesystem.

Shallow // (and overlays)

Attrset update (//) and overlay composition replace nested attrsets; they do not deep-merge. { a = { b = 1; }; } // { a = { c = 3; }; } drops b. When deep merge is intended, use lib.recursiveUpdate (see lib helpers). Overlay return values combine the same way — see overlays pattern.

# shallow — loses a.b
{ a = { b = 1; }; } // { a = { c = 3; }; }

# deep merge when that is the intent
lib.recursiveUpdate { a = { b = 1; }; } { a = { c = 3; }; }

Overlay final vs prev (and infinite recursion)

Per the Nixpkgs manual: use final for dependencies of packages you add or override (resolve against the finished set); use prev for the package being replaced and for helpers such as callPackage. Swapping them breaks consistency across the fixed-point set and often causes infinite recursion encountered.

# avoid — base package via final forces the attr being defined
final: prev: {
  hello = final.hello.overrideAttrs (old: { /* … */ });
}

# prefer — replace via prev; take deps from final
final: prev: {
  hello = prev.hello.overrideAttrs (old: {
    nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.someTool ];
  });
  myPkg = prev.callPackage ./my-pkg.nix { inherit (final) someDep; };
}

Also avoid forcing final in a top-level let of the overlay body before returning attrs—that can demand the fixed point too early. See overlays pattern and callPackage.

pkgs.extend / appendOverlays inside Nixpkgs

These recompute the Nixpkgs fixed point and are expensive. The overlays chapter says not to use them in nixpkgs itself; prefer composing overlays at import / nixpkgs.overlays instead.

IFD in flakes without need

Import from derivation (IFD) realises a store path mid-evaluation (import, readFile, … on a derivation output). Slow, sequential, and often banned in CI (allow-import-from-derivation = false). Pure flake eval does not disable IFD by itself.

# avoid — eval reads a built store path
let
  generated = pkgs.runCommand "gen.nix" { } ''echo '{ x = 1; }' > $out'';
in
  import generated

# prefer — commit or generate the .nix at edit time; eval only sources
import ./generated.nix

Ambient / mutated PATH

Builders and scripts that assume host /usr/bin tools, or prepend host paths onto $PATH, smuggle impurities into “hermetic” builds. Declare tools as derivation inputs and call them by store path (or a PATH built only from those inputs).

# avoid
buildPhase = ''
  export PATH=/usr/bin:$PATH
  make
'';

# prefer
nativeBuildInputs = [ pkgs.gnumake pkgs.gcc ];
# sandbox PATH comes from inputs; or call ${pkgs.gnumake}/bin/make explicitly

Unescaped store paths / strings in shell

Interpolating arbitrary strings into shell without quoting breaks on spaces and metacharacters, and can turn data into syntax. Use lib.escapeShellArg / escapeShellArgs (see lib helpers).

# avoid
''
  install -Dm644 ${configFile} $out/etc/app.conf
  ${pkgs.curl}/bin/curl ${url}
''

# prefer
''
  install -Dm644 ${lib.escapeShellArg configFile} $out/etc/app.conf
  ${lib.getExe pkgs.curl} ${lib.escapeShellArg url}
''

Store paths from packages are usually alphanumeric-safe, but user-facing paths, URLs, and filenames are not—escape by default when they become shell words.

Careless builtins.unsafeDiscardStringContext

Strings that interpolate derivations carry a string context so Nix tracks runtime/build dependencies. builtins.unsafeDiscardStringContext drops that tracking: the text of a store path can survive while the dependency is forgotten (wrong GC roots, missing build inputs, “file not found” at build time).

# avoid — path text without dependency edge
let
  p = builtins.unsafeDiscardStringContext "${pkgs.hello}";
in
  pkgs.writeText "note" "see ${p}"

# prefer — keep context so hello stays in the closure
pkgs.writeText "note" "see ${pkgs.hello}"

Only discard when you deliberately need a context-free string and understand the cost; prefer constructing from literals or hashes instead of stripping a live reference. Documented as unsafe in the Nix builtins manual.

Examples

Pinned vs floating nixpkgs input

# floating (example-only)
import <nixpkgs> { }

# pinned + empty host config (hash must match the URL)
import (builtins.fetchTarball {
  url = "https://github.com/NixOS/nixpkgs/archive/<rev>.tar.gz";
  sha256 = "…";
}) { config = { }; overlays = [ ]; }

Fixed store name for local src

pkgs.stdenv.mkDerivation {
  name = "foo";
  src = builtins.path { path = ./.; name = "myproject"; };
}

See also

References