← Back to postsCoding Notes
EnglishPublished Apr 12, 2026Updated Apr 12, 20266 min read

npm, npm -g, and npx: A Deep Dive

Tips

You've used these tools thousands of times. But do you actually know what's happening under the hood?


The Mental Model You Probably Have (And Why It's Slightly Wrong)

Most engineers carry a vague mental model: npm install puts stuff in node_modules, -g puts it "somewhere global," and npx "just runs it." That's good enough to be productive — but not good enough to debug a broken PATH, reason about version conflicts, or design a clean CI pipeline.

Let's fix that.


npm: The Package Manager

npm is three things simultaneously: a CLI tool, a package registry (npmjs.com), and a package format (the package.json contract). When engineers say "npm," they usually mean the CLI — but conflating all three causes confusion.

What npm install actually does

When you run npm install, npm:

  1. Reads package.json and package-lock.json
  2. Resolves the full dependency tree (including transitive deps)
  3. Fetches tarballs from the registry (or cache at ~/.npm)
  4. Extracts packages into node_modules/
  5. Writes/updates package-lock.json

The lock file is a deterministic snapshot — a tree of resolved versions, download URLs, and integrity hashes. This is what npm ci uses to guarantee byte-for-byte reproducible installs. If you're not using npm ci in CI pipelines, you're flying blind.

bash
npm install          # installs from package.json, updates lock file
npm ci               # installs from lock file exactly, never updates it
npm install express  # adds to dependencies, updates both files
npm install -D vitest  # adds to devDependencies

The node_modules structure

npm v3+ uses a flat node_modules structure (with some exceptions). Instead of deeply nested trees, packages are hoisted to the top level when possible. This reduces duplication but can cause phantom dependency issues — your code imports a package that isn't in your package.json, because it was hoisted from a transitive dep. It works until it doesn't.

code
node_modules/
  express/        ← hoisted, even though you only depend on it transitively
  lodash/
  your-package/
    node_modules/
      lodash/     ← NOT hoisted — version conflict with top-level lodash

This is why pnpm exists, with its symlinked, content-addressed store and strict dependency isolation. But that's another article.


npm -g: The Global Install

Global installs live outside any project. They're tools you use across your machine, not dependencies of a specific codebase.

bash
npm install -g typescript
npm install -g eslint
npm install -g @anthropic-ai/claude-code

Where do globals actually go?

bash
npm root -g   # shows global node_modules path
npm bin -g    # shows global bin path

On a typical Unix system:

code
/usr/local/lib/node_modules/   ← global node_modules
/usr/local/bin/                ← symlinks to global package binaries

With nvm, it's per-Node version:

code
~/.nvm/versions/node/v22.0.0/lib/node_modules/
~/.nvm/versions/node/v22.0.0/bin/

This is the source of a classic bug: you install a global tool with Node 18, switch to Node 20 via nvm, and the tool vanishes. You haven't lost it — it's just under the Node 18 path, which is no longer on your PATH.

The case against globals

Globals have a fundamental problem: they're not reproducible. Your machine has typescript@5.4 globally. A teammate has 5.2. CI has whatever was last installed by whoever set it up. This is fine for personal scripts, terrible for team tooling.

The modern best practice is to keep CLI tools as devDependencies and invoke them via npx or npm scripts:

json
{
  "devDependencies": {
    "typescript": "^5.4.0",
    "eslint": "^9.0.0"
  },
  "scripts": {
    "lint": "eslint .",
    "build": "tsc"
  }
}

Now every developer and every CI runner uses exactly the version pinned in package.json. Reproducibility is free.

When globals are still appropriate

  • Interactive REPL tools you want available everywhere (nodemon, http-server)
  • Package managers themselves (pnpm, yarn)
  • Tools that need to work before you have a project context
  • Your personal developer utilities not tied to any specific project

npx: The Package Executor

npx shipped with npm 5.2 in 2017 and fundamentally changed how we think about CLI tools. It separates execution from installation.

The resolution algorithm

When you run npx some-tool, npm resolves in this order:

  1. Local node_modules/.bin/ — if the package is installed as a project dependency, use it
  2. Global installs — if it's installed globally, use it
  3. npx cache (~/.npm/_npx/) — if it was previously downloaded by npx, use the cache
  4. Registry — download it, cache it, run it, then remove it from disk (but keep in cache)

Steps 1 and 2 are instant. Step 3 is fast (disk read). Step 4 is the one that shows the "Need to install?" prompt.

bash
npx create-cloudflare@latest
# → not in local deps, not global, not in cache
# → "Need to install the following packages: create-cloudflare@latest"
# → downloads, caches, runs, then the binary is removed from disk
# → cache entry stays: ~/.npm/_npx/{hash}/node_modules/

The cache

npx caches packages by a hash of the package name + version. On subsequent runs, it hits the cache — no download, no prompt. The cache persists until you run npm cache clean --force or the OS clears temp files.

bash
npm cache verify    # check cache integrity
npm cache clean --force  # nuclear option — clears everything

The --yes flag

Tired of the prompt in scripts?

bash
npx --yes create-cloudflare@latest
# or
npx -y create-cloudflare@latest

Useful in Dockerfiles and CI where stdin isn't interactive. Don't use blindly on developer machines — the prompt is a security feature.

Always pin versions with npx

bash
# Dangerous — grabs latest, may break tomorrow
npx create-react-app my-app

# Correct — pinned, reproducible
npx create-react-app@5.0.1 my-app

This is especially important for project scaffolding tools used in CI or shared scripts.


The Version Conflict Problem

Here's a real scenario that trips up even experienced engineers:

bash
# Your project has eslint@8 in devDependencies
npm install

# You also have eslint@9 installed globally
npm install -g eslint

# Now you run:
eslint .          # runs global v9 — ignores your project's v8
npx eslint .      # runs LOCAL v8 — correct behavior
npm run lint      # runs LOCAL v8 via PATH resolution — correct

npm scripts and npx both prioritize local node_modules/.bin/ over globals. The bare eslint command uses your shell's PATH, which puts the global binary first. This divergence causes subtle, maddening bugs.

Rule of thumb: Never invoke project tools with bare commands. Always use npm run or npx to ensure you're using the version your project declares.


npm exec vs npx

As of npm v7, npm exec is the "official" version of npx. They're functionally equivalent but with slightly different flags:

bash
npx some-tool arg1 arg2
npm exec -- some-tool arg1 arg2  # note the -- separator

npm exec is better documented, more predictable in edge cases, and is what the npm team recommends going forward. npx remains available as a convenience alias.


CI/CD Implications

Understanding these tools at a deeper level pays off in CI:

yaml
# Dockerfile — don't do this
RUN npm install -g typescript
RUN tsc --build

# Do this instead
COPY package*.json ./
RUN npm ci --ignore-scripts  # deterministic, no lifecycle scripts
RUN npx tsc --build          # uses local dep, no global install needed

npm ci over npm install in CI is non-negotiable: it's faster (skips dependency resolution), deterministic (reads lock file only), and fails loudly if the lock file is out of sync with package.json.


Quick Reference

CommandInstalls toPersists?Version-pinned?Use case
npm install pkg./node_modulesYesVia lock fileProject dependencies
npm install -g pkgGlobal node_modulesYesNoMachine-wide CLI tools
npx pkgCacheCache onlyOnly if you specify @versionOne-off execution
npm exec -- pkgCacheCache onlyOnly if you specify @versionSame as npx, more explicit
npm ci./node_modulesYesYes (from lock file)CI/CD installs

The Takeaway

  • Use npm install --save-dev for any tool your project uses — lock it, commit it, reproduce it
  • Treat global installs as personal convenience tools, not team infrastructure
  • Use npx or npm exec for one-off scaffolding and rarely-used CLIs
  • Always pin versions when using npx in scripts or CI
  • Use npm ci in pipelines, never npm install
  • Prefer npm run <script> over bare binary invocations to guarantee local resolution

Node's package tooling is mature and well-designed. The footguns mostly come from not understanding which resolution path you're on. Once you do, the behaviour becomes entirely predictable.