npm & Package Management

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out. The version numbers were checked on 2026-10-03; every example was run on Node.js 26, and on 24 and 22 wherever the behaviour could differ, plus npm 12.2.0 where the page says so. npm is a separate project that Node.js bundles, so the version you have depends on the Node.js line you installed: Node.js 26.10.0 ships npm 11.19.1, Node.js 24.21.0 ships npm 11.19.0 and Node.js 22.23.3 ships npm 10.9.9. npm 12 (12.0.0 on 2026-07-08, latest 12.2.0 on 2026-09-30) is not bundled with any Node.js line yet; it is installed explicitly and supports Node.js ^22.22.2 || ^24.15.0 || >=26.0.0. Unlike the Node.js API documentation, npm has no stability index: the commands and configuration options below are either released (in the npm version stated) or not. Where a feature differs between npm 10, 11 and 12 the page says so, because the differences are largest in the security area, where npm 12 changes defaults. The Bookshelf application that grows across this section uses almost no third-party code, which makes it a good place to learn how little of it you should add and how to add it safely.

What npm is, and which one you have

npm is three things with one name: the public registry at registry.npmjs.org, the npm command line client, and the package.json / package-lock.json file formats that describe a project and its dependency tree. The client is written in JavaScript and runs on top of Node.js; the Node.js installers and release archives include a copy of it (see Installing Node.js). The Learn article introduces npm install, the --save-dev, --no-save and --save-optional flags and npm run; this page goes further. The article also notes that Yarn and pnpm are alternatives to the npm client; they read the same registry. The Workspaces & Publishing page compares them and covers monorepos.

Table 1. npm versions bundled with each Node.js line (2026-10-03)
Node.js line Bundled npm Status What this page means for you

26 (26.10.0)

11.19.1

Current; LTS from 2026-10-28

Install-script warnings, min-release-age and npm install-scripts exist, but dependency scripts still run.

24 "Krypton" (24.21.0)

11.19.0

Active LTS

Same as Node.js 26 for everything on this page.

22 "Jod" (22.23.3)

10.9.9

Maintenance LTS

No min-release-age, no npm install-scripts; use --before for a date cut-off.

any supported line

12.2.0 (installed explicitly)

latest on the registry

Dependency install scripts blocked by default; git and remote tarball dependencies refused by default.

The release lines and their end-of-life dates are explained in Release Lines & LTS. To see which npm you are running, and to try npm 12 without changing the npm that came with Node.js, install it into a throwaway directory and run its binary from there (a global update with npm install -g npm@12 works too; npm itself prints that hint when a newer version exists).

Checking the npm version and trying npm 12 side by side
node --version
npm --version

mkdir npm12 && cd npm12
npm install npm@12.2.0
./node_modules/.bin/npm --version

On Node.js 26.10.0 this printed v26.10.0, 11.19.1 and 12.2.0; on Node.js 24.21.0 and 22.23.3 the middle line was 11.19.0 and 10.9.9. Every "npm 12" example below uses that locally installed binary (written npm12 in the prose).

A tour of the CLI

Most of the daily commands are verbs on the dependency tree. The tables and transcripts below come from a small scratch project; paths are shortened. Every command runs unchanged on npm 10, 11 and 12 unless stated.

npm install in its many forms

npm install (alias npm i) with no argument installs everything package.json and package-lock.json describe. With arguments it adds packages, and the argument is a package spec: a name with an optional version, range or tag, an alias, a folder, a tarball or a git location. Because npm 5 the command also records the dependency in package.json.

Table 2. Install forms and what each writes to package.json
Command Entry written Notes

npm install

nothing

Installs the whole tree from the lockfile (or resolves it if there is none).

npm install semver

"semver": "^7.8.5" in dependencies

The latest version, saved with the ^ prefix (save-prefix).

npm install semver@7.5

"semver": "7.5"

A range or version you type is saved as typed, not widened.

npm install prettier@latest -D

"prettier": "^3.9.9" in devDependencies

A dist-tag resolves to a concrete version first. -D is --save-dev.

npm install semver --save-exact

"semver": "7.8.5"

No range at all; see SemVer ranges and --save-exact.

npm install semver6@npm:semver@6

"semver6": "npm:semver@^6.3.1"

