NIX_PATH, Channels, and Pinning Inputs
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 --listIf 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 nixpkgsDo 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 listThe 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.05Save as default.nix:
# default.nix
let
sources = import ./npins;
pkgs = import sources.nixpkgs { };
in
pkgs.hellonix-buildnpins/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 { }).hellonix-build pin.nixProbe 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.lockon 26.05. - No
<nixpkgs>in production files. Registry pin is fornix shell nixpkgs#, not for CI. npinsonly when flakes are forbidden. One pin tool per repo.fetchTarballwithout a hash is not a pin. Bump URL and hash together.
Try this
echo $NIX_PATHon two machines (or a container vs the host). Compare<nixpkgs>paths.nix flake metadataand write down the nixpkgsrev.npins initin a throwaway dir; opensources.json.git grep -n '<nixpkgs>'on the desk repo — production files should be empty.nix registry listandnix flake metadata— if they disagree, unqualifiednixpkgs#is lying.