Back to glossary

Dependency Hell

Dependency hell is when a project's version constraints conflict, so updating one package breaks another and security fixes can't reach the build.

Also known as dependency conflict, DLL hell, JAR hell

Dependency hell, also called a dependency conflict, or DLL hell and JAR hell on older platforms, is the state where a project's version constraints cannot all be satisfied, so moving one package forward breaks another. Package managers respond by failing, installing duplicate copies, or silently picking one version, and that response decides which security fixes can reach your build.

We resolved react-scripts@5.0.1, the build tool behind Create React App, on October 31, 2026. Its tree resolved to 1,291 packages, and 102 package names appeared in more than one version. npm audit reported 73 vulnerabilities. For 69 of them, the only fix npm offered was react-scripts@0.0.0, a 2016 tarball that contains nothing but a package.json.

That is the security cost of dependency hell. The 73 findings trace to 24 advisories, and every one has a fixed release on the registry. The graph has no path to them. Our position: dependency hell is a security problem first and a build problem second. The escape hatches people reach for, such as --force, --legacy-peer-deps, and blanket overrides, switch off checks for the whole tree. Resolve conflicts one edge at a time, with an exact version, a recorded reason, and a test of the parent that has to run it.

What is dependency hell?

#

Dependency hell is a dependency graph whose version constraints no longer have an acceptable solution. The semantic versioning spec opens with it: "In the world of software management there exists a dreaded place called 'dependency hell.'" Its author, Tom Preston-Werner, names two causes. Constraints that are too tight cause version lock. Constraints that are too loose cause what the spec calls "version promiscuity."

The classic shape is the diamond. Your app depends on A and B. A requires D ^1.4. B requires D ^2.0. Each declaration is reasonable on its own. Together they ask for two incompatible versions of D, and every transitive dependency in a large tree is another chance for that to happen.

Whether the diamond is a real conflict depends on the runtime. Python imports a module by name from one environment, so only one version of D can be installed. The Java classpath loads the first matching class it finds. A native library can be linked into a process once. Node.js resolves require() from the nearest node_modules folder, so two copies of D can coexist. A conflict exists wherever the runtime can hold only one copy.

Finding a solution is also hard in the formal sense. Russ Cox showed in 2016 that version selection is NP-complete under four everyday conditions: dependencies can name specific versions, all dependencies must be installed, each version can have different dependencies, and two versions of one package cannot be installed together. In his words, "the version selection problem is NP-complete." He credits the EDOS project with proving the same for Debian and Red Hat package installation in 2005. Real resolvers backtrack, use heuristics, or change the rules of the problem to escape that bound.

you see it

Refuse

No single version satisfies both. The install stops with an error.

pip · Cargo within one compatible range · npm peer dependencies

silent

Duplicate

A gets D 1.x, B gets D 2.x. Both copies ship and both need patching.

npm and pnpm regular dependencies · Cargo and Go across majors

silent

Choose

One version wins and one parent runs a version it never declared.

Maven: nearest declaration wins

Figure 1. One diamond, three resolver behaviors. Only refusal tells you a conflict exists.

The figure shows one diamond and three outcomes. Your app depends on A and B. A requires D ^1.4 and B requires D ^2.0. A refusing resolver stops with an error: pip, Cargo within one semver-compatible range, and npm for peer dependencies behave this way. A duplicating resolver installs D 1.x for A and D 2.x for B: npm and pnpm do this for regular dependencies, and Cargo and Go do it across major versions. A choosing resolver keeps one version and ignores the other constraint: Maven picks the declaration nearest the root. Only the refusing resolver tells you a conflict happened.

Why does the same conflict fail in pip but install in npm?

#

The same conflict fails in pip and installs in npm because each package manager encodes a different answer to the diamond. Some refuse, some duplicate, and some choose silently. We ran each case below in a clean project on October 31, 2026, with npm 12.2.1, pnpm 12.16.1, pip 26.2.1 on Python 3.13, and Cargo 1.97.0.

npm duplicates regular dependencies without comment, but treats peer dependencies as a hard constraint. A peer dependency says "use the copy my host already has," which is how plugins share one React or one ESLint. Here is react-dom@18.3.1, which declares a peer of react ^18.3.1, installed next to react@19.2.0:

Bash
$ npm install react@19.2.0 react-dom@18.3.1
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: peer-demo@1.0.0
npm error Found: react@19.2.0
npm error node_modules/react
npm error   react@"19.2.0" from the root project
npm error
npm error Could not resolve dependency:
npm error peer react@"^18.3.1" from react-dom@18.3.1
npm error node_modules/react-dom
npm error   react-dom@"18.3.1" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry this command with --force or --legacy-peer-deps to accept an incorrect (and potentially broken) dependency resolution.

This exact error helped end Create React App. Once React 19 shipped, new projects failed during setup, because the template's React Testing Library still declared a peer dependency on React 18.

pnpm 12.16.1 installed the same react and react-dom pair, printed "Issues with peer dependencies found," and exited with code 0. The same graph is an error in one tool and a warning in another.

pip installs exactly one version of each distribution into an environment, so it has to refuse. Since pip 20.3, its resolver backtracks through older versions looking for a consistent set before it gives up. The search has a side effect most users never notice:

Bash
$ pip install --dry-run "protobuf==3.20.3" "grpcio-tools==1.66.0"
Collecting protobuf==3.20.3
Collecting grpcio-tools==1.66.0
  Downloading grpcio_tools-1.66.0.tar.gz (5.1 MB)
  Installing build dependencies: started
  Installing build dependencies: finished with status 'done'
  Getting requirements to build wheel: started
  Getting requirements to build wheel: finished with status 'done'
  Preparing metadata (pyproject.toml): started
  Preparing metadata (pyproject.toml): finished with status 'done'
INFO: pip is looking at multiple versions of grpcio-tools to determine which version is compatible with other requirements. This could take a while.
ERROR: Cannot install grpcio-tools==1.66.0 and protobuf==3.20.3 because these package versions have conflicting dependencies.

The conflict is caused by:
    The user requested protobuf==3.20.3
    grpcio-tools 1.66.0 depends on protobuf<6.0dev and >=5.26.1
...
ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts

grpcio-tools 1.66.0 has no wheel for Python 3.13 on Linux x86-64, so pip downloaded the source distribution and ran its setup.py to learn its dependencies. That happened under --dry-run, for a resolution that failed. pip's secure installs guide warns that installing "involves running arbitrary code from distributions" and recommends --only-binary :all:. With that flag, the same command fails before any package code runs.

pip's second behavior matters more for CI. Each pip install resolves only the packages you name against what is already installed, so a later install can break an earlier one:

Bash
$ pip install grpcio-tools==1.75.0      # installs protobuf 6.33.6
$ pip install protobuf==5.29.5
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
grpcio-tools 1.75.0 requires protobuf<7.0.0,>=6.31.1, but you have protobuf 5.29.5 which is incompatible.
Successfully installed protobuf-5.29.5
$ echo $?
0
$ pip check
grpcio-tools 1.75.0 has requirement protobuf<7.0.0,>=6.31.1, but you have protobuf 5.29.5.
$ echo $?
1

The word ERROR appears, and the exit code is still 0. A CI job that installs in steps ships a broken environment unless it also runs pip check.

Cargo sits between the two. The Cargo book says Cargo "will unify dependency versions if they are SemVer compatible," and errors when compatible requirements conflict. Across major versions it builds both copies. Pinning serde to exactly 1.0.150 next to serde_json 1.0.154, which requires serde ^1.0.220, fails:

Bash
$ cargo generate-lockfile
error: failed to select a version for `serde`.
    ... required by package `serde_json v1.0.154`
    ... which satisfies dependency `serde_json = "=1.0.154"` of package `demo v0.1.0`
versions that meet the requirements `^1.0.220` are: 1.0.229, 1.0.228, ...

all possible versions conflict with previously selected packages

  previously selected package `serde v1.0.150`

Depending on rand 0.8 and rand_chacha 0.9 succeeds, and cargo tree -d lists two copies each of rand_chacha and rand_core.

Go modules avoid the search by changing the problem. A go.mod requirement states only a minimum version, and minimal version selection builds with the highest minimum required for each module. Major versions from v2 on use a different module path, such as example.com/mod/v2, so they coexist as separate modules. A Go build cannot express "D must stay below 2.0," which is exactly the kind of constraint that produces a conflict.

Maven never reports the diamond at all. Its documentation says it "picks the 'nearest definition'": the version declared closest to the root wins, and on a tie the first declaration wins. In the documentation's own example, D 1.0 beats D 2.0 because its path is one hop shorter. If D 2.0 was the patched release, the build quietly ships D 1.0.