An alias: two majors of one package side by side under different names.

npm install is-odd --save-optional

entry in optionalDependencies

-O; installation failures of this package do not fail the install.

npm install react@^18 --save-peer

entry in peerDependencies

For libraries that must share the host’s copy.

npm install ../libdemo, npm install ./pkg-1.0.0.tgz

file: specifier

A folder is linked, a tarball is unpacked. Install scripts of a tarball run (see npm 12 and the supply-chain changes).

npm install git+https://host/user/repo.git, npm install https://host/pkg.tgz

git or URL specifier

Refused by default on npm 12 (allow-git and allow-remote default to none).

npm install --no-save ms

nothing

Installs without recording; npm ls then reports it as extraneous and npm prune removes it.

npm install --dry-run lodash

nothing

Prints add lodash 4.18.1 and changes nothing on disk.

npm install --package-lock-only

lockfile only

Updates package-lock.json without touching node_modules.

The table was produced by running the commands on npm 11.19.1 and spot-checked on 11.19.0 and 10.9.9. Two details save confusion later. A version you type, such as 7.5, stays a loose range in package.json, so use --save-exact or an exact version when you mean it. A GitHub shorthand such as github:user/repo resolves through git, which npm 12 refuses unless allow-git is relaxed.

Looking at the tree: show, ls, explain

npm view (aliases show, info, v) reads registry metadata without installing anything. npm ls prints what is installed, npm explain says why a package is there.

Reading registry metadata and the installed tree
npm view semver version dist-tags.latest engines
npm ls
npm ls --all
npm explain semver
npm ls -g --depth=0
version = '7.8.5'
dist-tags.latest = '7.8.5'
engines = { node: '>=10' }

demo@1.0.0 /path/to/demo
+-- prettier@3.3.3
`-- semver@7.8.5

semver@7.5.4 dev
node_modules/semver
  dev semver@"7.5" from the root project

/path/to/nvm/versions/node/v26.10.0/lib
`-- npm@11.19.1

npm ls shows only direct dependencies unless you add --all; --omit=dev restricts it to what production installs; --json gives machine-readable output; npm ls <name> shows every copy and the path that pulled it in. The last command lists the global packages of the current Node.js version, here only the bundled npm itself. On npm 12, npm view --json always returns an array, where npm 11 and 10 return an object for a single match.

outdated, update and uninstall

npm outdated compares installed, wanted and latest versions. Wanted is the highest version your range allows; latest is the highest published. The command exits with code 1 when something is outdated, which makes it usable as a CI check (observed on npm 11.19 and 12.2).

Finding and applying updates
npm install lodash@4.17.20
npm outdated
npm update lodash
npm ls lodash
npm uninstall lodash
Package   Current  Wanted  Latest  Location               Depended by
lodash    4.17.20  4.18.1  4.18.1  node_modules/lodash    demo
prettier    3.3.3   3.3.3   3.9.9  node_modules/prettier  demo

