12k
All articles

npm Commands for When Things Go Wrong

Use npm ls, npm explain, overrides, and npm ci to trace unexpected dependencies, fix bad versions, and avoid lockfile drift.

OpenReplay Team
OpenReplay Team
npm Commands for When Things Go Wrong

When a package shows up in node_modules that nothing in package.json asked for, or at a version you did not pin, run npm ls <package> to see where it sits and npm explain <package> to see which dependency pulled it in, before you touch anything.

Every developer has had the moment of staring at a version number in node_modules and thinking, where did you come from? The reflex is familiar: something looks wrong in the tree, so you rm -rf node_modules, reinstall, and hope. Sometimes the problem goes away. More often it comes straight back, because the installer rebuilt the same tree from the same inputs, and now you have no idea what changed.

This article walks through a single investigation: an unexpected package or version, traced back to the dependency that requested it, then fixed at the right layer. Install-time failures like ERESOLVE, EACCES, and native build errors are covered elsewhere on this blog, in the guides to fixing ERESOLVE conflicts, EACCES permission errors, and node-gyp build failures. This piece is for when nothing has thrown an error yet.

Key Takeaways

  • npm ls <package> shows every place a package appears in the installed tree and the version at each location; npm explain <package> shows the chain of dependencies that asked for it.
  • Without --all, npm ls lists only your direct dependencies; with --all it prints the complete tree, and --depth=<n> sets an explicit cutoff between those two extremes.
  • npm why is an alias for npm explain, so the same word works across npm, pnpm, and yarn.
  • The overrides field in package.json forces a specific version of a nested dependency regardless of the range its parent requested, which is why updating the parent should be tried first.
  • npm ci requires an existing package-lock.json, deletes node_modules, installs exactly what the lockfile specifies, and exits with an error if the lockfile and package.json disagree.

Why Does Deleting node_modules Destroy the Evidence?

Deleting node_modules and reinstalling removes the only record of how an unexpected package got into your project. The installed tree and package-lock.json together encode every resolution decision npm made: which parent requested which range, which version satisfied it, and where the result landed on disk.

A reinstall replays those decisions from package.json and the lockfile. If the inputs have not changed, you get the same tree and the same surprise. If they have changed (a config flag, a registry, a range edit), the reinstall overwrites the state you needed to compare against. Either way, read the tree before you rebuild it. The two commands that read it are npm ls and npm explain.

npm ls: Where Is the Package and at What Version?

npm ls <package> filters the installed tree to the paths that end at the named package, printing each location as name@version with its parents indented above it. Filter by a version range too, as in npm ls semver@^6, when you only care about the copies in a specific major.

# Every copy of semver, with the path down to each
npm ls semver

# The complete tree, not just direct dependencies
npm ls --all

# Cap the walk at two levels
npm ls --all --depth=2

# Only what ships to production
npm ls --all --omit=dev

The depth setting defaults to 0 unless --all is passed, in which case it becomes Infinity. That default governs a bare npm ls with no package argument. Once you name a package, npm follows the path to every copy regardless of depth, which is why the docs’ own npm ls promzard example shows a nested hit without --all; pass --depth=<n> explicitly if you want to cap that walk.

What npm prints is a map of which package depends on which, so it will not match how the folders actually sit on disk: a deduplicated package shows up beneath every parent that needs it, not only in the one place its files live. The output also flags packages that are extraneous (installed but not declared), missing, or at a version that does not satisfy the declared range; missing packages appear with an UNMET DEPENDENCY label. Add --package-lock-only and npm reports the tree the lockfile would produce, ignoring whatever node_modules currently holds.

Two spelling notes. The current filters are --omit=dev and --include=dev; --production is a deprecated alias for --omit=dev and --dev a deprecated alias for --include=dev, while --development is not a documented option at all. Also, npm ls exits non-zero when a package is missing or at an invalid version, or when a named package matches nothing, which makes it usable as a CI check; extraneous packages on their own do not fail it.

npm explain: Who Asked for the Package?

npm explain <package> prints, for each installed copy, the chain of dependency declarations that caused it to be there, walking upward until it reaches the root project. Where npm ls answers “where,” npm explain answers “who.”

npm explain semver
npm why semver              # identical
npm explain semver --json   # for jq

Each block in the output starts with the resolved name@version and its node_modules path, then indents one line per hop: the range a parent declared, the parent’s own version, and the parent’s path, ending with a line that names the root project. Read it bottom-up to follow your package.json down to the copy you did not expect. Duplicated packages get one block per copy, so conflicting ranges are visible side by side. You can also pass a folder, such as npm explain node_modules/foo/node_modules/semver, to explain exactly one nested copy.

The npm explain synopsis lists why as its alias, and the other major managers use the same verb.

