deep·tech·intuition
intermediate ·

Nix Deep Intuition

An experienced engineer's guide to Nix and NixOS

1. One-Sentence Essence

Nix is a purely functional build system whose “memory” is a content-addressed filesystem — every file it produces is identified by the hash of all the inputs that built it, and that single design decision is the source of everything else, good and bad, about Nix and NixOS.

If you remember nothing else: Nix treats your filesystem the way a Haskell program treats its heap. Packages are values, builds are pure functions, and /nix/store is a giant immutable heap of hash-named directories. Everything — nix-shell, NixOS, flakes, Home Manager, rollbacks, reproducibility — is downstream of that one idea.


2. The Problem It Solved

Eelco Dolstra’s 2006 PhD thesis observed something that everyone working with Linux distributions in the early 2000s had felt but never quite named: software deployment is just memory management for the filesystem, and we are spectacularly bad at it.

Look at how a normal Linux distribution installs software. You run apt install postgresql. The package manager unpacks files into /usr/bin, /usr/lib, /etc, runs a postinst script that pokes at system state, drops a config file in /etc/postgresql/, registers a systemd unit, edits /var/lib/dpkg/status — and your system is now a slightly different system than it was a minute ago. If the install fails halfway, you have a half-installed Postgres. If you upgrade and the upgrade fails, you have something somewhere between version 14 and 15. If two packages both want libssl.so.1.1 but different builds of it, one of them loses. If you want to roll back, you can’t, because the old files were overwritten.

This is what people meant by “DLL hell” on Windows and “dependency hell” everywhere else. The root cause is that the filesystem is being used as mutable shared state with no transactions and no version control. Two packages share /usr/lib/libfoo.so because they expect the same file to exist at the same path. The moment they disagree about what should be in that file, the system breaks.

The pre-Nix world had partial solutions. GoboLinux put each version of each package in its own directory (/Programs/Postgresql/14/). NixOS’s spiritual ancestors like Stow used symlinks. Containers (Docker, eventually) sidestepped the problem by giving each app its own filesystem. Language-level package managers (npm, Cargo, virtualenv) reinvented the wheel for their own ecosystem. None of them attacked the underlying issue.

Dolstra’s insight was to lift two ideas from programming language theory and apply them to the filesystem. First, content addressing: instead of putting Postgres at /usr/bin/postgres, put it at a path derived from the hash of everything that went into building it: /nix/store/abc123…-postgresql-15.4/bin/postgres. Now two different builds of Postgres have different paths and cannot collide. Second, purity: builds are functions of their inputs. Same inputs, same output. No reaching out to /usr/lib, no network access, no system state — just sandboxed, deterministic transformation of inputs into outputs. Put those two together and you get a package manager where installation is atomic, rollback is trivial, multiple versions coexist for free, and the same configuration produces the same system on every machine. That’s Nix.

NixOS, which came later, takes this further: if Nix can build any package, why not use it to build the entire operating system — kernel, init system, config files, the works? configuration.nix is a Nix expression that evaluates to a derivation that produces a complete bootable system. You “install” it by atomically switching a symlink.


3. The Concepts You Need

Nix uses a lot of words that mean specific things. Before we go further, here is the vocabulary, grouped by what they’re about. You will not understand the rest of this document without these, so spend the time.

Core data and addresses

  • The Nix store — the directory /nix/store/. Everything Nix ever builds or downloads lives here. It is immutable: files in the store are never modified after they’re written, only added or garbage-collected. This is the “heap” the whole system is built around.
  • Store path — a path inside /nix/store/, like /nix/store/abc123…-hello-2.12.1/. The first 32 characters after the slash are a base-32 cryptographic hash; everything after is a human-readable name. The hash is the package’s identity. Change anything that went into it and you get a different path.
  • Store object — the thing at a store path. Usually a directory containing a package’s files (bin/, lib/, etc.), but it can be a single file too.

Building things

  • Derivation — a precise, language-agnostic recipe for how to build one or more store objects. Concretely, it’s a .drv file in the store containing: the builder executable to run, its arguments, environment variables, declared input derivations, declared outputs, and the system it should be built on. Think of it as a serialized, sandboxed shell-script-plus-context. The derivation is the recipe; the store object is the result of running the recipe.
  • Realising a derivation — actually running the recipe to produce its outputs. This is what people mean when they say “building a package” in Nix. Often the result is fetched from a binary cache instead of being built locally, but the model is the same: the derivation specifies the outputs, and realising means making them exist in the store.
  • Closure — the transitive set of all store paths a given store path depends on, computed by scanning files for references to other store paths. The closure of your Firefox derivation is Firefox plus every library, every transitive dependency, plus the path to bash, etc. This is what you actually need to ship to make Firefox run.

The language

  • Nix language — a small, pure, lazy, functional, dynamically typed language whose only real job is to evaluate to derivations. It has integers, strings, lists, attribute sets (which everyone else would call maps or records), and functions. It is purpose-built and looks weird.
  • Attribute set — Nix’s name for a map. Written { name = "alice"; age = 30; }. The fundamental data structure. A “package” in Nixpkgs is just a particular kind of attribute set.
  • Evaluation vs build — Nix is a two-phase system. Evaluation runs the Nix language to produce derivations (.drv files). Building (or realising) runs the derivations to produce store objects. Evaluation is fast and happens on your machine; building is slow and may be cached or done remotely. Confusing these two phases is the source of many beginner mistakes.

The library and distribution

  • Nixpkgs — the gigantic Git repository at github.com/NixOS/nixpkgs containing Nix expressions for over 120,000 packages plus the entire NixOS module system. It is, by package count, the largest software distribution in the world. When people say “is X packaged for Nix?” they mean “is X in Nixpkgs?”
  • stdenv and mkDerivation — Nixpkgs’s standard build environment and the helper function nearly every package uses. stdenv.mkDerivation knows the conventional Unix build dance (configure, make, make install) and wires it into the raw derivation primitive. Most packages are 20 lines of arguments to mkDerivation.
  • Overlay — a function that takes the previous Nixpkgs package set and returns modifications. The mechanism by which you override a package, patch a library, or add your own packages without forking Nixpkgs.

Versioning and reproducibility

  • Channel — historically, the way users got “the current version” of Nixpkgs. A channel is essentially a named pointer to a git revision that updates over time. The classic NixOS workflow used nixos-channel --update to bump it, much like apt update. Channels are mutable, global, and increasingly considered legacy.
  • Flake — a standardized format (flake.nix + flake.lock) for declaring the inputs (other flakes, typically pinned to git revisions) and outputs (packages, NixOS configurations, dev shells) of a Nix project. Flakes replace channels with explicit, lockfile-pinned dependencies and a uniform interface. As of 2026 they are still officially “experimental” but are how essentially all serious Nix work is done.

State and rollbacks

  • Profile — a symlink in /nix/var/nix/profiles/ that points to a particular store path. Your ~/.nix-profile is a profile. The currently-active NixOS system is a profile. Profiles are how the system “uses” the immutable store: rather than installing anything, Nix builds a directory of symlinks in the store and points the profile at it.
  • Generation — a previous version of a profile. Every time you change a profile, Nix keeps the old symlink around as profile-<n>-link. Rolling back means pointing the profile at an older generation. This is what makes “atomic upgrades with rollback” trivial.
  • GC root — anything that prevents Nix’s garbage collector from deleting store paths. Profiles are GC roots. So are symlinks made by nix-build (./result). The garbage collector deletes any store path not reachable from a GC root.