changed 1 package
demo@1.0.0 /path/to/demo
`-- lodash@4.18.1

The table explains two behaviours. prettier is pinned with --save-exact, so its wanted version equals the current one and only a deliberate npm install prettier@latest moves it. npm update lodash moved the installed copy to the highest version the range ^4.17.20 allows and rewrote the lockfile; as the documentation says, npm update does not change the ranges of direct dependencies in package.json unless you add --save, so a fresh clone resolves the same version only through the lockfile. npm uninstall (aliases remove, rm, un, r, unlink) deletes the package and its entry. npm 12.1.0 made uninstall reject arguments that carry a version, such as npm uninstall lodash@4.

prune, dedupe and cache

npm prune removes packages from node_modules that package.json no longer asks for ("extraneous" packages); with --omit=dev it also removes development dependencies. npm dedupe (alias ddp) flattens duplicate copies where ranges allow; npm find-dupes only reports them.

Removing extraneous packages
npm install is-even
npm pkg delete dependencies.is-even
npm prune

The install added five packages (is-even and its transitive dependencies); after the entry was deleted from package.json, npm prune printed removed 5 packages. npm pkg is the supported way to edit package.json from scripts: npm pkg get, set and delete. On npm 12 its output is no longer forced to JSON, so a single value comes back bare.

npm downloads every tarball into a content-addressed cache (the cache setting; npm config get cache prints the folder) and reuses it for later installs and for offline work.

Inspecting and repairing the cache
npm cache ls
npm cache verify
npm install lodash --prefer-offline

npm cache ls lists entries, such as make-fetch-happen:request-cache:https://registry.npmjs.org/is-even; --prefer-offline uses cached metadata and tarballs without revalidating them; npm cache verify checks integrity and garbage-collects unused content (it reported Content verified with the number of entries). The npm documentation calls the cache self-healing, so npm cache clean --force is rarely the answer to an install problem. In CI, cache the folder npm config get cache prints between runs and install with npm ci (see package-lock.json, npm ci and --omit=dev).

npm link makes a folder show up in another project’s node_modules as a symlink, which is how you try a library you are editing in an application that uses it, without publishing.

Linking a local library into an application
cd appdemo
npm link ../libdemo
ls -l node_modules/@bookshelf
node --input-type=module -e "import { isbn } from '@bookshelf/isbn'; console.log(isbn())"

The folder listing showed isbn → ../../../libdemo, the import printed 978-0000000000, and npm ls reported the link as extraneous because linking does not write package.json. Running npm link without an argument inside the library links it into the global folder instead, and npm unlink -g @bookshelf/isbn removes that. Remember that a linked package is resolved through its real path, so a linked library that imports a peer dependency gets its own copy of it unless you take care. The Workspaces & Publishing page shows workspaces, which cover the same need more robustly inside one repository.

SemVer ranges and --save-exact

Semantic versioning (SemVer) gives the three numbers meaning: increment the first (MAJOR) for breaking changes, the middle (MINOR) for backward-compatible features and the last (PATCH) for backward-compatible fixes. A range in package.json is a promise about which of those you accept automatically. The npm documentation lists the common forms: patch releases 1.0, 1.0.x or ~1.0.4; minor releases 1, 1.x or ^1.0.4; and any release * or x.

Common ranges, measured with the semver package that npm itself uses
import semver from 'semver';

const published = ['1.2.3', '1.2.9', '1.3.0', '1.9.0', '2.0.0', '2.0.1-beta.1'];

for (const range of ['^1.2.3', '~1.2.3', '>=1.3.0 <2.0.0', '1.x', '1.2.3 - 2.0.0', '*']) {
  console.log('picks', range.padEnd(16), '->', semver.maxSatisfying(published, range));
}

for (const range of ['^1.2.3', '^0.2.3', '^0.0.3', '~1.2.3']) {
  console.log('range', range.padEnd(8), 'means', new semver.Range(range).range);
}

console.log(semver.satisfies('2.0.1-beta.1', '>=1.0.0'));
console.log(semver.satisfies('2.0.1-beta.1', '>=2.0.1-beta.0'));
picks ^1.2.3           -> 1.9.0
picks ~1.2.3           -> 1.2.9
picks >=1.3.0 <2.0.0   -> 1.9.0
picks 1.x              -> 1.9.0
picks 1.2.3 - 2.0.0    -> 2.0.0
picks *                -> 2.0.0
range ^1.2.3   means >=1.2.3 <2.0.0-0
range ^0.2.3   means >=0.2.3 <0.3.0-0
range ^0.0.3   means >=0.0.3 <0.0.4-0
range ~1.2.3   means >=1.2.3 <1.3.0-0
false
true

The output is identical on Node.js 26, 24 and 22. Three rules are worth memorising. The caret ^ allows everything that does not change the left-most non-zero digit, so ^0.2.3 stays inside 0.2.x and ^0.0.3 accepts only 0.0.3: before the first stable release, every minor release may break. The tilde ~ allows patch releases only. A prerelease such as 2.0.1-beta.1 does not match a plain range like >=1.0.0; it matches only a range that names a prerelease on the same major.minor.patch, so * and ^ do not pull betas by accident. Remember that the range only describes what may be installed: the lockfile decides what is.

Two settings change what npm install <pkg> saves. --save-exact (-E) writes the exact version, and --save-prefix='~' writes a tilde range; both can be made permanent in a project .npmrc with npm config set save-exact true --location=project. The scratch run produced these entries on npm 11.19.1, 11.19.0 and 10.9.9:

{
  "dependencies": {
    "is-even": "~1.0.0",
    "is-odd": "^3.0.1",
    "semver": "7.8.5"
  }
}

Exact pins give reproducibility for the one package you pinned, but not for its transitive dependencies; only the lockfile fixes the whole tree. Pin directly when a tool must not move without review (formatters and code generators are typical), and let the lockfile plus a regular npm outdated review handle the rest.

package-lock.json, npm ci and --omit=dev

package-lock.json is written automatically whenever npm changes node_modules or package.json. It records the exact tree: every package location, version, registry URL (resolved), content hash (integrity) and whether it is a development or optional dependency. Commit it for every application. lockfileVersion: 3 is what npm 9 and later write, and what the scratch run produced. The excerpt below is the entry for one package (the hash is shortened):

A lockfile entry for a development dependency
"node_modules/prettier": {
  "version": "3.3.3",
  "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
  "integrity": "sha512-i2tDNA0O5IrMO75…",
  "dev": true,
  "license": "MIT",
  "bin": { "prettier": "bin/prettier.cjs" },
  "engines": { "node": ">=14" }
}

npm install treats the lockfile as a starting point: it may update it when package.json asks for something the lock does not satisfy. npm ci ("clean install", aliases clean-install, ic) treats it as a contract. It requires a lockfile, fails instead of changing it when package.json disagrees, deletes any existing node_modules first, can only install the whole project, and never writes package.json or package-lock.json. That is what you want in CI, in Docker builds and on deployment machines.

Reproducible installs with npm ci
npm ci
npm ci --omit=dev
npm ls --omit=dev
npm pkg set dependencies.semver="^6.0.0"
npm ci
added 3 packages in 192ms
added 2 packages in 185ms
demo@1.0.0 /path/to/demo
+-- lodash@4.18.1
`-- semver@7.8.5

npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Invalid: lock file's semver@7.8.5 does not satisfy semver@6.3.1

The first call installed all three packages, the second --omit=dev installed only the two production ones (the development dependency prettier is gone), and the last call failed with exit code 1 because the edited range no longer matched the lock. The same output was produced on Node.js 24 and 22. The --omit=dev flag is the current way to leave out development dependencies; the older --production flag and --only=prod are documented as deprecated aliases for it. A Dockerfile therefore installs with npm ci --omit=dev, as shown in Docker & Containers. If you generate the lockfile with flags that change the tree shape, such as --legacy-peer-deps, pass the same flags to npm ci, or store them in the project .npmrc.

npm ci also fixes the order in which scripts run, which matters for the Scripts: lifecycle, pre and post, --, .bin section: preinstall runs after the lockfile has been validated but before dependencies are fetched, so it cannot rely on anything in node_modules. Two more facts about lockfiles: node_modules/.package-lock.json is a "hidden lockfile" npm uses to avoid rescanning the folder (never commit it), and as of npm 12 the file npm-shrinkwrap.json is no longer read or written (rename it to package-lock.json; a dependency that ships one inside its tarball has it ignored, so publishers who need to ship a locked tree use bundleDependencies).

Dependency types and overrides

package.json has several dependency maps, and the map decides when a package is installed and who is responsible for it. The full file reference is the subject of package.json, exports & imports.

Table 3. Dependency fields
Field Installed when Use it for

dependencies

always, including --omit=dev installs and when your package is installed by someone else

Code your package imports at run time.

devDependencies

in your own project, but not with --omit=dev and not when your package is a dependency of another

Test runners, linters, formatters, type checkers, build tools.

optionalDependencies

always, but a failure to install does not abort the install

Platform-specific accelerators; your code must cope when the import fails.

peerDependencies

npm 7 and later install them automatically unless that creates a conflict

Plugins and libraries that must share one copy of a host package (a framework, a type definition package).

bundleDependencies

packed into the tarball when you publish

Shipping a fixed tree; the only way to ship locked versions on npm 12.

When two packages need incompatible versions of a third, npm installs both in nested node_modules folders. When you must change what is installed deep in the tree, for instance to apply a security fix before the intermediate package releases one, use overrides. It is honoured only in the root package.json of the project; overrides inside installed dependencies and workspaces are ignored.

