NIX_PATH, Channels, and Pinning Inputs

Updated

September 12, 2026

NIX_PATH, Channels, and Pinning Inputs

<nixpkgs> is whichever tree $NIX_PATH names today. The boring default is: do not import from Nix PATH in anything you ship; pin with a flake lock on nixos-26.05, or npins if a repo cannot use flakes yet.

This is the “input determinism” lesson: same expression, same bytes, six months later.

Mental model

Mechanism What it pins Use
NIX_PATH / <nixpkgs> Whatever the machine has Repl, throwaway nix eval --impure
Channels (nix-channel) A moving URL per user Legacy; do not start new work here
npins / niv JSON of rev + hash in git Non-flake trees
flake.lock rev + narHash Desk default
<nixpkgs>     →  lookup NIX_PATH  →  laptop A ≠ laptop B
flake.lock    →  git-committed hash →  laptop A = laptop B = CI

niv still works. npins is the smaller, currently maintained pin tool. Flakes made both optional. fetchTarball without a hash is not a pin.

Worked examples

Case 1: See what <nixpkgs> actually is

echo "$NIX_PATH"
nix eval --impure --expr '<nixpkgs>'

Output (shape):

nixpkgs=/nix/var/nix/profiles/per-user/root/channels/nixpkgs:...
/nix/store/…-nixpkgs-unpacked

Two engineers with different channels evaluate different hello. nix-channel --update on a server is how prod drifts from CI.

nix-channel --list

If this prints nixpkgs … on a flake machine, that channel is still what <nixpkgs> sees. You do not need it. After the flake lock exists:

# optional: drop the unused channel so PATH lookups cannot surprise you
nix-channel --remove nixpkgs

Do not remove it until flake.lock is committed and nix flake metadata shows the 26.05 rev.

On NixOS the daemon’s path is nix.nixPath / nix.settings.nix-path, not your user’s leftover ~/.nix-defexpr. A flake-first host can pin the lookup so even --impure <nixpkgs> is 26.05:

# nix-path.nix
{ pkgs, ... }:
{
  nix.nixPath = [ "nixpkgs=${pkgs.path}" ];
}

That is still impure for files that import <nixpkgs>. The boring production file uses flake.lock, not this. It only stops a surprise channel from winning nix-shell -p.

Case 2: Flake pin (the default)

Save as flake.nix:

# flake.nix
{
  description = "Desk pin";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { self, nixpkgs }: {
    packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
  };
}
nix flake lock
nix flake metadata --json | jq -r '.locks.nodes.nixpkgs.locked.rev'

Commit flake.lock. That rev is the pin. Branch nixos-26.05 plus a lock hash — not master.

nix registry list

The global registry (flake:nixpkgs -> github:NixOS/nixpkgs/nixpkgs-unstable) is why unqualified nixpkgs#hello on a laptop is not 26.05. Pin it (nix registry pin nixpkgs github:NixOS/nixpkgs/nixos-26.05) or always type the URL / use .. CI uses the repo lock, not your registry. nix.settings.flake-registry can be set to an empty JSON on servers so they do not phone home for registry.json.

Case 3: npins without flakes

nix run github:andir/npins -- init
nix run github:andir/npins -- add github NixOS nixpkgs --branch nixos-26.05

Save as default.nix:

# default.nix
let
  sources = import ./npins;
  pkgs = import sources.nixpkgs { };
in
pkgs.hello
nix-build

npins/sources.json is the lockfile. Commit it. niv is the same idea with nix/sources.nix — do not mix niv and npins in one repo.

Case 4: Override NIX_PATH for one command (lab only)

NIX_PATH=nixpkgs=flake:nixpkgs nix eval --impure --expr 'import <nixpkgs> {}' --apply 'p: p.lib.version'

Useful to compare. Not a team pin. The next shell forgets it.

Case 5: Fetch a tarball by hash (no flake, no npins)

Save as pin.nix:

# pin.nix
let
  nixpkgs = builtins.fetchTarball {
    url = "https://github.com/NixOS/nixpkgs/archive/nixos-26.05.tar.gz";
    sha256 = "0000000000000000000000000000000000000000000000000000";
  };
in
(import nixpkgs { }).hello
nix-build pin.nix

Probe the hash once (lib.fakeHash / the got: line). The tarball URL nixos-26.05 moves; the sha256 does not. When you want a newer 26.05 commit, bump both. fetchTarball without sha256 is a channel with extra steps.

The trap

The trap is import <nixpkgs> {} in configuration.nix on a server. Rebuilds follow the last nix-channel --update. CI, the laptop, and prod disagree.

The other trap is pinning master. There is no “26.05” in the incident report. Branch nixos-26.05 plus a lock hash.

A third: fetchTarball of nixos-26.05.tar.gz without sha256 — GitHub serves a moving tarball. A fourth: mixing npins and flake.lock for the same nixpkgs.

The boring rule

  • Ship flakes + flake.lock on 26.05.
  • No <nixpkgs> in production files. Registry pin is for nix shell nixpkgs#, not for CI.
  • npins only when flakes are forbidden. One pin tool per repo.
  • fetchTarball without a hash is not a pin. Bump URL and hash together.

Try this

  1. echo $NIX_PATH on two machines (or a container vs the host). Compare <nixpkgs> paths.
  2. nix flake metadata and write down the nixpkgs rev.
  3. npins init in a throwaway dir; open sources.json.
  4. git grep -n '<nixpkgs>' on the desk repo — production files should be empty.
  5. nix registry list and nix flake metadata — if they disagree, unqualified nixpkgs# is lying.