APIs, integration & security — in depth

Nix Closure Structure and Transitive Dependency Graphs

Nix makes dependency graphs explicit and immutable instead of lazy and drifting.

Staff Writer · · 11 min read
Cover illustration for “Nix Closure Structure and Transitive Dependency Graphs”
Nix Derivation Anatomy · September 12, 2026 · 11 min read · 2,556 words

Most developers picture dependencies as a short list: your app needs a few named libraries, and the operating system puts them wherever it puts them. That picture is wrong, and the wrongness costs real money. Nix treats every package as a directed, content-addressed graph that includes not just the libraries you named, but the dependencies of those libraries, all the way down to glibc and the compiler runtime that built it, and it makes that full graph the unit of installation, not an afterthought.

The gap between those two mental models explains a lot of everyday software pain, and most of that pain is self-inflicted by tools that resolve dependencies lazily instead of fixing them at definition time. npm and pip let you specify version ranges, so two installs of the "same" project can resolve to different transitive packages depending on the day you ran npm install. Homebrew resolves each formula to whatever the latest version happens to be at install time, with no ranges in its inter-formula dependency specs. A widely cited case captures the problem well. Two developers each install "Java 17," one gets one vendor's build, the other gets a different vendor's build, and a test that passes on one machine fails on the other, even though both engineers would tell you, correctly, that they're running the same JDK version. Docker narrows this but doesn't close it. An apt-get install line inside a Dockerfile resolves against whatever package index is current on build day, so a later rebuild can silently pull in different transitive packages than the original.

The pattern underneath all of these is the same: dependency graphs that stay implicit drift, quietly and without warning, because nothing forces them to stay fixed. Every packaging system faces one real choice, resolve the dependency graph lazily at install time against whatever's current, or make it explicit, content-addressed, and mandatory the moment the package is defined. Most of the industry picked the first option and calls the resulting chaos "dependency hell." That choice is the wrong one, and Nix picked the second. Reproducibility, auditability, and onboarding all follow from that single decision, not from any tooling bolted on afterward.

What the Nix store actually is: a graph database of immutable, content-addressed nodes

The directory at /nix/store looks like an ordinary filesystem path. Treating it as one misses the point entirely. As Shopify's engineering team has described it, the store functions as a graph database: every entry underneath it is a node, and the relationships between those entries are edges.

An edge is a piece of behavior a program's logic actually depends on, a literal string, a store path, embedded directly inside the contents of another node. If a binary's contents contain the hash-prefixed path of a library, that's a directed edge from the binary to the library, full stop. Run otool -L or ldd on a binary built by a reproducible package manager and you'll see exactly this: paths like /nix/store/gk9l41kp852lddrvjx9cfkgxwjs3vls8-libsodium-1.0.16/lib/libsodium.23.dylib sitting right there in the linked-library list. Nix reads that same information to build its dependency graph, because the graph and the filesystem are, in a real sense, the same object.

The hash prefix on every path isn't decoration. Nix computes it from every input that went into producing that node, including the source code, the compiler used, the build script, and even relevant environment variables. Change any one of those inputs and the hash changes, which means the output path changes too. Two builds that start from identical inputs land on the identical output path, which is what makes it safe to share store paths across completely different machines without a fingerprint mismatch. Once Nix writes a path, its contents never change again. The name is a hash of the complete input, a permanent commitment to a specific, fixed set of bytes.

How a closure is constructed: tracing the directed graph from a package to its full transitive dependency set

Nix draws a sharp line between two related but distinct ideas: what a package points to directly, and everything it needs, full stop, to actually run.

The first is captured by --references, the set of store paths a given path directly points to, its immediate neighbors in the graph. The second is --requisites, the transitive closure, computed by following references recursively until no new node turns up. Shopify's explainer walks through a clean example. A Ruby application depends on a gem bundle. That bundle depends on nokogiri. Nokogiri depends on libxml2, and libxml2 depends on libc or libSystem, depending on the platform. Only the gem bundle shows up in the app's --references. All four packages, gem bundle, nokogiri, libxml2, and libc, show up in --requisites.

Querying this graph takes a few commands: nix-store --query --references <path> for direct edges, nix-store --query --requisites <path> for the full closure, and --referrers to walk the graph backward and find what depends on a given path. None of this is optional bookkeeping. Nix enforces, as a hard constraint, that every path named in a closure must actually be present in the store before that closure can be installed. There's no such thing, structurally, as a Nix package with a missing transitive dependency, because the system won't let that state exist.