Forcing a patched transitive dependency
npm install is-odd@3.0.1
npm ls is-number
npm pkg set 'overrides.is-number'='7.0.0'
npm install
npm ls is-number
ov@1.0.0 /path/to/ov
`-- is-odd@3.0.1
  `-- is-number@6.0.0

ov@1.0.0 /path/to/ov
`-- is-odd@3.0.1
  `-- is-number@7.0.0 overridden

The first npm ls shows the version that is-odd asked for; after the override, the same position is 7.0.0 and npm ls marks it overridden. An override can be a bare version (applies everywhere), a nested object ({ "is-odd": { "is-number": "6.0.0" } } applies only below is-odd), a semver range, a dist-tag or a replacement spec such as npm: or file:. Overrides are an escape hatch: they can pick a version the consuming package was never tested with, so remove them once the real dependency catches up. Run npm explain <name> to see which override applies.

Scripts: lifecycle, pre and post, --, .bin

The scripts field maps names to shell commands. npm run <name> runs one; npm start, npm test, npm stop and npm restart are shortcuts for the built-in names, and npm run with no name lists everything. npm runs the command through /bin/sh on POSIX systems and cmd.exe on Windows (configurable with script-shell), always from the package root, with an environment that includes npm_package_name, npm_package_version, npm_lifecycle_event and npm_package_json. Since npm 7 most other package.json fields are no longer exported as variables; read the file or use npm_package_json.

A script set with hooks, an argument-forwarding script and a local binary
{
  "name": "bookshelf-scripts",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "prebuild": "echo prebuild",
    "build": "echo build",
    "postbuild": "echo postbuild",
    "pretest": "echo pretest",
    "test": "node --test",
    "posttest": "echo posttest",
    "args": "node args.js",
    "fmt": "prettier --version"
  },
  "devDependencies": { "prettier": "3.3.3" }
}

Two behaviours are visible in this file. Any script x can have prex and postx companions, which npm runs automatically around it: npm run build printed prebuild, build, postbuild in that order, and npm test ran pretest, the tests, then posttest. And fmt calls prettier with no path: npm adds the project’s node_modules/.bin (and those of parent folders) to PATH for the script, so the locally installed version runs (3.3.3), whatever is or is not installed globally. That is why scripts, not global tools, are the way to share commands in a repository.

Passing arguments through to the script with — 
import process from 'node:process';

console.log(process.argv.slice(2));
npm run args --silent -- --port 3000 --verbose
npm run args --silent --port 3000
[ '--port', '3000', '--verbose' ]
[ '3000' ]

Everything after a lone -- goes to the script; without it npm treats --port as its own option and passes only the non-option 3000. On Node.js 26, 24 and 22 (npm 11 and 10) the second form also printed a warning that --port is an unknown cli config that "will stop working in the next major version of npm", and npm 12 is that major version: it fails with EUNKNOWNCONFIG and Unknown cli flag: --port. Always write the --. The same npm 12 release turned unknown keys in .npmrc, abbreviated flags and single-hyphen multi-character shorthands into errors, so a typo is no longer silently ignored. For running a script without npm at all, see node --run in Running Scripts, the CLI & the REPL; it runs the same scripts entries, adds node_modules/.bin to the path, but does not run pre and post companions.

Which scripts run when

Besides the arbitrary names, npm runs fixed lifecycle scripts around its own commands. The ones that matter for application developers:

Table 4. Lifecycle order for the commands you use most (from the npm documentation, checked by running them)
Command Scripts, in order

npm install (no arguments), npm ci

preinstall, install, postinstall, prepublish (deprecated), preprepare, prepare, postprepare (root project only)

npm test, npm start, npm stop, npm restart

pre<name>, <name>, post<name>

npm pack

prepack, prepare, postpack

npm publish

prepublishOnly, prepack, prepare, postpack, publish, postpublish

npm version

preversion, version, postversion

npm rebuild

preinstall, install, postinstall, prepare (the last only for linked packages)

A root package.json with echo scripts for each name printed preinstall install postinstall prepublish prepare postprepare for a bare npm install on npm 10.9.9, 11.19.1 and 12.2.0 alike; npm install is-odd (with a package argument) ran none of them. prepare is the right place for a build step that must happen before the package is used, for example compiling TypeScript, while prepublish is deprecated in favour of prepare and prepublishOnly. npm no longer has uninstall scripts: since npm 7 removal can happen for too many reasons to give a script enough context. The scripts of the dependencies you install are a different matter: their preinstall, install and postinstall are exactly what the npm 12 policy in npm 12 and the supply-chain changes now blocks. Also, prepare scripts of workspaces run concurrently, and since npm 7 they run in the background; add --foreground-scripts to see their output.