Package managerCommandOutput shape
npmnpm explain <pkg> or npm why <pkg>One block per installed copy, chain up to the root
pnpmpnpm why <pkg>An upside-down tree, with the package you asked about on top
Yarnyarn why <pkg>Reasons per workspace, accepts pkg@range

Should You Update the Parent or Add an Override?

Once npm explain names the parent that requested the bad range, the first fix is to move that parent to a release that requests a better one. Run npm outdated <parent> to see whether a newer version exists, or read the parent’s package.json in the registry with npm view <parent>@latest dependencies. If a newer parent declares an acceptable range, update it and let npm re-resolve the child.

Only when no parent release fixes the range should you reach for overrides:

{
  "overrides": {
    "semver": "^7.5.4"
  }
}

An override replaces the nested dependency’s version regardless of the range the parent declared, so the parent may now run against a version it was never tested with. That is the trade-off, and it is why overrides is the second move rather than the first. A few rules from the docs: overrides are honored only in the root package.json; a package you depend on directly can only be overridden with a spec identical to its own, otherwise npm throws EOVERRIDE, and the $name reference form exists for that case; and values can be an exact version, a range, a dist-tag, or an npm:, file:, or Git specifier. Scope the override under the parent’s name when you want it to apply to one branch of the tree instead of everywhere.

npm config list: Settings You Forgot You Set

npm config list prints the settings that you, your environment, or an .npmrc file have set; npm config list -l also prints npm’s defaults, and --json returns the same data as JSON. When a tree resolves in a way package.json alone cannot explain, the cause is often a config value nobody remembers writing.

npm config list
npm config list -l

The output is grouped by source (command line, environment, project .npmrc, user .npmrc, global), which tells you which file to edit. Two keys deserve a look first. A non-default registry means versions were resolved against a mirror or private registry whose contents may lag the public one. A saved legacy-peer-deps setting tells npm to build the tree without consulting peerDependencies at all, the way it behaved up to version 6, so you can end up with pairings the current resolver would have refused. There is a knock-on effect: once a lockfile has been built with that flag, every later npm ci needs it too, or the install breaks. One forgotten line in a project .npmrc can explain both an odd local tree and a red CI run.

npm ci vs npm install: What Happens When the Lockfile Disagrees?

When the lockfile satisfies package.json, npm install uses the lockfile’s exact versions; when it does not, npm install re-resolves and updates package-lock.json. npm ci errors instead.

Behaviornpm installnpm ci
Requires package-lock.jsonNoYes
Lockfile and package.json disagreeRe-resolves, rewrites lockfileExits with an error
Existing node_modulesReusedRemoved first
Writes package.json or lockfileYesNever
Add a single packageYesNo

The npm install docs are explicit about the pecking order: the ranges in package.json are the source of truth, and the lockfile only keeps its pinned versions for as long as they still fit inside those ranges. That is exactly the behavior you do not want in CI, where a silently rewritten lockfile hides the drift you are trying to catch. npm ci refuses to reconcile the two files and fails loudly, so use it in pipelines and keep npm install for the machine where you intend to change dependencies.

Conclusion

An unexpected package in the tree is a resolution decision with a paper trail, and npm ls plus npm explain read that trail without disturbing it. Trace the chain to the parent that declared the range, fix the parent if a better release exists, override only when it does not, then check npm config list for settings that skewed the resolution in the first place. Run npm ci in CI so the next mismatch fails the build instead of quietly rewriting the lockfile.

FAQs

What does 'deduped' mean next to a package in npm ls output?

A 'deduped' label means npm ls is showing the package at that point in the logical dependency graph, but no separate copy exists there: a single installed copy higher in node_modules satisfies that parent's range. It is not an error. Because npm ls prints the logical tree, the same package appears under every parent that requires it, and only the unlabelled line corresponds to a physical folder.

How do I remove packages that npm ls reports as extraneous?

Run npm prune. It deletes anything sitting in node_modules that nothing else depends on; name one or more packages to limit it to those. Add --omit=dev, or set NODE_ENV to production, and your devDependencies go too. Use --dry-run to see the plan first, and --json to get the changes back as JSON. Installs already clear out extraneous packages on their own, so you mostly need this after a crash or a half-finished install.

Does npm dedupe fix duplicate versions that npm ls shows, or do I need overrides?

npm dedupe only consolidates copies the declared ranges already allow. It walks the tree and lifts each dependency as high as it can, so parents with overlapping ranges end up sharing a single copy, and it never pulls anything new from the registry. If two parents ask for ranges with no version in common, both copies stay, and the fix is updating a parent or adding an overrides entry. npm find-dupes runs the same pass as a dry run, so you can see the outcome first.

How do I list globally installed npm packages?

Run npm ls -g. The --global flag points npm ls at the global prefix, listing packages installed there instead of in the current project. The same depth rules apply: without --all it prints only top-level global packages, and npm ls -g --all expands each one into its full dependency tree. Add an explicit --depth value to cap the walk, or --json for machine-readable output.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.