The scale at which this rule gets enforced is worth sitting with. Tweag's analysis of the nixpkgs dependency graph put the package count above 80,000 as of 2022, and every one of those packages has to declare a closure that resolves cleanly down to the kernel. That's not a suggestion offered to package maintainers. A package that fails to declare its dependencies correctly simply will not build the same way twice, and Nix will not paper over the gap.

Diagram: From Package to Full Closure: The Four-Layer Dependency Chain. Visualizes: Visualize the directed dependency chain Nix traces from a Ruby application down to its full transitive closure.

Why content-addressing is the mechanism that enforces reproducibility across machines

A version string is a promise, not a proof. "curl 7.88.1" tells you what somebody called the build, not what's actually inside it, and two machines can carry entirely different binaries under that identical label without either one being wrong. Anyone who treats a version number as a guarantee of matching contents is trusting a label, not a fact, and that trust is misplaced more often than most teams admit.

A path like /nix/store/abc123-curl-7.88.1 makes a different kind of claim. The hash is cryptographically derived from the actual inputs, so if two systems produce the same hash, their contents are provably identical, not merely asserted to match. That property cascades through the entire graph. Because every node's path encodes its own inputs, a change anywhere in the tree, a compiler flag two layers down, a one-line patch to a library nobody thinks about, propagates upward as a changed hash at every level above it. There's no way for that kind of change to hide.

This has a security dimension too. An attacker who swaps out a dependency can't do it quietly, because the swap changes the store path, which in turn invalidates every path downstream that referenced it, a property that follows directly from how content-addressing propagates through the graph. Nix flakes push the same logic further: a flake.lock file pins the exact commit hash of every external input the project depends on, closing off the mutable-channel behavior that older nix-channel workflows allowed, where "the same channel" could quietly point somewhere new. Docker, for comparison, pins the base image but not the instructions that run inside the Dockerfile, so an apt-get install step still resolves against whatever's current on build day. That's the actual structural difference between the two approaches, not a matter of discipline or best practices. Nix's content-addressing applies at every layer, container or not. Docker's doesn't, and no amount of careful configuration fixes that.

Diagnosing unexpected dependencies: the tools for interrogating a closure

Closures grow bloated in ways that are easy to miss and annoying to trace. The Nix reference manual flags a common failure mode directly: a stray reference to a compiler can end up baked into a runtime binary by accident, dragging an entire toolchain into a closure that should only need to run, not build.

nix why-depends <package> <dependency> exists specifically to answer "why is this here." It finds the shortest path through the references graph connecting the two packages and, for each hop along that path, shows the actual file fragment where the reference lives. The manual's own example traces nixpkgs#hello to nixpkgs#glibc through a chain: hello-2.10 to bin/hello to glibc-2.32/lib/ld-linux-x86-64. Add --all and the tool shows every edge between the two paths instead of just the shortest one, which matters for cases like a desktop application build pulling in libX11 through several distinct routes at once. The --derivation flag switches the question from runtime dependencies to build-time ones, separating what a package needs to exist from what it needed to get built in the first place.

For a broader view, there's nix-tree, an interactive terminal tool built by utdemir and announced on the NixOS Discourse in July 2020, made specifically because developers were spending real time digging through transitive dependency chains just to trim closure size down. The practical workflow runs in that order, and skipping a step usually means guessing: spot the bloat visually in nix-tree, confirm the exact reference chain with nix why-depends, then either patch the derivation to strip the offending reference or restructure the build inputs so it never gets pulled in.

How propagated build inputs and platform offsets filter the transitive closure at definition time

Cross-compilation adds a wrinkle that a simple references-and-requisites model doesn't cover on its own. Nixpkgs' standard build environment, stdenv, computes its transitive closure across platform offsets, tracking the distinction between the build platform, the host platform, and the target platform, since a cross-compiling toolchain genuinely needs to reason about all three.

When two dependency links combine, their platform offsets are summed, and transitive dependencies whose combined offset falls out of bounds get pruned automatically. The stdenv documentation describes this in blunt terms: the filter exists to catch cases that are, in its own phrasing, "blatantly absurd," like a library meant for the target platform ending up propagated as a build-time tool in a context where it literally cannot run. This isn't a crack in the reproducibility model. The filtering rules are deterministic and fully declared, so the same inputs run through the same filter always produce the same reduced closure, every time. For anyone packaging a cross-compilation toolchain, the practical task is knowing which propagated inputs survive that offset filter and which get dropped, and when a drop looks wrong, nix why-depends --derivation is the tool that shows why.