npx and npm exec

npx runs a command from an npm package: one installed in the project, or one fetched on demand into the npm cache. It shares its implementation with npm exec (since npm 7). Local executables win: with semver installed as a dependency, npx semver uses that copy; otherwise the package is downloaded and a prompt asks first (suppress it with --yes or --no).

Running package binaries
npx --yes semver 1.2.3 -r '^1.0.0'
npx --yes semver 2.0.0 -r '^1.0.0'
npx --yes -p semver -c 'semver -i minor 1.2.3'
npm exec -- semver -i patch 1.2.3
npx --yes semver@6 --help

Source: npm Docs: npx

The first command printed 1.2.3 (the version satisfies the range), the second printed nothing and exited with code 1, the third printed 1.3.0, the fourth 1.2.4, and the last printed the help of SemVer 6.3.1 rather than the 7.x copy: a package name with a specifier only counts as the local one when name and version match exactly. When no prompt can be shown (no terminal), npm 11 printed a warning that the package "was not found and will be installed" and went ahead, so be careful in scripts that call npx on unreviewed names: that is a way to run code you have not locked in package-lock.json. The option rule from the npm documentation: with npx, all flags must come before the first positional argument; with npm exec, use -- to separate npm’s flags from the command’s. In a real terminal, name the package and its version explicitly (npx --yes semver@7.8.5) when the command must be reproducible.

npm audit, signatures and supply-chain hygiene

npm audit submits a description of the dependencies configured in your project to the registry and prints the report of known vulnerabilities it gets back. npm audit fix applies compatible updates, --audit-level=<level> sets the minimum severity that makes the exit code non-zero, and --omit=dev limits the report to what production installs. npm audit signatures verifies the registry signatures of the installed tarballs.

Auditing a deliberately vulnerable pin
npm install minimist@1.2.0
npm audit
npm audit --audit-level=critical
npm audit fix
npm audit signatures
# npm audit report

minimist  1.0.0 - 1.2.5
Severity: critical
Prototype Pollution in minimist - https://github.com/advisories/GHSA-vh95-rmgr-6w4m
fix available via `npm audit fix`
node_modules/minimist

1 critical severity vulnerability

changed 1 package, and audited 2 packages in 564ms
found 0 vulnerabilities

1 package has a verified registry signature

npm audit exited with code 1 (it does whenever a vulnerability is found, as the documentation says), npm audit fix moved minimist to 1.2.8 within its range, and the signature check passed. The output is a point-in-time snapshot of the advisory database, so yours will differ. Audit is a floor, not a guarantee: it knows only published advisories, says nothing about malicious packages published yesterday, and complains about development dependencies that never reach production (use --omit=dev). The wider threat model (typosquatting, dependency confusion, compromised maintainers) and what to do about it is the topic of Security Best Practices. The npm threats-and-mitigations page is the official companion. Combine five habits: commit and review the lockfile, install with npm ci, keep the tree small, apply the npm 12 script policy below, and consider a release-age cool-down (also below).

Global versus local installs

A local install puts packages in the project’s node_modules and lists them in package.json; anything the application imports or runs belongs there. A global install (-g) puts a package in the folder of the current Node.js installation and exists to put a command on your PATH. Because the global folder is per Node.js installation, switching versions with a version manager such as nvm gives you a different set of global packages; the Installing Node.js page covers the managers.

Where local and global packages live
npm root
npm root -g
npm prefix -g
npm ls -g --depth=0

On the scratch machine the local root was the project’s node_modules, the global root was <prefix>/lib/node_modules under the active Node.js installation, and the global list contained only npm@11.19.1. Prefer local installs and npm exec or npx for tools: the version is recorded in package.json and the lockfile, so everyone, including CI, uses the same one. Reserve global installs for tools that are not tied to a project, and note that npm 12 asks you to provide install-script permissions for global installs and npx through the --allow-scripts flag, because there is no project package.json in which to record them (npm 12 and the supply-chain changes). On Unix-like systems, do not use sudo npm install -g: install Node.js with a version manager or into a user-owned prefix instead.