EcosystemTwo parents need incompatible versions of DWhat you seeSecurity consequence
npm (regular dependencies)Installs both copies, nestedNothingOld copies survive upgrades of the new one
npm (peer dependencies)RefusesERESOLVE, which suggests --forceThe suggested flags disable checks tree-wide
pnpmInstalls both; peer conflicts warnA warningBroken peer graphs ship
pipRefuses within one command; allows conflicts across commandsResolutionImpossible, or ERROR text with exit code 0Inconsistent environments pass CI; source distributions run code during the search
CargoRefuses within a compatible range; builds both across majorsfailed to select a versionDuplicates across majors, errors within one
GoHighest minimum wins; majors are separate module pathsNothingNo upper bounds, so no conflicts; upgrades stay explicit
MavenNearest declaration winsNothingA nearer, older, vulnerable version can win silently

The verdict: prefer a resolver that refuses over one that chooses silently. An error costs an afternoon. A silent choice, like Maven's mediation or pip's exit code 0, hides the fact that a parent is running a version it never declared. Go's minimum-only model is the cleanest design because it removes upper bounds from the problem. The cost moves to module authors, who must keep every release in a major line compatible.

Duplicate copies keep unpatched code alive

#

Duplication makes a conflict disappear from your terminal, not from your build. Every extra copy is another version to patch, and a fix that reaches one copy does not reach the others.

react-scripts@5.0.1 was published on April 12, 2022, and has had no stable release since. The React team deprecated Create React App on February 14, 2025, noting that it "currently has no active maintainers." We resolved its tree with npm 12.2.1 (npm install --package-lock-only) and counted package-lock.json entries. The lockfile held 1,291 package entries under 1,098 names. 102 names appeared in more than one version, adding 118 extra versions. Another 75 entries were repeat copies of a version npm could not hoist. Four versions each of commander and schema-utils ship side by side.

nth-check shows what duplication means for patching. The tree holds two copies:

react-scripts@5.0.1
no stable release since April 2022

Path to the patched copy

  1. html-webpack-plugin@5.6.8
  2. pretty-error@4.0.0
  3. renderkid@3.0.0
  4. css-select@4.3.0declares nth-check ^2.0.1
  5. nth-check@2.1.1patched · node_modules/nth-check

Path to the vulnerable copy

  1. @svgr/webpack@5.5.0react-scripts declares ^5.5.0
  2. @svgr/plugin-svgo@5.5.0declares svgo ^1.2.2
  3. svgo@1.3.2declares css-select ^2.0.0
  4. css-select@2.1.0declares nth-check ^1.0.2
  5. nth-check@1.0.2GHSA-rp65-9cf3-cjxr · node_modules/svgo/node_modules/nth-check
Figure 2. Two copies of nth-check in one react-scripts tree, resolved October 31, 2026 with npm 12.2.1.

The figure traces both copies of nth-check to react-scripts. The patched copy, nth-check@2.1.1, arrives through html-webpack-plugin@5.6.8, pretty-error@4.0.0, renderkid@3.0.0, and css-select@4.3.0, and is hoisted to node_modules/nth-check. The vulnerable copy, nth-check@1.0.2, arrives through @svgr/webpack@5.5.0, @svgr/plugin-svgo@5.5.0, svgo@1.3.2, and css-select@2.1.0, which declares nth-check ^1.0.2. It is nested at node_modules/svgo/node_modules/nth-check. The advisory, GHSA-rp65-9cf3-cjxr, covers nth-check below 2.0.1.

Fixing the nested copy requires svgo 2, which requires a newer @svgr/plugin-svgo, which requires a react-scripts release that will not ship. npm audit reports the dead end directly:

Bash
$ npm audit
...
fix available via `npm audit fix --force`
Will install react-scripts@0.0.0, which is a breaking change
...
73 vulnerabilities (11 low, 49 moderate, 13 high)

$ npm pack react-scripts@0.0.0 && tar tzf react-scripts-0.0.0.tgz
package/package.json

npm's audit cannot express "upgrade this leaf against its parent's declared range," so its suggested fix moves the only direct dependency to its lowest published version. react-scripts@0.0.0 was published on July 18, 2016. Applying that fix removes the build tool, which also removes the vulnerabilities.

Many of these advisories sit in build-time code that only processes your own source files, which limits how exploitable they are. The structural problem remains. The next advisory in that tree could matter, and the tree still has no path to a fix.

How does dependency hell block security fixes?

#