What the closure graph reveals about software supply chain risk that language-ecosystem SBOMs miss

Lockfiles from various package managers capture a real dependency graph, but only up to a point. They stop cold at the native boundary. A package-lock.json has nothing to say about glibc, openssl, libz, or the compiler that built the binaries your application code eventually calls into, which means any SBOM generated purely from a language lockfile is incomplete by construction, not by oversight. Treating that kind of SBOM as a complete supply chain record is the mistake most security teams are still making.

Nix's closure has no such blind spot, because the native dependency tree is the thing it was built to track in the first place. An SBOM generated from a Nix closure is, structurally, more complete than one derived from a language-ecosystem lockfile alone, a structural advantage that follows from how the closure is defined. Tooling has grown up around this: tiiuae's sbomnix project provides sbomnix for generating SBOMs straight from a flake reference or store path, nixgraph for querying and visualizing the dependency graph itself, nixmeta for summarizing nixpkgs metadata for a given version, and vulnxscan for running vulnerability scans against the generated SBOMs. Nix doesn't output SPDX or CycloneDX natively, but the derivation graph is the ground truth those formats get generated from, not a separate record kept in sync with it by hand.

Safeguard's own pipeline illustrates what that unlocks in practice. It ingests Nix-generated CycloneDX SBOMs, cross-references the components against OSV, GHSA, and NVD, then applies policy gates that block a flake update outright if it introduces a known-vulnerable transitive dependency or changes a narHash without a signed commit behind it. Manifest Platform has focused on a related gap, building automated vulnerability mapping specifically for Nix packages, an area where visibility for security teams has historically lagged. The regulatory backdrop makes this more than a nice-to-have: a US federal budget and management oversight body set a compliance deadline tied to the Secure Software Development Framework, and government bodies in Germany, India, Britain, Australia, and Canada, alongside ENISA, have each issued their own guidance pushing software component inventories as a baseline expectation. Nix's SBOM is a byproduct of the build graph itself, not something bolted on to satisfy an auditor. It's a direct export of the same graph that made the build reproducible in the first place.

Where the closure model directly solves the developer onboarding and environment drift problems

Environment drift is what an implicit dependency graph looks like once it reaches an organization instead of a single machine. Two developers on the same team end up with subtly different environments simply because each one set theirs up on a different day, against whatever versions happened to be current at the time.

The cost of that drift shows up most visibly during onboarding. SHRM's 2025 research found new hires reach only about 25% productivity in their first month without structured onboarding in place, and for a senior engineer earning $180,000 a year, that gap works out to roughly $3,750 a week of salary paid against very little shipped work. Most of that gap traces back to environment setup, not to unfamiliarity with the codebase, which is a failure of tooling, not a failure of the new hire. A single command, nix develop or a flake-based equivalent, materializes the exact closure defined in flake.nix: same derivation graph, same hashes, same environment, whether it's running on a new hire's laptop, a CI runner, or the production build host. The environment stops being tribal knowledge scattered across a wiki and becomes a versioned artifact that lives in the repository next to the code it supports.

Tool fragmentation compounds the same problem even for established teams. A large share of developers report losing meaningful time each week to fragmented tooling, a number that a fully declared, reproducible toolchain attacks directly by removing the guesswork over which version of what is actually installed where. Devbox, a tool built on top of Nix, lowers the barrier further for teams not ready to write raw Nix expressions themselves, offering shell hooks, custom scripts, and devcontainer.json generation for editors like VS Code. The organizational shift toward platform engineering fits this model closely, too: Gartner has tracked the share of large engineering organizations with dedicated platform teams rising to 80%, up from 45% in 2022, a trend that maps neatly onto Nix's own pattern of platform engineers declaring a reproducible base closure once, and developers simply activating it.

The guarantee that holds on a single laptop is the same one that holds across the entire pipeline. Nothing about the closure changes moving from a developer's machine to CI to production, which is precisely why "works on my machine" stops being a shrug and starts being a solved problem.

Diagram: The Onboarding Productivity Gap a Reproducible Environment Closes. Visualizes: Render a single stat callout or progress meter built around one concrete figure: new hires reach only 25% productivity in their first month without structured…

Sources

  1. What Is Nix - Shopify
  2. Nix-tree: Interactively browse the dependency graph of your Nix derivations
  3. nix why-depends - Nix 2.34.9 Reference Manual
  4. Construction and analysis of the build and runtime dependency graph of nixpkgs
  5. ryantm.github.io
  6. cycloid.io
  7. github.com

More in Nix Derivation Anatomy