npm 12 and the supply-chain changes

npm 12.0.0 (2026-07-08) is a major release with defaults aimed at supply-chain attacks. The 12.0.0 release notes list the breaking changes verified here by running npm 12.2.0 on Node.js 26.10.0:

Table 5. Behaviour that changes in npm 12
Change What happens

Dependency lifecycle scripts blocked by default

preinstall, install, postinstall (and prepare for non-registry sources) of dependencies only run if the root package.json approves them in allowScripts.

Git and remote tarball dependencies

allow-git and allow-remote default to none; git+https://… fails with EALLOWGIT and a URL tarball with EALLOWREMOTE until set to all (or root). allow-file and allow-directory default to all.

Root preinstall

Runs before dependencies are fetched.

Unknown configuration

Unknown .npmrc keys, unknown or abbreviated CLI flags and single-hyphen multi-character shorthands throw instead of warning.

Removed or changed commands

npm shrinkwrap, star, stars, unstar and adduser are removed (log in with npm login); npm pkg and npm view --json change their output shape; man pages are no longer registered system-wide.

Supported Node.js

`^22.22.2

^24.15.0

>=26.0.0`.

Install scripts are blocked: allowScripts

A dependency’s install-time scripts run arbitrary code on your machine and your CI runner during npm install, before you have imported a line of it. That is the delivery mechanism of most npm supply-chain malware, so npm 12 makes you opt in. The test case: esbuild has a postinstall script (node install.js).

The same install on npm 11 and npm 12
npm install esbuild
npm12 install esbuild
npm12 install-scripts ls
npm12 install-scripts approve esbuild
npm12 rebuild
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts   esbuild@0.28.2 (postinstall: node install.js)
...
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   esbuild@0.28.2 (postinstall: node install.js)
...
1 package has install scripts blocked because they are not covered by allowScripts:
  esbuild@0.28.2 (postinstall: node install.js)

Approved esbuild:
  added esbuild@0.28.2
rebuilt dependencies successfully

Note the verbs. npm 11.19 (Node.js 26 and 24) warns that the script is "not yet covered" and still runs it, and it already ships the npm install-scripts command; npm 12 says the script "had install scripts blocked" and does not run it; npm 10 (Node.js 22) says nothing and runs it. The experiment that proved it used a local package whose postinstall writes a marker file: the marker existed after installing with npm 10 and npm 11, and was absent with npm 12 until the package was approved and npm rebuild ran. The approval is written to the root package.json:

The allowScripts field after approving
{
  "dependencies": { "esbuild": "^0.28.2" },
  "allowScripts": { "esbuild@0.28.2": true }
}

The pinned form esbuild@0.28.2 approves only the version you reviewed; --no-allow-scripts-pin writes a name-only entry for any version. npm install-scripts deny <pkg> writes a false entry that survives approve --all and keeps a package blocked; npm install-scripts prune removes entries that no longer match anything. npm approve-scripts and npm deny-scripts are aliases. After the approval, npm ci ran the script on a fresh node_modules (the policy in package.json applies to every install). Four configuration options sharpen the policy:

Table 6. Install-script options on npm 12
Option Effect

--strict-allow-scripts

An unreviewed script becomes a hard error (ESTRICTALLOWSCRIPTS) instead of a warning, which is a good setting for CI. Packages denied with false are silently skipped.

--allow-scripts=<names>

For one-off and global contexts (npx, npm exec, npm install -g) only; using it with a project npm install, ci, update or rebuild is an error.

--ignore-scripts

Runs no scripts at all (including your own); it overrides every other setting.

--dangerously-allow-all-scripts

Bypasses the policy; documented as a migration escape hatch whose use is strongly discouraged.

Treat the first run on npm 12 as a review: run npm install-scripts ls, read what each script does (most native packages only download or compile a binary), approve the ones you trust, and commit the allowScripts field. Packages with native binaries, such as canvas or sharp (the examples used in the npm documentation), are the usual entries. The policy also covers prepare of dependencies installed from git or from a folder or tarball; it never affects the scripts of your own root project.

min-release-age: a cool-down for new versions