Dependency hell blocks security fixes when the package that constrains a vulnerable dependency stops releasing. The fixed version exists on the registry, but the parent's declared range excludes it, and only the parent's maintainer can widen that range.

request is the canonical case. Its README says, "As of Feb 11th 2020, request is fully deprecated. No new changes are expected to land." Its last release, 2.88.2, declares tough-cookie ~2.5.0, form-data ~2.3.2, qs ~6.5.2, and uuid ^3.3.2. Every one of those ranges now excludes the fixed release of a published advisory. An abandoned dependency keeps its constraints forever:

Bash
$ npm install request@2.88.2
$ npm audit
form-data  <=2.5.5
Severity: critical
...
No fix available

qs  <=6.15.4
Severity: moderate
...
No fix available

tough-cookie  <4.1.3
Severity: moderate
tough-cookie Prototype Pollution vulnerability - https://github.com/advisories/GHSA-72xf-g2v4-qvf3
No fix available

uuid  <11.1.1
Severity: moderate
...
No fix available

5 vulnerabilities (3 moderate, 2 critical)

"No fix available" is false in one sense: tough-cookie 4.1.3 fixed CVE-2023-26136 in June 2023. It is true in the sense npm means. No version of request accepts it.

Overrides force a version the parent never tested

#

An override replaces a dependency's declared version from the root of your project, whatever its parent asks for. npm calls the feature overrides, Yarn calls it resolutions, pnpm has its own overrides setting, Maven uses dependencyManagement, and Gradle uses constraints. npm's package.json documentation lists replacing "the version of a dependency with a known security issue" as the first use.

Scope the override to the parent that needs it, and use exact versions:

JSON
{
  "dependencies": { "request": "2.88.2" },
  "overrides": {
    "request": {
      "tough-cookie": "4.1.4",
      "form-data": "2.5.6",
      "qs": "6.17.1",
      "uuid": "11.1.1"
    }
  }
}

npm ls marks each one "overridden," and npm audit drops from five findings to one. Then the app fails on startup:

Bash
$ node -e "require('request')"
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './v4' is not defined by "exports" in node_modules/uuid/package.json

request calls require('uuid/v4') in three files, and the exports map in uuid 11.1.1 does not expose that path. The override satisfied the scanner and broke the program. After we removed the uuid override, request loaded. Against a local test server, it sent a multipart body, sent a stored cookie, and saved a new one, running on tough-cookie 4.1.4, form-data 2.5.6, and qs 6.17.1. A smoke test is not proof of compatibility. request's own test suite never ran against those versions.

The uuid finding did not need an override. GHSA-w5hq-g745-h8pq affects v3(), v5(), and v6() when the caller passes an output buffer. request calls only v4(), with no buffer. The tough-cookie advisory needs a CookieJar created with rejectPublicSuffixes set to false, and request's built-in jar sets only looseMode. Checking whether your code can call the vulnerable function is reachability analysis, and it often removes the reason to fight the graph at all. We did it above with grep. Socket's precomputed reachability does the same check at the function level for transitive dependencies, scanning dependency source code to find which functions your direct dependencies call, according to Socket's reachability documentation.

The finding that remained cannot be overridden. request itself carries CVE-2023-28155, a server-side request forgery bypass through cross-protocol redirects, and GitHub's advisory lists no patched version of request. Overrides fix the leaves. A vulnerable trunk has to be replaced, and Node.js has shipped a built-in fetch since version 18.

Overrides carry three risks of their own.

Override values accept any specifier, including npm: aliases, file: paths, and Git URLs. An override is a code-substitution point for every parent in its scope, so review it like a new dependency. npm 12 refuses Git specifiers by default: our Git override of tough-cookie failed with EALLOWGIT until we allowed it.

Overrides have been unreliable. The npm overrides bug, which made an override silently revert to the vulnerable version after first use, went unfixed for nearly three years until npm 11.2.0 in March 2025.

Overrides go stale. Each one records a decision about two specific packages. When the parent finally updates, the override can hold it back.

When neither an upgrade nor an override is safe, the remaining option is to change the vulnerable version in place. Socket Certified Patches apply a small, reviewed source patch to the exact version you already have, stored in a .socket/ directory in your repository. Individual patches for critical and high severity advisories are free, as Socket noted with its vm2 sandbox escape patch in May 2026. A patch fixes one advisory. It does not make an unmaintained package maintained, so it buys time to replace the package.

The flags that make the error go away

#

