# 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

Source: https://socket.dev/glossary/dependency-hell

Last updated: 2026-11-01

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](https://socket.dev/blog/create-react-app-officially-deprecated), 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](https://socket.dev/glossary/dependency-graph) whose version constraints no longer have an acceptable solution. The [semantic versioning](https://socket.dev/glossary/semantic-versioning-semver) 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](https://semver.org/). 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](https://socket.dev/glossary/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](https://research.swtch.com/version-sat), "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.

> 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](https://socket.dev/glossary/pip-python-package-index) installs exactly one version of each distribution into an environment, so it has to refuse. Since pip 20.3, its resolver [backtracks](https://pip.pypa.io/en/latest/topics/dependency-resolution/) 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](https://pip.pypa.io/en/stable/topics/secure-installs/) 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](https://doc.rust-lang.org/cargo/reference/resolver.html) 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](https://socket.dev/glossary/go-modules) avoid the search by changing the problem. A `go.mod` requirement states only a minimum version, and [minimal version selection](https://go.dev/ref/mod#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](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html) 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.

| Ecosystem | Two parents need incompatible versions of D | What you see | Security consequence |
| --- | --- | --- | --- |
| npm (regular dependencies) | Installs both copies, nested | Nothing | Old copies survive upgrades of the new one |
| npm (peer dependencies) | Refuses | ERESOLVE, which suggests --force | The suggested flags disable checks tree-wide |
| pnpm | Installs both; peer conflicts warn | A warning | Broken peer graphs ship |
| pip | Refuses within one command; allows conflicts across commands | ResolutionImpossible, or ERROR text with exit code 0 | Inconsistent environments pass CI; source distributions run code during the search |
| Cargo | Refuses within a compatible range; builds both across majors | failed to select a version | Duplicates across majors, errors within one |
| Go | Highest minimum wins; majors are separate module paths | Nothing | No upper bounds, so no conflicts; upgrades stay explicit |
| Maven | Nearest declaration wins | Nothing | A 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](https://react.dev/blog/2025/02/14/sunsetting-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:

> 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](https://github.com/request/request) 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](https://socket.dev/glossary/end-of-life-dependencies) 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](https://docs.npmjs.com/cli/v12/configuring-npm/package-json) 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](https://github.com/advisories/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](https://socket.dev/glossary/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](https://docs.socket.dev/docs/reachability-analysis).

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](https://github.com/advisories/GHSA-p8p7-x288-28g6) 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](https://socket.dev/blog/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](https://socket.dev/blog/npm-overrides-bug-fixed), 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](https://socket.dev/blog/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](https://socket.dev/blog/free-certified-patches-for-critical-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](https://docs.npmjs.com/cli/v12/using-npm/config) 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 hatch | Scope | What it turns off | Use when |
| --- | --- | --- | --- |
| --force (npm) | Whole tree, one command | Peer, engine, and audit-range protections | Never in CI |
| --legacy-peer-deps (npm) | Whole tree; every install once saved in .npmrc | All peer dependency checks | A one-time local experiment |
| --no-deps (pip) | Whole command | Dependency resolution | Installing a set you resolved and locked elsewhere |
| Scoped override, exact version | One edge | That parent's declared range | The parent is stuck and a compatible fixed release exists |
| In-place patch | One package version | Nothing; the version stays the same | An upgrade breaks callers or the package is unmaintained |
| Fork | Everything the fork contains | Upstream fixes, until you merge them | You will maintain it |
| Replace the dependency | The subtree | The constraint itself | The 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](https://socket.dev/blog/oj-vite-rust): "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](https://socket.dev/blog/understanding-the-security-concerns-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](https://socket.dev/glossary/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](https://socket.dev/blog/pnpm-9-5-introduces-catalogs-shareable-dependency-version-specifiers) 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](https://socket.dev/blog/minimatch-patches-3-high-severity-redos-vulnerabilities) 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](https://docs.npmjs.com/cli/v12/commands/npm-approve-scripts), "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`.