A cool-down gives the registry and the community time to detect and remove a malicious release before it reaches your machine. The min-release-age option (added in npm 11.10.0, 2026-02-11, so available on Node.js 24.21 and 26.10 but not on npm 10 in Node.js 22) makes npm build the tree only from versions that have been published for more than the given number of days. It complements --before, which takes an exact date. If no version satisfies the constraint, the install fails; min-release-age-exclude exempts package names or glob patterns, such as your own scope.

Installing only versions at least 200 days old
npm install semver --min-release-age=200
npm install semver --min-release-age=200 --min-release-age-exclude=semver
npm install semver --before=2026-01-01
npm install semver --min-release-age=100000

On 2026-10-03 these resolved to semver@7.7.4, semver@7.8.5 (the excluded package takes the newest), semver@7.7.3 and the last one failed with ENOVERSIONS ("No versions available for semver"). With npm 12.2.0 and on Node.js 24 (npm 11.19.0) the first command gave the same result; on Node.js 22 (npm 10.9.9) the option was ignored silently and 7.8.5 was installed, so a team that still runs Node.js 22 should use --before or a registry proxy. Put the value in the project .npmrc (the npm documentation’s own example uses min-release-age=7) so that every developer and CI job gets it. The option also applies to npm audit fix: when a patched version is newer than the window, npm keeps the vulnerable version and exits with a non-zero code, so list the package in min-release-age-exclude to install an urgent fix. The option is not exported to child processes.

What changed recently

Table 7. npm changes that matter to application developers
Version Date Change

npm 11.10.0

2026-02-11

min-release-age added (included in the npm 11.19.x that Node.js 24 and 26 bundle).

npm 11.19.x

bundled with Node.js 26.10.0 and 24.21.0

npm install-scripts exists and install scripts not covered by allowScripts are reported with a warning; scripts still run.

npm 12.0.0

2026-07-08

Dependency scripts blocked by default; allow-git/allow-remote default to none; root preinstall before fetch; strict handling of unknown config; npm shrinkwrap and adduser removed; new supported-Node range.

npm 12.1.0

2026-09-21

npm stage display of staged packages, uninstall rejects version arguments, allowScripts keys match local paths, persistent allow-scripts is not exported to the environment.

npm 12.2.0

2026-09-30

npm dist-tag supports OIDC authentication.

Earlier changes still worth knowing: the --production flag is deprecated in favour of --omit=dev; npm 7 made peerDependencies install automatically and introduced lockfile version 2 and the hidden lockfile; npm 9 introduced lockfileVersion: 3. Corepack is no longer part of the Node.js distribution (see Installing Node.js), so pnpm and Yarn are installed like any other tool.

Where the books are outdated

Efficient Node.js (Samer Buna, O’Reilly 2025) has a chapter on package management covering npm show, ls, link, cache, the install forms, SemVer operators, --save-exact, update, outdated, prune, the lockfile, scripts and npx. Its baseline is Node.js 22 with npm 10, so the concepts hold but three points need updating: use npm ci and --omit=dev where an older workflow runs prune and install before tests or deploys, treat --production as deprecated, and read the install section knowing that npm 12 blocks dependency scripts, refuses git and URL dependencies by default and errors on unknown flags. Nothing in it covers allowScripts, npm install-scripts or min-release-age, which did not exist in npm 10.

Node.js Projects (Jonathan Wexler, O’Reilly 2025) uses npm init, package.json fields, SemVer ranges and, in its appendices, Yarn and Corepack. The npm usage is still valid; the Corepack route is not, because Corepack is no longer bundled with current Node.js. Its projects also install packages such as dotenv, nodemon and npm-run-all, for which Node.js now has built-ins (--env-file, --watch and node --run, covered in CLI Options, Environment & Config File), and a native add-on installed from npm would need an allowScripts entry on npm 12. Both books were written before npm 12, so neither explains why npm install on a fresh machine may now report blocked scripts.

For publishing a package to the registry, package.json fields such as files, exports and engines, and trusted publishing, see JavaScript Tooling: Bundling, npm & Publishing; this page deliberately covers only what that page lacks (lockfiles and npm ci, audit, npm 12, npx and overrides). The Node.js-specific publishing topics and workspaces continue in Workspaces & Publishing.

References