NixOS-specific

  • Module — a Nix file with a particular shape: it declares options (settings it can be configured by) and config (what those settings actually do, often expressed in terms of other modules’ options). NixOS is built as thousands of composable modules that all get merged into one big configuration.
  • Option — a typed, documented configuration knob declared by a module. services.nginx.enable, networking.hostName, users.users.alice.shell — these are all options. NixOS has roughly 20,000 of them.

That’s the vocabulary. The Mental Model section will lean on every term above; if any of them are still fuzzy, re-read it before continuing.


4. The Distilled Introduction

This section is what you’d get from 15 hours of YouTube tutorials, minus the screen-typing. By the end you should be able to install Nix, use it as a package manager, build a shell environment for a project, write a small package, and configure a NixOS system.

Installing Nix

You have two installers and you should know which:

  • The official Nix installer (sh <(curl -L https://nixos.org/nix/install)) — does the job, but its multi-user setup on macOS has historically been finicky, and uninstalling is awkward.
  • The Determinate Systems installer (curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install) — the de facto recommendation since around 2023. Faster, cleaner uninstall, flakes enabled by default. Use this on macOS or any non-NixOS Linux unless you have a specific reason not to.

On NixOS itself, Nix is already installed; it is the system.

After installing, enable flakes (they’re still nominally experimental, but you want them). Add this to ~/.config/nix/nix.conf:

experimental-features = nix-command flakes

Now you have a nix command that includes the modern subcommands (nix run, nix shell, nix develop, nix build, nix flake).

Using Nix as a package manager (without committing to anything)

The lowest-effort way to use Nix is to run programs ad-hoc without “installing” them at all:

# Run a program without installing it. Nix downloads it to the store, runs it, and that's that.
nix run nixpkgs#cowsay -- "hello"

# Drop into a shell where ripgrep and fd are on your PATH. Exit the shell, they're gone from your environment.
nix shell nixpkgs#ripgrep nixpkgs#fd

This is genuinely Nix’s killer party trick. You never installed anything. No version conflicts with your system. The packages live in /nix/store/, and when you next garbage-collect, they’re gone unless something else is keeping them around.

nix-shell and nix develop — the developer’s main use

The single most popular reason people adopt Nix without using NixOS is per-project development environments. You write a small file describing what your project needs to build and run, and anyone with Nix can reproduce that environment exactly.

With flakes, create flake.nix in your project root:

{
  description = "My project's dev environment";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";

  outputs = { self, nixpkgs }:
    let pkgs = nixpkgs.legacyPackages.x86_64-linux; in {
      devShells.x86_64-linux.default = pkgs.mkShell {
        packages = with pkgs; [ python311 nodejs_20 postgresql_16 ripgrep ];
        shellHook = ''
          echo "Welcome to the project shell"
        '';
      };
    };
}

Then nix develop drops you into a shell with exactly that set of tools. nix flake update updates the pinned versions; flake.lock records exactly which git revision of Nixpkgs you’re using. Commit the flake.nix and flake.lock and your colleague clones the repo, runs nix develop, and is in the same environment as you. Bit-for-bit, on Linux and macOS.

Pair this with direnv (and nix-direnv) and the shell loads automatically when you cd into the directory and unloads when you leave. This is the workflow most working Nix users have, and it is genuinely transformative.

Writing a package

Most packages in Nixpkgs are a single attribute set passed to stdenv.mkDerivation. The simplest possible package:

{ stdenv, fetchurl }:
stdenv.mkDerivation {
  pname = "hello";
  version = "2.12.1";

  src = fetchurl {
    url = "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz";
    hash = "sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=";
  };

  # stdenv knows how to do ./configure && make && make install, so this can be empty.
}

That’s it. stdenv runs through its default phases — unpack, patch, configure, build, install — and produces a store object containing the built hello. The hash is mandatory: Nix refuses to download anything it can’t verify, because that would violate purity. To find the right hash, leave it empty or put a fake value, run a build, and Nix tells you what it actually got.

To override a package in Nixpkgs without forking, write an overlay:

final: prev: {
  hello = prev.hello.overrideAttrs (old: {
    version = "2.12.2";
    src = prev.fetchurl {
      url = "https://ftp.gnu.org/gnu/hello/hello-2.12.2.tar.gz";
      hash = "sha256-...";
    };
  });
}

This is the mechanism for patching anything: a one-line library version bump, a custom kernel config, a hot-fix to your editor. You don’t fork; you compose.

NixOS: configuring a whole system

The flagship use of Nix is NixOS, where the entire operating system is built from a Nix expression. On a NixOS machine, /etc/nixos/configuration.nix is your entry point. The classic shape:

{ config, pkgs, ... }:
{
  imports = [ ./hardware-configuration.nix ];

  boot.loader.systemd-boot.enable = true;
  networking.hostName = "thinkpad";
  time.timeZone = "Europe/London";

  users.users.alice = {
    isNormalUser = true;
    extraGroups = [ "wheel" "networkmanager" ];
    shell = pkgs.zsh;
  };

  environment.systemPackages = with pkgs; [ git vim firefox ];

  services.openssh.enable = true;
  services.postgresql = {
    enable = true;
    package = pkgs.postgresql_16;
    enableTCPIP = true;
  };

  system.stateVersion = "25.11";
}

You apply this with sudo nixos-rebuild switch. NixOS evaluates the configuration, builds a new system closure (which is just another store path), atomically switches the running system to it, and registers the previous system as a “generation” you can roll back to from the boot menu. If the new configuration is broken, reboot, pick the previous generation, and you’re back. This works for kernel upgrades too.

You can also nixos-rebuild build-vm to spin up a VM running your configuration without touching your host — wonderful for testing service config changes.

To search for what options exist, the canonical tool is the NixOS option search at search.nixos.org/options. There are around 20,000 of them. The pattern is always the same: a module declares an option, you set it in your config, the module turns your setting into systemd units, config files, package selections, and so on.

Home Manager and nix-darwin

NixOS configures the system. Home Manager does the same thing for your user environment — dotfiles, shell config, per-user packages — and works on any Linux or macOS where Nix is installed. nix-darwin is the NixOS-equivalent layer for macOS: it manages system defaults, Homebrew (declaratively!), and integrates with Home Manager.

A typical “Nix everywhere” setup looks like: NixOS or nix-darwin handling the OS, Home Manager handling user-level config, both expressed in one flake. This means your laptop and your servers and your work mac all share configuration, and you set up a new machine by cloning a Git repo and running one command.

Garbage collection

The Nix store grows over time. Every build leaves outputs in /nix/store. Every previous generation of your profile is preserved so you can roll back. After a few months of normal use, the store is gigabytes.

Cleanup is explicit:

# Delete generations older than 30 days, then collect everything unreferenced.
sudo nix-collect-garbage --delete-older-than 30d

# Aggressive: delete all old generations (you lose the ability to roll back).
sudo nix-collect-garbage -d

The garbage collector deletes any store path not reachable from a GC root. Roots include: every profile generation, every ./result symlink from nix-build, anything in /nix/var/nix/gcroots/. As long as something points to a store path, it survives.

A bird’s-eye view of the toolchain

You will encounter these commands. The modern nix subcommands and the legacy ones do roughly the same things; both still exist:

JobModern (flakes)Legacy
Build a packagenix build <flake>#<attr>nix-build
Run a programnix run <flake>#<attr>nix-shell -p ... --run
Enter a shellnix develop / nix shellnix-shell
Install (user-level)nix profile installnix-env -i
Search packagesnix search nixpkgs <q>nix-env -qa
Rebuild systemnixos-rebuild switch --flake .#hostnixos-rebuild switch
Update pinsnix flake updatenix-channel --update
GCnix store gc / nix-collect-garbagenix-collect-garbage

You will eventually need to know both, because half the internet still shows legacy commands. But for new projects, use flakes and the modern CLI. We’ll see in the Mental Model section why the underlying behavior is the same — the surface API changed but the model didn’t.


5. The Mental Model

Two ideas, deeply internalized, will let you predict almost everything about Nix. A third is more about the human side. Get these and you stop being surprised.

Core Idea 1: /nix/store is a content-addressed heap, and packages are values in it.

The hash in /nix/store/abc123…-postgresql-15.4/ is computed from the derivation that built that path: every input, every flag, every dependency, recursively. Change any of them and you get a different hash and therefore a different store path. The hash is the identity.

This predicts a startling number of behaviors:

  • Multiple versions of anything coexist. Two Pythons, two glibcs, two OpenSSLs — they have different store paths, so they don’t fight. Your shell has whichever one is on its $PATH. The only conflicts are inside a single program’s runtime, never on disk.
  • There is no “installation” in the traditional sense. Software is built into a store path. “Installing” is just adding a symlink in a profile pointing at that store path. “Uninstalling” is removing the symlink. The bits never move.
  • Rebuilding a package on different machines, with the same inputs, produces the same store path. This is how binary caches work: someone on Hydra (NixOS’s CI) builds Firefox into /nix/store/xyz…-firefox/, and your machine knows the path Firefox should have at, so when it computes that path and finds a binary cache that has it, it can substitute the prebuilt version instead of compiling.
  • Patching a low-level package rebuilds everything that depends on it. Bumping glibc changes its hash, which changes every dependent’s hash, which changes their dependents’, all the way up. That’s why a glibc security patch in Nixpkgs triggers a “mass rebuild” of the entire ecosystem.
  • Closures are exactly what you need to ship. The closure of a derivation is the complete, self-contained set of files needed to run it. Nix’s nix copy and nix-copy-closure let you ship that closure to another machine — and because it’s content-addressed, you’re guaranteed it’s the same thing on both ends.

Core Idea 2: Builds are pure functions; the Nix language is the planner, not the doer.

A Nix expression is evaluated to produce derivations. Derivations are built in a sandbox: no network (except for fetchers with explicit hashes), no /usr, no environment leakage, no access to anything except declared inputs. The build is supposed to be a pure function from inputs to outputs.

This predicts:

  • flake.nix evaluates the same on your laptop and on CI. No pip install discovering different versions on different days. No system-leak. The lock file pins inputs to specific git revisions, the inputs pin further dependencies, and the entire derivation graph is reproducible.
  • You separate “what to build” from “build it.” When you read complex Nix code, ask yourself: is this code that runs at evaluation time, or is it a string that becomes part of a build script run at build time? Confusing these is the single most common beginner mistake. Evaluation can read other Nix files and import them; building runs in a sandbox with only what’s declared.
  • Failures fall into two categories. Evaluation errors (your Nix expression is wrong: type mismatch, undefined variable, missing attribute) happen instantly and have nothing to do with packages. Build errors (the C compiler failed, configure didn’t find a library) happen inside the sandbox and look like normal build failures. The error messages for each are different beasts.
  • Sandboxing reveals undeclared dependencies. A package whose Makefile silently reaches out to /usr/bin/perl works fine on Ubuntu and fails immediately on Nix. This is annoying and also the whole point: it forces every dependency to be declared, which is what makes the whole system reproducible.

Core Idea 3: Composability through attribute sets and lazy evaluation.

Nix’s killer feature isn’t reproducibility; it’s composition. The whole ecosystem is structured as attribute sets that you can extend, override, and merge.

  • An overlay is final: prev: { … } — take the previous package set, return a modified one. You can stack overlays. You can override an override.
  • A NixOS module is { config, pkgs, ... }: { options = {…}; config = {…}; } — declares some options, defines some values, possibly in terms of other modules’ options. The module system finds all modules, merges all options, resolves all references, and you get a system. This works because Nix is lazy: a module can reference config.services.nginx.enable without forcing it to evaluate yet, allowing circular-looking references to resolve.
  • Laziness is why nixpkgs can be a single huge attribute set with 120k packages: nothing is built until you ask for it. You can pass pkgs around freely; only the packages you actually use get evaluated.

This predicts the shape of customization: if you want to change something, you don’t fork it, you override it. There’s almost always a .override, .overrideAttrs, or overlay path to do what you want. Spelunking for the right override function is half of intermediate-level Nix work.


6. The Architecture in Plain English

Let’s walk through what happens when you run nixos-rebuild switch on a typical config — this is the moment that exercises basically every part of Nix.

You run the command. nixos-rebuild is a shell script that invokes nix-build (or nix build with flakes) on a specific attribute: <nixpkgs/nixos> with your configuration.nix as input, asking for the attribute config.system.build.toplevel.

Phase 1 — Evaluation. Nix’s evaluator reads configuration.nix. It’s a function that takes { config, pkgs, ... } and returns an attribute set. Nix imports nixpkgs, which is itself a massive set of expressions. It then imports the NixOS module system, which loads every module in nixos/modules/, including yours. The module system runs its fixpoint: every module declares options, every module defines values for options (often in terms of other modules’ options), and the system finds a consistent assignment. This is where services.nginx.enable = true; becomes “build an nginx package, build a config file, build a systemd unit, register the unit in the system.” All of this happens in the Nix language, lazily. Nothing is actually built yet; evaluation produces a graph of derivations, written as .drv files in the store.

The final output of evaluation is one big top-level derivation: system.build.toplevel, which transitively depends on derivations for every package in the system, every config file, every systemd unit, the kernel, the bootloader, everything.

Phase 2 — Building / substituting. Nix walks the derivation graph. For each derivation, it computes the output path (deterministically from the derivation’s content). If the path already exists in /nix/store, skip. If not, check the configured binary caches (cache.nixos.org by default). If they have it, download. Otherwise, build it locally in a sandbox.

Sandboxed building means: a fresh chroot, no network (unless the derivation is a fetcher with a fixed-output hash), only the declared inputs available, only the declared outputs writable. The builder runs (usually a bash script wired up by stdenv), produces output files in $out, and Nix moves them to the computed store path. References to other store paths inside the output are scanned and recorded — that’s how Nix knows what the closure is.

When this completes, you have a fully-built system in /nix/store/abc123…-nixos-system-thinkpad-25.11/. This directory contains everything: a boot.json, an etc/ with all your config files (which are themselves symlinks into the store), an init script, the kernel image, an activate script.

Phase 3 — Activation. nixos-rebuild runs the new system’s activate script. This is the carefully-orchestrated dance that takes the running system from the old generation to the new one without rebooting. It:

  1. Diffs the old and new systemd units; stops removed services, restarts changed ones, starts new ones.
  2. Updates /etc/ symlinks (every file in /etc/ is a symlink into the store).
  3. Updates /run/current-system to point at the new system path. This is the atomic switch. The previous symlink is preserved as a generation.
  4. Updates the bootloader to add the new generation as the default boot option, with the previous one available.

If activation fails halfway, the old /run/current-system is still there and you can reboot back. If it succeeds, you’re now running the new configuration — without rebooting in most cases. (Kernel upgrades still require a reboot, naturally, but the new kernel is already installed and the bootloader is already configured.)

Where state lives in this whole thing:

  • /nix/store/ holds every immutable artifact: packages, config files, system closures. It’s append-only.
  • /nix/var/nix/profiles/ holds symlinks named like system-42-link, system-43-link. Each is a previous version of the running system. These are GC roots.
  • /etc/ is almost entirely symlinks into the store.
  • /var/ is where actual mutable state lives: databases, logs, application data. NixOS doesn’t manage this; it’s your responsibility (and the source of half the friction).
  • ~/ is also yours. NixOS doesn’t touch your home directory. Home Manager will, if you opt in.

That separation — store is immutable, services have their own mutable /var, users have their own mutable home — is what makes atomic rollbacks possible. The OS can roll back; databases and home directories can’t.


7. The Things That Bite You

These are the surprises in the first six months. Each one connects to the mental model above; they aren’t random trivia.

1. “But it works on every other Linux” — undeclared dependencies fail loud

You try to build some open-source project and it dies because ./configure can’t find libfoo.so in /usr/lib/. Of course it can’t — there is no /usr/lib/ inside the build sandbox. On Ubuntu you’d apt install libfoo-dev and it would magically be on the search path. On Nix, every dependency must be in buildInputs (or nativeBuildInputs) or it doesn’t exist.

This is purity working as designed. It’s also why packaging Python in Nix is famously fiddly: a pip install might silently link against libxml2 from the system if it’s there, and “if it’s there” is something Nix removes from the equation. The fix is to declare all the dependencies. The pain is that figuring out the full list sometimes means reading the source.

2. Binary executables don’t just run

Download a pre-built binary off the internet — say, the official code tarball from Microsoft — make it executable, run it. On Ubuntu: works. On NixOS: bash: ./code: No such file or directory. The file exists. The error is that the binary’s interpreter is set to /lib64/ld-linux-x86-64.so.2 and that file does not exist on NixOS.

NixOS doesn’t have an FHS (Filesystem Hierarchy Standard) layout. The dynamic linker is in some /nix/store/<hash>-glibc-2.39/lib/ld-linux-x86-64.so.2 path. Programs compiled for normal Linux assume the standard path. They fail.

The fixes are: use patchelf to rewrite the binary’s interpreter, run inside steam-run (a wrapped FHS environment), or nix-ld (a NixOS option that creates a fake interpreter at the standard path that proxies to the real one). It is genuinely a daily friction point.

3. Evaluation vs build, again

You wrote:

my-script = pkgs.writeShellScript "deploy" ''
  echo "Building version ${pkgs.git.rev}"
  ${pkgs.curl}/bin/curl ${someUrl}
'';

You expected ${pkgs.git.rev} to be the current git revision of your repo. It is not. It’s whatever the Nixpkgs git package declares as its rev attribute. Nix interpolated this at evaluation time, before any build, with no relation to your shell environment. To get the current commit, you’d need either Flake’s self.rev, or an impure read, or --impure mode.

This is the most pervasive class of bug: confusing what’s known at Nix evaluation (early, on your machine, before any build) versus what’s known at build time (later, in a sandbox) versus what’s known at runtime. The mental model: evaluation produces a derivation; the derivation is a string of bash to run later; anything ${interpolated} happens at the earlier phase.

4. Strings carry context, and you can lose it

In Nix, a string that contains a store path also carries hidden metadata called string context recording that dependency. So "${pkgs.hello}/bin/hello" is a string that also tells Nix “this thing depends on the hello derivation.” This is how Nix tracks dependencies through code.

If you do something that strips the context — builtins.toString on the wrong thing, unsafeDiscardStringContext, certain readFile patterns — the dependency vanishes from the graph and your build mysteriously fails because the dependency wasn’t built. The error message rarely says “you lost the string context.” You’ll see a path-not-found error and have to figure out why Nix didn’t realize it needed to build that path.

5. Import From Derivation (IFD) is a tarpit

Sometimes you want to evaluate Nix code that depends on the output of a build — e.g., generate package definitions from a Cabal file or a package.json. This is “import from derivation”: evaluation forces a build to happen so it can read the result. It works, but it serializes evaluation and building, breaks evaluation parallelism, and is the reason flakes ban it by default. Whenever something in Nixpkgs is “slow to evaluate,” there’s a good chance IFD is involved. You’re better off generating the Nix code ahead of time and committing it.

6. The cache only helps if your hashes match

You added one tiny change to a library deep in your dependency graph. Now everything that transitively depends on it has a new hash, and cache.nixos.org has none of these — it only caches what Hydra (NixOS CI) builds, which is exactly the unmodified Nixpkgs trees. So a “small” change can trigger a multi-hour local rebuild of half your system.

This is a direct consequence of input-addressing: a different input produces a different output path, regardless of whether the resulting binary would be byte-identical. The “content-addressed derivations” feature (CA derivations), in beta for years, partially fixes this by hashing outputs after the fact, but it’s not the default and many things don’t yet work with it.

7. The <nixpkgs> vs pkgs vs inputs.nixpkgs confusion

Three ways to reference Nixpkgs live in the same ecosystem and they don’t always agree:

  • <nixpkgs> — looked up via the NIX_PATH environment variable. Whichever channel happens to be on the system. Mutable, global, often skewed between users.
  • import <nixpkgs> {} — runs the entry-point expression and returns the package set, with whatever your config and overlays say.
  • inputs.nixpkgs in a flake — pinned in flake.lock, immutable, scoped.

In a long-running project you’ll see all three in different places. They can refer to different revisions of Nixpkgs on the same machine. When something behaves differently on your colleague’s box than yours, this is the first thing to check.

8. State is your problem, not Nix’s

NixOS configures the structure. It does not migrate your Postgres database when you upgrade from 15 to 16. It does not move your home directory’s .config/foo.conf if the upstream tool changes its location. State lives in /var/, /home/, sometimes /etc/passwd for actual user records, and Nix is not the source of truth for any of it. Treat NixOS as “the system that gets you to a running service”; the service’s own state is its own problem.

9. nix-env is a footgun

The original way to install packages was nix-env -i firefox. It works. It also creates a parallel, imperative, undocumented mutation of your user profile that does not appear in your configuration.nix and that survives nixos-rebuild invisibly. Months later you wonder why your system isn’t reproducible from your config. The answer is nix-env -q, which shows you the mess.

Modern Nix uses nix profile install (which is at least flake-aware), but the strong advice in the community is: do not install packages imperatively. Put them in your declarative config — environment.systemPackages, users.users.<name>.packages, or home.packages in Home Manager. The whole point of Nix is reproducibility from a file; imperative installs break that.

10. Documentation is split, partial, and sometimes contradictory

There are five places you might look up something:

  • The Nix manual (the language and tools).
  • The Nixpkgs manual (the build infrastructure and stdenv).
  • The NixOS manual (modules and options).
  • The wiki (community-maintained, varies wildly in quality).
  • Random blog posts (often out of date — pre-flakes vs post-flakes, especially).

The official docs have improved enormously since around 2023, but a beginner googling “how do I X” will often land on three pages giving three different answers, two of which are five years old. The single most reliable path: NixOS option search (search.nixos.org), the Nixpkgs source itself, and Discourse. Treat Stack Overflow with skepticism.


8. The Judgment Calls

Flakes or no flakes?

Flakes are still officially “experimental” as of 2026 and have been for years. They’re also what every serious Nix project uses. Experienced users reach for flakes for: any new project, any system configuration, anything that wants pinned reproducible dependencies. They avoid flakes for: small one-off scripts where a default.nix is faster to write, and pedagogical contexts where they want to teach the underlying derivations. If you’re starting today, use flakes. The “experimental” label is misleading and the migration cost only grows.

Pin Nixpkgs to stable, unstable, or a specific commit?

Three positions, all defensible:

  • Stable (e.g., nixos-25.11) — what NixOS releases ship with. Slower-moving, well-tested, security-patched. Pick this for servers, for anything you don’t want to break.
  • Unstable (nixos-unstable) — the rolling channel. More recent packages, more frequent breakage, but you’re never far behind upstream. Pick this for your dev laptop where you want recent versions.
  • A specific commit — pin a flake input to github:NixOS/nixpkgs/<sha>. You update on your schedule, get exactly what you tested. Pick this for CI, for production builds, for shipped software.

The community-recommended compromise is using stable for the system, unstable as a secondary input for specific packages you want newer (pkgs.unstable.helix), and pinned for CI builds. This is what most experienced NixOS users settle on.

Override with .override or .overrideAttrs?

Both exist, do similar-sounding things, and beginners confuse them constantly.

  • .override modifies the arguments to the package’s defining function. Use when you want to change something the package was parameterized on: pkgs.python311.override { enableOptimizations = true; }.
  • .overrideAttrs modifies the attributes passed to mkDerivation. Use when you want to change build flags, sources, patches: pkgs.firefox.overrideAttrs (old: { patches = old.patches ++ [ ./mypatch.patch ]; }).

Rule: if the change is a “knob the package author exposed,” use .override. If it’s a “deeper surgery into the build,” use .overrideAttrs. If you find yourself reaching for overrideDerivation, you’ve gone too far; it’s the legacy version and .overrideAttrs should do the job.

Home Manager standalone, or as a NixOS module?

Two ways to use Home Manager: as a standalone CLI tool (home-manager switch) managing only your user environment, or as a module imported into your NixOS configuration (so nixos-rebuild switch does both system and user at once).

  • Standalone — necessary on non-NixOS systems (macOS, Ubuntu with Nix installed). On NixOS, lets the user iterate on their config without root.
  • As a module — one command rebuilds everything, system and user. Cleaner for personal NixOS machines where you’re the only user.

The right answer is contextual. Multi-user NixOS server: standalone Home Manager per user (or no Home Manager at all). Personal laptop: as a module. Cross-platform setup with both NixOS and macOS: standalone, so the same flake works on both.

Build from source or trust the binary cache?

By default, NixOS pulls binaries from cache.nixos.org. Some shops have policies that forbid this. Building from source is one config option: nix.settings.substitute = false; or just removing the cache from nix.settings.substituters.

The realistic position: if you’re a normal user or a small shop, trust the cache; the binaries are reproducible builds from a known Git revision, and the cost of rebuilding everything locally is enormous (compiling Firefox once will tell you why). If you’re in a regulated environment, you build your own cache from a known-good Nixpkgs revision and substitute only from that.

Imperative nix-env or declarative config?

There is no real judgment here, but it’s worth stating clearly: don’t use nix-env -i. Every package you ever want should live in some file (configuration.nix, home.nix, or a flake). The imperative path leads only to suffering, because the whole value proposition of Nix is reproducibility from a config, and nix-env quietly breaks that.

When to write your own module vs. configure the existing one?

NixOS has 20,000 options. Before you write a custom module for service foo, search the options. There’s a decent chance services.foo exists and does what you want. If it doesn’t, you can usually get 90% of the way with systemd.services.foo = { … }, which is the option that defines an arbitrary systemd unit. Only write a proper module — declaring your own options — when you have multiple machines that need slightly different configurations of the same custom service. That’s the case where module options pay off; for a single one-off service, raw systemd config is simpler.

Use Docker for development, or use Nix?

Both solve some of the same problems. They differ in nature:

  • Docker isolates the runtime environment by giving the app its own filesystem. It’s an opaque container — you ship a built image. Heavyweight, but works for almost anything, including software not packaged for Nix.
  • Nix isolates the build by hashing inputs. You ship a flake. Lightweight (no containerization), but everything needs to be expressible in Nix.

Experienced engineers reach for Nix for the dev environment and Docker for the production image. Better still: use Nix to build the Docker image (pkgs.dockerTools.buildImage), getting a deterministic, minimal-size image without the usual Dockerfile accidents. The Docker daemon as a deployment substrate, Nix as the build system that feeds it.

Channels for one-off CI, flakes for everything else

Even shops that have committed to flakes sometimes pin a channel for legacy reasons. The signal: are you composing multiple Nix projects together? Flakes. Are you running a one-off script in a CI job? Either works; flakes are better but the difference is small.

Adopt NixOS, or just Nix the package manager?

This is the biggest call. The package manager alone gets you reproducible dev environments, ad-hoc tool execution, and a powerful way to manage your home directory via Home Manager — while leaving the rest of your OS alone. NixOS gets you all that plus declarative system configuration and rollback-safe upgrades, at the cost of making the system itself unfamiliar.

For a developer’s primary work machine, the honest recommendation: start with Nix the package manager on whatever OS you’re already running, give yourself six months to get fluent with the language and nix develop, then decide if NixOS is worth the move. The package manager is 70% of the value with 30% of the friction. The full NixOS step is for those who want the “infrastructure as code” experience for their personal computer.


9. The Commands/APIs That Actually Matter

Grouped by what you’re trying to do.

Inspecting and searching

# What's in this flake?
nix flake show

# Search Nixpkgs for a package.
nix search nixpkgs '<term>'

# What options does NixOS have for <thing>?
# Use search.nixos.org/options — better than any CLI.

# What does this binary need to run? Show its closure.
nix-store -q --requisites $(which firefox)

# Why is this package in the closure? (Crucial for "why is glibc-old here" debugging.)
nix why-depends /run/current-system /nix/store/...-glibc-2.30

Running and developing

# Run a one-off tool from Nixpkgs, no install.
nix run nixpkgs#htop

# Shell with packages on PATH, exit and they're gone.
nix shell nixpkgs#nodejs_20 nixpkgs#ripgrep

# Enter the project's declared dev environment (reads flake.nix or shell.nix).
nix develop

# Same, but pure: ignore your $HOME and user environment.
nix develop --ignore-environment

Building

# Build the default package of this flake.
nix build

# Build a specific output.
nix build .#myPackage

# Show what would be built without doing it.
nix build .#myPackage --dry-run

NixOS

# Apply the configuration: build, switch, register a new generation.
sudo nixos-rebuild switch --flake .#hostname

# Build but don't switch — useful for testing.
sudo nixos-rebuild build --flake .#hostname

# Build a VM running the configuration. Excellent for testing services.
nixos-rebuild build-vm --flake .#hostname && ./result/bin/run-*-vm

# Roll back to the previous generation.
sudo nixos-rebuild switch --rollback

# List previous generations.
sudo nix-env --list-generations -p /nix/var/nix/profiles/system

Flakes

# Update all pinned inputs to their latest.
nix flake update

# Update just one input.
nix flake lock --update-input nixpkgs

# Show the lock file's pinned inputs and revisions.
nix flake metadata

Garbage collection

# Delete unreferenced store paths.
nix-collect-garbage

# Delete old generations and then collect garbage. Irreversibly removes rollback history.
sudo nix-collect-garbage -d

# Delete generations older than N days.
sudo nix-collect-garbage --delete-older-than 30d

# What would be deleted?
nix-store --gc --print-dead

Debugging

# Open a REPL with Nixpkgs loaded.
nix repl '<nixpkgs>'    # legacy
nix repl --expr 'import (builtins.getFlake "nixpkgs") {}'  # flakes

# Trace a flake evaluation for performance issues.
nix build .#foo --show-trace --debug

# Drop into a failed build to poke around.
nix develop .#foo  # or use the --keep-failed flag on a build

10. How It Breaks

”infinite recursion encountered”

The classic Nix module-system error. Two options reference each other directly without mkIf or similar conditional wrappers. The fix is usually using lib.mkIf to gate the definition on a condition, rather than putting the conditional in raw Nix:

# BAD — Nix evaluates the if before knowing whether config.foo is settled.
config = if config.foo then { warnings = ["foo"]; } else {};

# GOOD — mkIf is lazy and integrates with the module system's fixpoint.
config = lib.mkIf config.foo { warnings = ["foo"]; };

When you see this error, look for if config.something outside of mkIf.

”error: attribute ‘X’ missing”

You’re referencing an attribute that doesn’t exist. Two common subspecies: a typo, or you’re looking in the wrong attribute set. Use nix repl to interactively explore the set you’re in. :p pkgs.hello and then tab-complete are your friends.

”hash mismatch in fixed-output derivation”

A fetchurl or fetchFromGitHub got something other than what its hash declared. Either the upstream content changed (the canonical reason — silent github tarball regeneration, project changed a version’s contents), or you wrote the wrong hash. Leave the hash empty (hash = "";), build, copy the actual hash from the error message.

”this derivation will be built” — when you expected substitution

You expected a binary cache hit and got a local build. Three possible causes: (1) your substituters are misconfigured; (2) the output path differs from what the cache has (someone changed a default in Nixpkgs; you pinned a different revision; you have an overlay applied); (3) the binary cache doesn’t know about it (you built something custom, or it’s not on Hydra’s mass-rebuild list). nix path-info --derivation /nix/store/...drv and check the output path against what cache.nixos.org knows.

”error: file ‘X’ was not found in the Nix search path”

NIX_PATH doesn’t point to what you think. In flake-land you generally shouldn’t have a NIX_PATH. In legacy land, nix-channel --list shows what’s configured. The same name might be configured differently for root and for your user, which causes the maddening case of “works for me, fails with sudo.”

Network/SSL errors during fetches

A surprising number of mystery build failures are fixers reaching out for sources at build time. The fix is rarely “make the network work in the sandbox” (it doesn’t, by design); it’s “use the right fetcher with a fixed-output hash, which is allowed network access.”

NixOS won’t boot to your new generation

The bootloader has a list of generations. Pick the previous one at the boot menu — that’s the whole point of generations. Once you’re back, journalctl -u … from the previous boot and nixos-rebuild build-vm to test the broken configuration in a VM before trying again.

Out of disk space

/nix/store has grown. The store is additive — every old generation, every build artifact, every gcroot keeps things alive. du -sh /nix/store to see the damage. nix-collect-garbage -d to reclaim. If du is honest but df still shows it full, there’s likely an unrelated process holding open deleted files; that’s not Nix’s fault but the symptom looks similar.

The general debugging workflow

When something is wrong and you’re not sure what:

  1. nix-store --verify --check-contents — verifies the integrity of the store. Rarely the cause but worth ruling out.
  2. nix doctor — reports common configuration issues.
  3. nix path-info -rs /run/current-system | sort -rh | head -20 — what’s taking space; great for “why is my system so big.”
  4. nix why-depends /run/current-system /nix/store/<problem-path> — why is this thing in my closure?
  5. journalctl -u nixos-upgrade or journalctl -b -1 (previous boot) — the actual error message from systemd, often more informative than what nixos-rebuild prints.
  6. nix log /nix/store/<path>.drv — the build log for a specific derivation, including failures.

11. The Downsides / Disadvantages

These are the durable, structural costs of choosing Nix. They aren’t going to be fixed by a config tweak or a newer version.

1. The Nix language is hard, weird, and underspecified.

Nix is a small, dynamically typed, lazy, pure functional language that was designed by one person for one purpose (specifying derivations) and grew well beyond it. It has syntax that surprises everyone (rec, with, inherit, ${} interpolation rules), error messages that are notoriously bad (you’ll see “infinite recursion encountered” with no usable stack trace), no type checker, and no debugger to speak of beyond nix repl and builtins.trace. The Nixpkgs codebase uses functional patterns — fixed-point combinators, fancy currying, deeply nested attribute sets — that intimidate even experienced Haskell programmers.

Where this comes from: the choice for laziness and purity, which is genuinely required for the system to work (you need to pass around package descriptions without forcing every transitive dependency to evaluate). But that came packaged with a language that is much harder to learn than it needed to be.

What it costs you: a steep learning curve where most users plateau as “can copy-paste examples” and never become fluent. Onboarding takes months. Hiring is hard. Debugging non-trivial Nix is genuinely painful.

What people think mitigates it: alternative languages compiled to Nix (Nickel, Nix DSLs in other languages). In practice none have achieved critical mass, because Nixpkgs is written in Nix and is too large to rewrite. The language is here to stay.

When this is a dealbreaker: small teams without anyone willing to be the “Nix person.” If no one on the team becomes the local expert, you’re a copy-paste shop forever, which is more painful than just using Ansible.

2. Documentation is fragmented and historically poor.

The Nix manual, the Nixpkgs manual, the NixOS manual, the wiki, and a constellation of blog posts each describe pieces of a single tightly-coupled system, but the joints between them are weakly documented. The wiki gets out of date. Blog posts from 2020 advocate channel-based workflows that the community has since moved past. The official documentation has steadily improved (the nix.dev site is a real upgrade) but the cumulative situation in 2026 is still “you will spend a meaningful fraction of your Nix time hunting for which doc page applies to your problem.”

Where this comes from: a small community supporting a large surface area. Nixpkgs alone is 120k packages and an entire OS; the official docs cannot keep up. The language design also makes documentation inherently hard, because every package is also code, and the API of “the package” is whatever the code happens to export.

What it costs you: substantially more time per problem than equivalent issues in mainstream tools. The cost compounds for non-trivial questions; the cost goes down as you learn to read Nixpkgs source directly, which becomes the de facto documentation.

3. Closed-source and pre-built Linux binaries don’t just work.

Software from outside the Nix ecosystem — a downloaded Slack tarball, a pip install of some package with a wheel containing a binary, a game from Steam, anything with a ./run-me.sh script — assumes the standard FHS Linux layout. NixOS doesn’t have that. Every such binary needs patchelf to fix its interpreter, or steam-run/buildFHSEnv to fake an FHS environment around it, or nix-ld to provide a fake /lib64/ld-linux.so.2.

Where this comes from: Nix’s fundamental decision to not have global library paths. Without that decision, none of the rest of Nix works. With it, the world of “just download a binary” is alien.

What it costs you: ongoing daily friction proportional to how much you rely on third-party closed-source software. For a developer using mostly open-source tools, this is a minor irritant. For someone who needs Zoom, proprietary VPN clients, vendor drivers, weird industry software — it’s a real burden.

What people think mitigates it: nix-ld, FHS wrappers, Flatpak. These help a lot but every one is a workaround that requires you to know it exists.

4. Storage is generous, and “generous” is a euphemism.

/nix/store grows. Every system generation holds onto every package it used. Every nix-build ./result is a GC root keeping things alive. Multiple versions of the same library coexist for as long as anything references them. A laptop running NixOS with a year of casual use can easily have a 50-100GB store. After mass rebuilds (a glibc bump, for instance), the store doubles temporarily.

Where this comes from: the immutability + multiple-versions design is exactly what gives you rollbacks and reproducibility, and it also means you can’t share files between versions of the same library.

What it costs you: disk space, mostly on laptops with smaller SSDs. Garbage collection is an explicit action, not background. You will eventually run out of space if you don’t nix-collect-garbage periodically.

5. Binary cache misses lead to long local builds.

When you stay on the well-trodden path (current Nixpkgs, no overlays), cache.nixos.org provides almost everything as a prebuilt binary. The moment you stray — overlay a low-level package, pin a different glibc, use an unusual stdenv — the cache stops helping you and you start compiling things that would normally be downloaded. Mass-rebuild-triggering changes can mean compiling thousands of packages from source.

Where this comes from: input-addressing. Different inputs, different output paths, different cache entries. Even if the resulting binary would be byte-identical, the path differs, so the cache doesn’t know about it.

What it costs you: hours of build time, sometimes days, for what would be a five-minute change on another distro. Sets a ceiling on how creatively you can customize without burning real time.

What people think mitigates it: content-addressed derivations (CA derivations), which would cache by output content, not input hash. The feature has existed in beta for years and never become default. Don’t bet on it changing your life soon.

6. Service state and migrations are still your problem.

NixOS declaratively configures the static structure of your system. It does not migrate your Postgres database when you bump from version 15 to 16. It does not adapt your home directory’s old config layout to the new one. It does not handle the case where a service stored data in /var/lib/oldname/ and now expects /var/lib/newname/. State is mutable; Nix’s superpower is immutability; the two don’t compose cleanly.

Where this comes from: by construction, Nix manages the store. Services manage their own /var. Users manage their own /home. There is no story for “atomically migrate stateful data when the configuration changes.”

What it costs you: any time you change a stateful service, you write the migration yourself. Bumping major versions of databases is the same hands-on operation it is on every other Linux distribution; NixOS gives you nothing extra there.

7. The community is small, opinionated, and prone to churn.

The Nix community is a few thousand people deep, technically excellent, and historically prone to internal arguments that play out in public (the flakes experimentalism debate, the governance crisis of 2023-2024, the rate of breaking changes in unstable). Forums and GitHub issues are responsive but you’ll repeatedly encounter strongly-held opinions on questions that have no clean answer.

What it costs you: when something breaks, the body of Stack Overflow / forum knowledge is much smaller than for mainstream distros. You depend on Discourse, Matrix chat, and reading source. New contributors can be put off by the steepness of the social as well as technical curve.

8. The development experience is one tier slower than ecosystem-native tools.

The Nix way to develop is to write a flake.nix, run nix develop, work in the shell. This is reproducible and clean. It’s also one indirection further from the metal than cargo build or npm install would be. Language-native package managers move faster, have native tooling support in IDEs, and don’t require you to know two ecosystems at once.

Where this comes from: Nix is “above” the ecosystem package manager. A Nix Python project uses Nix to manage Python (via mkPython or poetry2nix or pyproject.nix or one of a dozen tools), and Python’s own packaging on top of that. Cargo plays better with Nix than pip does, but the friction is real.

What it costs you: a non-trivial integration burden per language. You’ll spend time learning the language-specific Nix conventions (which differ wildly: Python is hard, Rust is medium, Go and Haskell are easy). Sometimes the answer is “use plain Cargo and only use Nix for the system dependencies”; that’s a perfectly defensible position.

9. systemd is non-negotiable.

NixOS uses systemd for service management. There is no supported alternative (no OpenRC, no runit, no s6). If you have philosophical or technical opposition to systemd, NixOS is not for you.

Where this comes from: the NixOS module system is heavily structured around systemd’s unit model. Re-implementing for another init would essentially mean rewriting the service modules.

What it costs you: nothing if you’re fine with systemd. Everything if you’re not. Not negotiable.

10. Security update latency on stable.

NixOS releases come every six months. The stable channel gets backports of security fixes, but the backporting is community-driven and uneven; some CVEs land within days, others within weeks, occasionally lower-priority ones never. This is not worse than most distros for major issues, but the long tail of “is this CVE patched in stable yet?” requires more hands-on tracking than enterprise distros like RHEL.

Where this comes from: the small size of the security-response team relative to the size of Nixpkgs.

What it costs you: in security-sensitive contexts, you may need to track CVEs yourself and apply your own overlays for not-yet-backported patches. This is doable, even ergonomic in Nix, but it’s work you’re signing up for.


12. The Taste Test

Experienced Nix users can recognize each other in seconds by their code. Here’s the side-by-side.

Beginner: package list dumped into environment.systemPackages

environment.systemPackages = with pkgs; [
  firefox vim git curl wget htop neofetch ripgrep fd bat exa
  python3 nodejs go rustc cargo
  vscode discord slack zoom
  ...80 more lines...
];

This works. It’s also a sign that the user hasn’t internalized the system. Most of these should be elsewhere:

Experienced: layered, modular, idiomatic

# System packages: things that need to be system-wide.
environment.systemPackages = with pkgs; [ git vim curl ];

# Use a NixOS module for the editor — gets its own config, completions, MANPATH, etc.
programs.neovim = {
  enable = true;
  defaultEditor = true;
  viAlias = true;
};

# Languages and tools the user works with go in Home Manager.
# Per-project dev tools go in each project's flake.
# GUI apps that need a desktop entry go in environment.systemPackages or programs.<x>.

The taste-difference is where a thing lives, not whether it’s installed.

Beginner: lots of with pkgs; and bare imports

with import <nixpkgs> {};
mkDerivation { ... }

Two red flags. with pkgs; brings every Nixpkgs attribute into scope, which hides errors (a typo becomes “undefined variable” with no useful location). import <nixpkgs> uses NIX_PATH, which is mutable global state. Both work; neither is what an experienced person writes.

Experienced: explicit imports, scoped with

{ pkgs, lib, stdenv, fetchurl, ... }:
stdenv.mkDerivation {
  pname = "foo";
  ...
  buildInputs = with pkgs; [ openssl zlib ];  # `with` only where it shortens a list
}

Imports are explicit. with pkgs; is used inside one expression where it shortens a list, not across the file. The function takes named arguments so the caller can override.

Beginner: a 400-line configuration.nix

Everything in one file. Hardware, services, users, packages, kernel modules, network. Every change touches this one file. Three months later, it’s incomprehensible.

Experienced: a flake with split modules

flake.nix
flake.lock
hosts/
  laptop/
    default.nix
    hardware-configuration.nix
  server/
    default.nix
modules/
  desktop.nix
  development.nix
  gaming.nix
users/
  alice/
    home.nix

Each file does one thing. Hosts compose modules. Modules are reusable. Adding a new machine is creating a folder and listing the modules it includes. The flake outputs nixosConfigurations.laptop, nixosConfigurations.server, etc.

Beginner: copy-pastes hashes from elsewhere

fetchurl {
  url = "...";
  sha256 = "0000000000000000000000000000000000000000000000000000";
}

The hash is wrong, the build fails, the user pastes the correct one from the error message. Works, but indicates no understanding of why the hash is mandatory.

Experienced: knows that hashes are integrity, not bureaucracy

Uses lib.fakeSha256 or lib.fakeHash as a placeholder (clearer intent). Knows that the hash is what makes the input deterministic. Treats a hash mismatch as a real event, not an annoyance to silence.

Beginner: imperative nix-env -i everywhere

User has 30 packages from nix-env, 50 in environment.systemPackages, 20 in Home Manager. None of them agree about what’s actually installed.

Experienced: one source of truth

Everything in declarative config. nix-env is reserved for, at most, ad-hoc experimentation that gets cleaned up. The system is reproducible from the flake.

Beginner: doesn’t pin Nixpkgs

<nixpkgs> everywhere. Different machines, different Nixpkgs revisions, different behavior.

Experienced: pinned, locked, and reproducible

Flake locks all inputs. The Nixpkgs revision is in flake.lock. Two months from now, the same nixos-rebuild produces the same system, bit-for-bit (modulo nondeterministic builds).

The single best red-flag scan when reviewing someone else’s Nix code: how many places does it implicitly depend on global state (NIX_PATH, nix-env profiles, system Nixpkgs version)? An experienced setup has zero. A beginner setup has many.


13. Where to Go Deeper

  • Eelco Dolstra’s PhD thesis, “The Purely Functional Software Deployment Model” (2006). The original document. Surprisingly readable, and the only place where the why of Nix is laid out coherently. Read this when you’ve used Nix for a few months and want to understand why it is the way it is. Available at edolstra.github.io/pubs/phd-thesis.pdf.

  • nix.dev. The de facto modern documentation. Tutorials for the language, derivations, packaging. Written this decade. Start here for the language and the build model.

  • The Nix Pills. A series of 20 essays that walk through Nix from absolute basics through deep internals. Older (pre-flakes) but still the best deep dive into how derivations and the store actually work under the hood. Read after you’re comfortable with the basics; before then it’s overwhelming.

  • The NixOS option search at search.nixos.org/options. Not reading material, but the single most useful Nix resource: 20,000 options with their types, defaults, and descriptions. Bookmark it.

  • Nixpkgs source on GitHub. Eventually you stop reading docs and start reading the source. nixos/modules/services/ is where every service module lives; pkgs/build-support/ is where the meta-tooling is. Read these to learn how the experts write Nix.

  • “Three Years of Nix and NixOS: The Good, the Bad, and the Ugly” (Pierre Zemb). A balanced, recent (2025) practitioner’s view that confirms what your own experience will tell you after a year on Nix. Useful as a sanity check.

  • The NixOS & Flakes Book (nixos-and-flakes.thiscute.world). Community book that bridges the gap between “Nix language tutorials” and “real NixOS configurations using flakes.” Better than the official docs for the modern workflow.

  • Hands-on: rebuild a personal config from scratch. No resource teaches faster than spending a weekend translating your existing dotfiles, package list, and a couple of services into a flake-based NixOS or Home Manager config. Plan to throw away the first attempt; that’s where the learning is.


14. The Final Verdict

Nix is the best idea in package management since apt, packaged inside a language and ecosystem that fight you every step of the way. After all the depth above, here is the honest take.

The core idea — that a package is a value, a build is a function, and the filesystem is a content-addressed heap — is correct. It is so correct that almost every language ecosystem has independently rediscovered pieces of it (Cargo’s lockfile, npm’s node_modules, Bazel’s hermetic builds, OCI image hashes). Nix got there first and went further than any of them. Once you’ve seen a system where rollbacks are trivial, where dev environments are bit-for-bit reproducible across machines, where “it works on my laptop but not the server” is genuinely impossible — you cannot un-see it. That’s why people who use Nix daily for years sound like they’re in a cult. They aren’t. They’ve just been spoiled.

What Nix gets profoundly right: first, the isolation by construction. Every package’s full set of dependencies is captured in its store path, so two versions of anything coexist for free and rollbacks are a symlink change. Second, the uniformity: the same model — derivations, attribute sets, the store — handles a one-liner shell script, a kernel build, a 20,000-option operating system, a dev environment with five languages, and a deployment artifact. There is one mechanism, applied at every layer. Third, the composition through overlays and modules: you can override anything in the system without forking, and the override flows through the entire dependency graph automatically. No other system has anything close to this.

What it costs you: a language that is harder to learn than it should be, a documentation surface that you will spend years navigating, a constant low-grade friction with anything that wasn’t designed for Nix (closed-source binaries, language-specific package managers, anything assuming /usr). You will, periodically, lose a Saturday to a problem that would have taken twenty minutes on Ubuntu. The system is excellent at what it’s good at and unyielding about what it’s not.

Reach for Nix if: you are a developer who maintains multiple environments and wants reproducibility above all; you run a fleet of Linux machines and want them configured as code with safe upgrades; you work in a team with frequent “works on my machine” frustrations; you’re a single-person shop willing to invest weeks for a payoff in years. Reach for NixOS specifically if you control your own laptop, want everything in version control, and don’t depend heavily on proprietary Linux software.

Don’t reach for it if: you need a turnkey desktop experience and consider tinkering a tax, not a hobby; your work depends on lots of closed-source Linux software with bespoke installation; your team has no one willing to become the Nix expert; you need RHEL-grade security response timelines on a corporate-supported distro. There is no shame in this; Nix asks a lot and you have to want what it gives.

After the depth above, the calibrated beliefs to walk away with:

  • Believe that Nix’s core model is correct. It is the most coherent answer anyone has produced to “how should software be deployed.” Even if Nix the tool is replaced one day, its model will outlive it.
  • Don’t believe that everything in Nix is a deliberate, well-designed choice. The language has accidents. The CLI has accidents. The documentation has accidents. Some pain is essential, some is just history.
  • When you hear “Nix is too complex,” it usually means “the Nix language is hard to write and the documentation is fragmented” — both true. It rarely means “the underlying model is too complex” — the model is actually quite small.
  • When you hear “Nix is the future,” it usually means “the ideas in Nix are the future” — that is, content-addressed stores, hermetic builds, declarative system configuration. Whether the specific tool wins is less certain than whether its ideas have already won.

The hard-won line, after everything: Nix solves the right problem in the right way, badly. It is worth learning because it solves the right problem; it costs what it costs because it solves it badly. Pay the cost willingly, knowing what you’re getting, and you will own a system most engineers cannot quite believe is possible. Pay it grudgingly, hoping it gets easier, and you will resent every minute. Choose with eyes open, then commit.


The ideas are mine. The writing is AI assisted