The fastest fix for an ERESOLVE error is the one npm's error message suggests: retry with --force or --legacy-peer-deps. Both flags turn off checks for the entire tree, not for the one edge that conflicted.

npm's config documentation says --force "removes various protections against unfortunate side effects, common mistakes, unnecessary performance degradation, and malicious input." Its list includes letting npm audit fix install versions outside your declared ranges, including major changes. --legacy-peer-deps "causes npm to completely ignore peerDependencies when building a package tree." Written into a project's .npmrc, it applies to every future install by every developer and every CI job, long after the original conflict is gone.

pip has the same pattern. pip install --no-deps skips dependency resolution for the whole command, and a staged install exits 0 over a known conflict, as shown above.

Escape hatchScopeWhat it turns offUse when
--force (npm)Whole tree, one commandPeer, engine, and audit-range protectionsNever in CI
--legacy-peer-deps (npm)Whole tree; every install once saved in .npmrcAll peer dependency checksA one-time local experiment
--no-deps (pip)Whole commandDependency resolutionInstalling a set you resolved and locked elsewhere
Scoped override, exact versionOne edgeThat parent's declared rangeThe parent is stuck and a compatible fixed release exists
In-place patchOne package versionNothing; the version stays the sameAn upgrade breaks callers or the package is unmaintained
ForkEverything the fork containsUpstream fixes, until you merge themYou will maintain it
Replace the dependencyThe subtreeThe constraint itselfThe parent is abandoned

The verdict: use the bottom four rows, chosen by how long the problem will last, and keep the top three out of CI. A scoped override or an in-place patch is right for a stuck parent you plan to keep. Replacement is right for an abandoned one. A fork moves the maintenance burden to you. Evan You described where that trend leads in Lovable's OJ rewrite of Vite's dev server: "everyone just maintains their own slop fork." A fork receives no upstream security fix until someone merges it.

How do you avoid dependency hell?

#

You avoid dependency hell by keeping version constraints wide where code is shared and narrow where code is deployed, and by paying down conflicts in small steps. None of these steps removes conflicts entirely. Each one keeps them smaller and makes them visible earlier.

Libraries should publish ranges. An exact pin in a published library forces a duplicate or a conflict on every consumer that needs a different version. A shrinkwrap file published inside a package goes further: Socket's analysis of npm shrinkwrap found it can override any transitive dependency in the consumer's tree and "land you in 'dependency hell.'" The cost of ranges is that they admit whatever the publisher ships next.

Applications should lock. A committed lockfile with a frozen install reproduces the last resolution, including its duplicates. A lockfile does not prevent the next conflict. It keeps a conflict from changing between your laptop and CI.

Declare shared versions once. pnpm catalogs, Maven BOMs and dependencyManagement, Gradle constraints, and pip constraints files (pip install -c) set one version for a package across a repository. Socket's coverage of pnpm 9.5 catalogs notes the goal of "reducing the likelihood of multiple versions of the same dependency in a monorepo." The cost is that one stubborn consumer can hold the whole repository back.

Update often, behind a cooldown. Conflicts compound. Ten small upgrades each resolve one constraint. One upgrade after three years must resolve all of them at once, which is how a project ends up stranded on a tool like react-scripts. Every update is a fresh resolution, so pair the cadence with a minimum release age.

Prefer maintainers who backport. A fix shipped only in the newest major line pushes every older consumer toward an override. For the minimatch ReDoS advisories in February 2026, fixes shipped on eight major lines, from 10.x back to 3.x, so consumers on any of those lines could patch within a caret range.

Fail CI on an inconsistent tree. Run pip check after the last install step, keep --legacy-peer-deps and --force out of committed configuration, and review overrides in the same pull request as the code they protect.

Conflicts live on one edge, but get approved for the whole tree

#

Every conflict in a dependency graph sits on one edge: one parent, one child, one range. The tools for accepting a conflict work at the wrong scale. --force and --legacy-peer-deps accept every conflict in the tree, including ones that do not exist yet. Even a scoped override outlives the parent version it was written for, with nothing to make anyone look again.

npm already solved this problem for a different risk. In npm 12, dependency install scripts are blocked until approved, and npm approve-scripts writes pinned entries that, in npm's documentation, "keep their approval narrowed to the specific version you reviewed." Conflict resolution should work the same way. An override should name the parent version it was written for, lapse when that parent changes, and fail the install until someone reviews it again. Until package managers record conflict decisions that way, dependency hell will keep being resolved by whoever is willing to type --force.