Developer Tools · Package Managers
Diamond Dependencies and Duplicate Versions
What happens when two packages require incompatible versions of a third, why some installers keep both copies, and what duplication costs in type identity, disk space, and patch coverage.
The diamond is the smallest interesting dependency conflict. A root package requires B and C. B requires D in the 1.x series, C requires D in the 2.x series, and the two ranges do not overlap. Everything else about the graph is acyclic and shallow. The resolver now faces a choice that is not about algorithms at all: it can refuse, it can pick one D and hope, or it can install both.
One name, two nodes
Under a single-version-per-package assumption, the graph may contain at most one node for the name D. Since no version satisfies both ranges, the assignment is unsatisfiable and the install fails. This is the model used by system package managers and by several language-level ones, and it has a clear virtue: the runtime sees exactly one D, so there is no question of which copy a given piece of code is talking to.
Under a multi-version model, the graph contains two nodes named D at different versions, and the installer must arrange for B to resolve D to one and C to the other. The requirement graph now needs a notion of scope: the edge from B points at D@1, the edge from C points at D@2, and the same name means different things in different subtrees. That is a property of the module system as much as of the package manager, because the language’s import mechanism has to be able to express it.
Node’s module system does express it, by searching directories. A module located at node_modules/b that calls require('d') is resolved by checking node_modules/b/node_modules/d first, then node_modules/d, then the same pattern at each ancestor directory up to the filesystem root. Placing a copy of D inside B’s own node_modules therefore shadows the outer copy for B and only for B. The mechanism is ordinary path lookup, and the diamond problem is solved by making the filesystem tree deeper.
Unification first, duplication second
Keeping both copies is a last resort in every multi-version installer, because duplication is expensive. The usual policy is to unify whenever the ranges permit and duplicate only when they do not. Cargo states the rule in terms of semver compatibility: two requirements on the same crate are unified if they fall in the same compatibility range — the same major version once the major is at least 1, and the same minor version while it is 0 — and otherwise Cargo may compile both versions and link them under distinct crate identities. A requirement of ^1.2 and a requirement of ^1.9 unify at one version at least 1.9; ^1.2 and ^2.0 do not.
The compatibility-range rule encodes an assumption about what a version number promises. Within a major version, a library author is expected not to break the API, so one copy can serve both consumers. Across a major boundary, breakage is expected, so the two consumers genuinely need different code. Where the assumption holds, unification is safe; where it does not, unification produces a build that compiles against an API nobody promised.
npm’s unification is weaker and more opportunistic. It hoists packages to the shallowest position where no conflict arises, flattening the tree so that one copy at the root serves as many dependents as possible. When two dependents need incompatible ranges, one of them keeps a nested copy. The resulting tree depends on traversal order in ways that are not part of any specification, which is why the lockfile records the layout rather than only the versions.
What duplication costs at runtime
The first cost is identity. Two copies of a module are two distinct sets of objects, two distinct class definitions, and two distinct module-level states. A value created by one copy fails an instanceof check against the other copy’s class, and a type assertion in a statically typed language fails for the same reason when the two copies are separate compilation units. Code that passes objects across the boundary between B and C — a plugin interface, an event object, a shared cache — silently stops working, and the failure appears far from its cause.
The second cost is singleton state. If D holds a registry of plugins, a connection pool, or a React-like hook dispatcher, then two copies mean two registries and two pools. Registrations made through one are invisible through the other. This is the mechanism behind the class of bugs where a component renders with a null context: the provider and the consumer each imported their own copy of the context object.
The third cost is size, which is the least interesting but the easiest to measure. Two copies of a large library ship twice, and if each has its own dependencies that are also duplicated, the multiplication compounds. A bundle that contains three copies of a utility library because three dependencies pinned different majors is a common outcome, and the fix is usually to upgrade one of the dependents rather than to force deduplication.
Strict layouts and the phantom dependency
Hoisting creates a second-order problem. Once a package is flattened to the top level, every other package in the tree can import it, whether or not it declared the dependency. The import succeeds because the file is on the lookup path. The package is then depending on a name it never asked for, and the arrangement survives only as long as something else keeps that name at the top level.
This is a phantom dependency, and it is a real class of breakage: removing an unrelated dependency, or changing its version so that hoisting places the package elsewhere, makes the phantom import fail. Strict installers address it by making the reachable set equal the declared set. pnpm stores packages in a content-addressed store and links each package’s node_modules to exactly its declared dependencies, so a package cannot resolve a name it did not declare. The symlink layout also means one physical copy of a version can serve many dependents even when it appears at several places in the logical graph.
The trade is compatibility. Strict layouts break packages that relied on hoisting, and those packages exist in quantity, which is why a strict installer needs an escape hatch for a dependency whose own dependencies are incomplete. The escape hatch is a per-package declaration of what to expose, which is a manifest change rather than a resolution change.
Duplication as a patch-coverage problem
Security updates interact badly with duplication. If five copies of a library at four versions exist in a tree, a fix published as a patch release reaches only the copies whose ranges admit it. The others need a range change by their dependents, which is a change in another package’s manifest that the application does not control. The result is a tree where one vulnerable copy is enough to keep the vulnerability present, and finding it requires enumerating the duplicates rather than reading the direct dependencies.
Tooling responds by listing duplicates: an install that prints the names appearing at more than one version, with the paths that require each. The list is a resolution artifact, derived from the same graph the lockfile records, and its usefulness depends on the installer being able to explain, for each duplicate, which requirement forced the second copy. That explanation is what a conflict-driven resolver already computes while it works, and what Conflict-Driven Backtracking describes as the derivation chain.
When refusing is the better answer
An installer that refuses on a diamond forces the ecosystem to keep its ranges compatible, which is a strong constraint with a large payoff: one copy of everything, one identity per type, one place to patch. Go takes this position to its limit. The module graph has exactly one version per module path at build time, selected as the maximum of the declared minimums, so a diamond resolves to whichever requirement is higher and the other dependent simply gets a newer version than it asked for. No duplicates exist as a concept, and Minimal Version Selection explains why that choice is consistent.
The cost is that a dependent cannot pin an old major while another moves ahead. When a module makes a breaking change, every consumer in the build must move, and the ecosystem’s answer is a new import path rather than a second copy. That is a social contract enforced by the tool, and it trades flexibility for a graph whose invariants are simple enough to reason about.
Between the two extremes sits the common arrangement: unify within a compatibility range, duplicate across major boundaries, and record the result. It matches how library authors actually behave, and it keeps the common case at one copy per package. The price is a graph that can contain several nodes with the same name, which every downstream tool — the linker, the type checker, the vulnerability scanner, the lockfile diff — has to be able to represent. The representation question is settled in the lockfile, whose entries are keyed by location rather than by name in exactly the installers that permit duplicates, as What a Lockfile Records describes.
References
- Node.js Modules: Loading from node_modules Folders — Node.js project
- The Cargo Book: Specifying Dependencies — Rust project
- pnpm: Symlinked node_modules structure — pnpm project