Why this matters
Inputs are your dependency list and flake.lock is your lockfile. You already reason about this with pnpm: one lockfile, exact versions, deliberate updates. The difference is that a Nix input can be an entire package repository, so one careless update can rebuild everything. Knowing the lock format lets you review it in a PR like any other diff.
Concept
Input URL forms
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-pinned.url = "github:NixOS/nixpkgs/b4fd65b198c599cbe814fcb9f42d25d021595ec9";
home-manager.url = "github:nix-community/home-manager/release-25.05";
my-lib.url = "git+https://git.example.com/me/lib.git?ref=main";
local.url = "path:./subflake";
indirect.url = "nixpkgs";
};
outputs = inputs: { };
}github:owner/repo[/ref-or-rev]— fetches a tarball through the GitHub API. Fastest.refcan be a branch, tag or full commit hash.git+https://...?ref=...&rev=...— real git clone. Works for any host and for submodules (?submodules=1).path:./dir— a local directory. Not copied to the store with git filtering.nixpkgswith no scheme — an indirect reference resolved through the flake registry (nix registry list). Fine for scratch work, bad for reproducibility because the registry is machine state.
Non-flake inputs
Any repository can be an input even if it has no flake.nix:
{
inputs.fish-plugin = {
url = "github:PatrickF1/fzf.fish";
flake = false;
};
outputs = { self, fish-plugin }: {
pluginSource = fish-plugin.outPath;
};
}With flake = false you get a plain source tree: outPath, rev, lastModified. No outputs.
follows: one nixpkgs, not five
Every input that itself depends on nixpkgs brings its own pinned copy. Three inputs means three nixpkgs downloads and three evaluations. follows rewires a transitive input to one of yours:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
systems.url = "github:nix-systems/default";
flake-utils = {
url = "github:numtide/flake-utils";
inputs.systems.follows = "systems";
};
};
outputs = inputs: { };
}Now flake-utils/systems is an alias for your systems. The same pattern for home-manager.inputs.nixpkgs.follows = "nixpkgs" is what keeps a Home Manager setup to one nixpkgs.
Reading flake.lock
The lock is a graph of nodes. Real file produced by the flake above:
{
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"flake-utils": {
"inputs": {
"systems": [
"systems"
]
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
}(Output trimmed to two nodes with jq.) Read it like this:
root.inputsmaps your input names to node ids.originalis what you wrote inflake.nix.lockedis what Nix resolved: exactrev, andnarHash, the hash of the fetched tree. The hash is what makes the lock trustworthy; the rev alone is not enough for a tarball."systems": ["systems"]as a list instead of a string is afollowsedge: it is a path from root, not a node id.
Updating
Verified on Nix 2.35.2 with nix flake update --help:
nix flake update— re-resolves every input. Expect a large diff.nix flake update nixpkgs— only that input. This is the modern form.nix flake lock— adds missing inputs to the lock, changes nothing already locked.nix flake lock --update-input nixpkgsstill exists but is the older spelling of the same thing.
Updating and pinning goes deeper.
Try it
In the flake directory from Flake anatomy:
nix flake metadata --json | jq '.locks.nodes.nixpkgs.locked | {rev, lastModified, narHash}'{
"rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9",
"lastModified": 1790689126,
"narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg="
}Ask Nix to update an input that does not exist. It warns, it does not error:
nix flake update boguswarning: 'bogus' does not match any input of this flakeExercise
Write a flake.nix with two inputs: nixpkgs (nixos-unstable) and nixpkgs-stable (the nixos-25.05 branch). Make outputs return { stable = nixpkgs-stable.lib.version; unstable = nixpkgs.lib.version; }. Do not run it yet if you want to save bandwidth; just check it parses with nix-instantiate --parse flake.nix.
Solution
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-25.05";
};
outputs = { self, nixpkgs, nixpkgs-stable }: {
stable = nixpkgs-stable.lib.version;
unstable = nixpkgs.lib.version;
};
}Running nix eval .#stable would download a second nixpkgs tree (tens of MB). Two nixpkgs inputs is a legitimate pattern when one package is broken on unstable.
Trap
An output argument you did not declare is silently turned into an input. This flake has no inputs block at all:
{
outputs = { self, nixpkgs }: { x = 1; };
}nix eval .#x prints 1 after adding an implicit input resolved through the registry:
warning: not writing modified lock file of flake 'git+file:///tmp/lxJ-ddvG':
• Added input 'nixpkgs':
'https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1082290.b6c8664de9b6/nixexprs.tar.zst?narHash=sha256-k8Fu4c9Z%2B4Nh7mUr0cfw%2B%2BITQiyEhlWxoJBOkI3tOcQ%3D' (2026-09-29)
1It works on your machine and fails on a machine with a different registry. Always declare inputs.nixpkgs.url explicitly.