# npm, pnpm, and Yarn

> Node package managers explained: package.json, the lockfile that pins your real dependency tree, semver ranges, and why pnpm's content-addressed store is so fast.


---

# npm, pnpm, and Yarn

You ran `npm install`, watched a thousand packages scroll by, and ended up with a `node_modules` folder the size of a small planet and a `package-lock.json` you've never opened. Then a teammate's machine builds something subtly different from yours, a "patch" upgrade breaks the app overnight, and someone suggests switching to pnpm "because it's faster" - without anyone explaining *why*.

This guide untangles all of it. By the end you'll know exactly what `package.json` declares versus what the lockfile *pins*, why a caret can hand you a surprise upgrade, what makes pnpm's store fast and strict at the same time, and how to pick the right tool without cargo-culting. Same mental model underneath all three managers - once you see it, the differences stop being mysterious and become a short list of trade-offs.

## How to read this

- **Want the whole thing to click?** Read in order. The first phase installs the one distinction - *declaration* versus *lockfile* - that the other two lean on.
- **Already burned by a surprise upgrade or a bloated `node_modules`?** Phase 3 is the gotchas-and-production phase: semver traps, `install` vs `ci`, and why pnpm's layout catches bugs npm silently allows.

## The phases

1. **[The Manifest and the Lockfile](01-manifest-and-lockfile.md)** - `package.json` is your *wish list*; the lockfile is the *receipt* that pins the exact tree you actually got. Why both exist, and why the lockfile is the truth.
2. **[Installing, Updating, and Workspaces](02-installing-and-workspaces.md)** - the everyday commands across all three managers, how semver ranges decide what an update does, and how one repo can hold many packages with workspaces.
3. **[node_modules, the pnpm Store, and the Gotchas](03-store-and-gotchas.md)** - why `node_modules` got so big, how pnpm's content-addressed store makes installs fast and disk-cheap, the strictness that catches phantom dependencies, and the traps that bite everyone.


---

# The Manifest and the Lockfile

Here's the reality you've already lived: you cloned a repo, ran an install, and it worked. Your colleague did the same a week later and got a slightly different result. Neither of you changed `package.json`. That confusion almost always comes from one missing distinction - the difference between what you *asked for* and what you *actually got*. Two files hold those two facts, and they are not the same file.

## Two files, two jobs

Every Node project has a `package.json`. Many also have a lockfile sitting next to it - `package-lock.json` for npm, `pnpm-lock.yaml` for pnpm, `yarn.lock` for Yarn. They look related, and they are, but they answer different questions.

- **`package.json` is your declaration - a wish list.** It says, in human terms, "this project wants Express, somewhere in the version 4 family." It's short, you write it by hand (or via commands), and it describes *intent* using ranges, not exact versions.
- **The lockfile is the receipt - the truth.** It records the *exact* version of every package that got installed, including the packages your packages depend on, all the way down. You don't write it; the package manager generates it. It describes *reality*.

Hold onto this: **`package.json` says what you want; the lockfile says what you have.** When they disagree about what's possible, the lockfile wins on the next install - that's the whole point of it existing.

## What package.json actually contains

Here's a trimmed, realistic one:

```json
{
  "name": "my-app",
  "version": "1.4.0",
  "scripts": {
    "dev": "vite",
    "test": "vitest run"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "vitest": "^1.6.0"
  }
}
```

*What just happened:* this file declares two kinds of dependencies. `dependencies` are needed to *run* the app (Express serves requests). `devDependencies` are needed only to *build and test* it (Vitest never ships to production). The `^4.19.2` next to Express is a **range**, not an exact pin - we unpack what that caret means in [Phase 2](02-installing-and-workspaces.md). The `scripts` block names shortcuts you run with `npm run dev`, `npm test`, and so on.

📝 **Terminology.** A *direct dependency* is one you listed yourself in `package.json`. A *transitive dependency* is one your dependencies pull in. You asked for Express; Express asks for a dozen other packages; those ask for more. `package.json` only lists the handful you chose. Everything else is transitive - and that's where the lockfile earns its keep.

## Why the lockfile has to exist

If `package.json` only stores ranges, then "install the dependencies" isn't a precise instruction. `^4.19.2` means "4.19.2 or any newer 4.x." Run that install today and you get 4.19.2; run it next month after Express ships 4.20.0 and you get 4.20.0 - *with no change to `package.json`*. Multiply that across hundreds of transitive packages and "the same project" quietly becomes a different tree on every machine and every day.

The lockfile freezes that. It writes down the one exact version that was resolved for *every* package in the tree:

```text
package.json says:   express ^4.19.2     (a range - "4.19.2 or newer 4.x")
lockfile says:       express 4.19.2      (exact)
                     body-parser 1.20.2  (exact - transitive, you never asked for this)
                     cookie 0.6.0        (exact - transitive)
                     ...the entire tree, pinned...
```

*What just happened:* the lockfile turned a fuzzy wish ("somewhere in 4.x") into a precise, repeatable fact (these exact versions, this exact tree). With the lockfile committed, anyone who installs gets *byte-for-byte the same dependencies you did* - not "compatible," identical.

This is the difference between "works on my machine" and "works on every machine." The lockfile is the thing that makes a Node install *reproducible*.

```mermaid
flowchart TD
  A["package.json - ranges, your intent"] --> R["resolver picks exact versions"]
  R --> L["lockfile - exact tree, the truth"]
  L --> N["node_modules - installed bytes"]
  A2["teammate clones repo"] --> L
  L --> N2["identical node_modules"]
```

*The resolver turns ranges into a pinned tree once; everyone after that installs from the lock, not the ranges.*

## So: do you commit the lockfile?

Yes - almost always. **Commit the lockfile for applications.** It's the only way teammates, CI, and your production build all get the same dependency tree. A lockfile that isn't in version control is a lockfile that isn't doing its job.

⚠️ **Gotcha.** The exception is *libraries* you publish to a registry for others to install. A published library's own lockfile isn't used by the projects that depend on it - they resolve their own tree - so it's conventional not to ship one. But the line that trips people up: even library authors usually *keep* a lockfile in the repo for reproducible local development; they only don't depend on it being honored downstream. If you're building an app, a service, or anything you deploy, the rule is plain: commit it.

⚠️ **Gotcha.** Don't hand-edit the lockfile. It's machine-generated and internally consistent; editing it by hand is how you get a tree that the manager later "corrects" out from under you. To change versions, change `package.json` (or use an install/update command) and let the manager rewrite the lock.

## For builders

When a bug appears only in CI or only in production and "it's fine locally," your first suspect should be the dependency tree, and your first question is: *did everyone install from the same lockfile?* A common cause is CI running a plain install that's allowed to drift from the lock, while you ran an install months ago and never updated. The fix is a strict, lock-respecting install in CI - the exact command for that is in [Phase 3](03-store-and-gotchas.md).

## Recap

1. **`package.json` is the manifest** - your declaration of intent, written with version *ranges*, listing only your *direct* dependencies.
2. **The lockfile is the receipt** - machine-generated, pinning the *exact* version of every package including all *transitive* ones.
3. Ranges make installs non-deterministic over time; the lockfile makes them **reproducible** - identical trees everywhere.
4. **Commit the lockfile for any app or service.** It's the bridge from "works on my machine" to "works on every machine."
5. Never hand-edit it; change `package.json` and let the manager regenerate the lock.

Next, the commands you run every day - and the small print of semver ranges that decides whether an update is a yawn or a 2am page.

```quiz
[
  {
    "q": "What does package.json store that the lockfile does not?",
    "choices": [
      "The exact resolved version of every transitive dependency",
      "Version ranges and your list of direct dependencies",
      "A hash of every installed file",
      "Nothing - they store the same thing"
    ],
    "answer": 1,
    "explain": "package.json holds your intent: ranges for the direct dependencies you chose. The lockfile holds reality: exact pinned versions for the entire tree, transitive packages included."
  },
  {
    "q": "Why can two installs from the same package.json produce different dependency trees?",
    "choices": [
      "package.json is encrypted differently each time",
      "node_modules is randomized for security",
      "Ranges resolve to whatever matching versions are newest at install time",
      "npm installs in a random order"
    ],
    "answer": 2,
    "explain": "A range like ^4.19.2 means '4.19.2 or any newer 4.x'. Run it before and after a new release and you get different versions - unless a committed lockfile pins the exact tree."
  },
  {
    "q": "For an application you deploy, should the lockfile be committed to version control?",
    "choices": [
      "No, it's machine-generated noise",
      "Only on the production branch",
      "Yes - it's what makes installs reproducible across machines and CI",
      "Only if you use pnpm"
    ],
    "answer": 2,
    "explain": "Committing the lockfile is the only way teammates, CI, and production all install the identical tree. Skipping it reintroduces the 'works on my machine' problem the lockfile exists to solve."
  }
]
```


---

# Installing, Updating, and Workspaces

You'll spend most of your package-manager life in about six commands. The good news: npm, pnpm, and Yarn share the same shape, so learning one mostly teaches you all three. The part that actually trips people isn't the commands - it's the tiny version symbols (`^`, `~`) that quietly decide whether tomorrow's install upgrades half your tree. We'll get the commands out of the way fast, then slow down on the part that bites.

## The everyday commands, side by side

Three managers, the same handful of jobs. Here's the translation table you can keep on a sticky note:

```text
job                         npm                      pnpm                  yarn
--------------------------  -----------------------  --------------------  --------------------
install everything (lock)   npm install              pnpm install          yarn install
add a runtime dependency    npm install express      pnpm add express      yarn add express
add a dev-only dependency   npm install -D vitest    pnpm add -D vitest    yarn add -D vitest
remove a dependency         npm uninstall express    pnpm remove express   yarn remove express
run a script                npm run dev              pnpm dev              yarn dev
update within ranges        npm update               pnpm update           yarn upgrade
```

*What just happened:* the verbs differ (`install` vs `add`, `uninstall` vs `remove`) but the model is identical. Adding a dependency does three things at once: downloads it, writes it into `package.json`, and updates the lockfile. The `-D` flag (short for `--save-dev`) sends it to `devDependencies` instead of `dependencies` - use it for anything that doesn't ship to production: test runners, bundlers, linters, type definitions.

📝 **Terminology.** `npm install` with no package name means "install the whole tree from the manifest/lock." `npm install <name>` means "add this one package." Same command, two jobs, decided by whether you name a package. pnpm and Yarn split these more clearly with `install` versus `add`.

## Semver: the version numbers have grammar

Every version is three numbers: `MAJOR.MINOR.PATCH`, like `4.19.2`. This is **semantic versioning** - semver - and the promise behind it is what makes ranges safe-ish:

- **PATCH** (`4.19.2` → `4.19.3`): bug fixes only. Nothing you use should change behavior.
- **MINOR** (`4.19.2` → `4.20.0`): new features added, but old code keeps working (backward-compatible).
- **MAJOR** (`4.19.2` → `5.0.0`): breaking changes. Your code might need edits.

The promise: a properly versioned package only breaks you on a MAJOR bump. The range symbols in `package.json` are you telling the resolver *how far you trust that promise.*

```text
"express": "4.19.2"     exact      → only 4.19.2, nothing else
"express": "~4.19.2"    tilde      → 4.19.x  (patch updates only: 4.19.2, 4.19.3, ...)
"express": "^4.19.2"    caret      → 4.x.x   (minor + patch: up to but not 5.0.0)
"express": "*"          wildcard   → anything (don't)
```

*What just happened:* the caret `^` - the default when you run `npm install express` - allows minor and patch upgrades but stops before the next major. The tilde `~` is stricter: patch only. Both bet on semver being honored. The caret bets *more*, because it lets in new minor versions you've never tested.

⚠️ **Gotcha - the surprise upgrade.** This is the classic 2am story. Your `package.json` says `^4.19.2`. It's worked for months. A teammate (or CI on a fresh machine with no lockfile, or you after deleting the lock) runs an install, and because the caret allows it, they pull in `4.25.0` that shipped last week - which has a regression. Nothing in *your* code or `package.json` changed. The lockfile is exactly what prevents this: with the lock committed and respected, the range is only consulted *once*, when a version is first resolved. After that, everyone installs the pinned version. **The caret is your ceiling; the lockfile is your floor.**

## install vs update - they are not the same

This catches everyone, so be precise:

- **`npm install`** (with a lockfile present) installs *exactly what the lockfile says*. It does **not** go looking for newer versions. It respects the pin.
- **`npm update`** deliberately moves dependencies *up to the newest version your ranges allow*, and **rewrites the lockfile** to the new pins.

```console
$ npm update
changed 6 packages in 1s

$ git diff package-lock.json
-      "version": "4.19.2",
+      "version": "4.20.1",
```

*What just happened:* `npm update` walked your ranges, found newer versions within them (a caret let `4.19.2` move to `4.20.1`), installed them, and edited the lockfile. This is the *intended* way to take upgrades: run it on purpose, review the lockfile diff, run your tests, commit. The danger is never `update` itself - it's an *accidental* upgrade from a missing or ignored lockfile, which we close off for good in [Phase 3](03-store-and-gotchas.md).

📝 **Terminology.** To cross a *major* version (`4.x` → `5.x`) you can't use `update` - the caret won't allow it. You change `package.json` yourself (or run `npm install express@5`), then read the package's migration notes, because a major bump means something will break on purpose.

## Workspaces: many packages, one repo

Real projects rarely stay a single package. You end up with a web app, a shared UI library, and a backend that all live together and depend on each other. **Workspaces** let one repository hold multiple packages and wire them up locally - the foundation of a *monorepo*.

You declare the member packages in the root `package.json` (npm and Yarn) or in a `pnpm-workspace.yaml` (pnpm):

```json
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}
```

```yaml
# pnpm-workspace.yaml (pnpm uses this instead)
packages:
  - "packages/*"
  - "apps/*"
```

*What just happened:* the root is marked `"private": true` (a workspace root is never published) and points at folders of packages. Now one install at the root resolves *all* of them at once, and when `apps/web` depends on `packages/ui`, the manager links the local `ui` directly instead of downloading a published copy. Edit `ui`, and `web` sees the change immediately - no publish step.

Running a script in a specific member:

```console
$ pnpm --filter web dev          # pnpm: run "dev" in the web package
$ npm run test --workspace=ui    # npm: run "test" in the ui package
$ yarn workspace ui test         # yarn: same, yarn syntax
```

*What just happened:* each manager has its own flag for "do this in that member" - pnpm's `--filter`, npm's `--workspace`, Yarn's `workspace` subcommand. Same idea, three spellings. This is how you build or test one app in a big repo without touching the others.

⚠️ **Gotcha.** A workspace links local packages by their declared version range too. If `apps/web` depends on `"ui": "^1.0.0"` but your local `ui` is at `2.1.0`, the range won't match and the manager may try to fetch `ui` from the registry (and fail, if it was never published). For internal-only packages, point workspace dependents at the local version explicitly - pnpm offers `"ui": "workspace:*"` to mean "always the local one, whatever its version."

## In the wild

Big JavaScript codebases lean hard on workspaces. A typical setup keeps shared code (design system, API client, config) in `packages/` and deployable things in `apps/`, with one lockfile at the root governing the entire tree. It's the same reason monorepos exist anywhere: one install, one consistent dependency set, atomic changes across packages. The package manager is doing the unglamorous wiring that makes it hold together.

## Recap

1. The three managers share one model - **install / add / remove / run / update** - with different verbs (`install` vs `add`, etc.).
2. **Semver is `MAJOR.MINOR.PATCH`**; the promise is that only a MAJOR bump may break you.
3. **Caret `^` allows minor + patch; tilde `~` allows patch only.** The caret is the source of the classic *surprise upgrade* - and the committed lockfile is what neutralizes it.
4. **`install` respects the lockfile; `update` deliberately moves up within ranges and rewrites the lock.** Take upgrades on purpose, then review the diff and test.
5. **Workspaces** put many packages in one repo and link them locally - the backbone of a monorepo.

Next, the part everyone has *felt* but few have had explained: why `node_modules` ballooned, how pnpm makes installs fast and strict at once, and the traps that catch every team.

```quiz
[
  {
    "q": "In package.json, what does \"express\": \"^4.19.2\" allow?",
    "choices": [
      "Only the exact version 4.19.2",
      "Patch updates only, up to 4.19.x",
      "Minor and patch updates, up to but not including 5.0.0",
      "Any version including 5.x and beyond"
    ],
    "answer": 2,
    "explain": "The caret allows minor and patch upgrades but stops before the next major. So 4.20.0 and 4.25.3 are fine, but 5.0.0 is not - that would be a breaking change."
  },
  {
    "q": "With a committed lockfile present, what does a plain install do versus update?",
    "choices": [
      "Both install the newest versions in range",
      "Install respects the locked versions; update moves up within ranges and rewrites the lock",
      "Install rewrites the lock; update only reads it",
      "They are aliases for the same operation"
    ],
    "answer": 1,
    "explain": "Install honors the pins in the lockfile and does not seek newer versions. Update intentionally pulls the newest versions your ranges allow and updates the lockfile to match."
  },
  {
    "q": "What problem do workspaces solve?",
    "choices": [
      "They encrypt node_modules",
      "They let one repo hold multiple packages and link interdependent ones locally",
      "They replace the lockfile",
      "They convert npm projects to pnpm automatically"
    ],
    "answer": 1,
    "explain": "Workspaces are the monorepo foundation: many packages in one repo, one install resolving all of them, and local packages linked directly so edits are seen immediately without publishing."
  }
]
```


---

# node_modules, the pnpm Store, and the Gotchas

You've seen the joke about `node_modules` being the heaviest object in the universe. It's funny because everyone has watched the folder swallow gigabytes. This phase explains *why* that happens, what pnpm does differently to fix it, and - more useful than any benchmark - the strictness that catches a class of bug npm and Yarn quietly let through. Then the short list of traps worth knowing before they cost you an afternoon.

## Why node_modules got so big

A dependency tree is deep. Express depends on a dozen packages; each of those depends on more. Worse, two of your dependencies might each want a *different version* of the same small utility. Something has to store all of it.

npm and Yarn (in its classic mode) build a **flat-ish `node_modules`**: they *hoist* as many packages as possible to the top level so they can be shared, and tuck conflicting versions deeper where they're needed. It works, but it has two costs. First, every project gets its **own full copy** of every package on disk - ten projects using React means ten copies of React. Second, hoisting has a side effect we'll come back to: packages you never declared end up sitting at the top of `node_modules`, reachable by your code by accident.

```text
node_modules/              ← npm/yarn: a copy lives here, per project
  express/
  body-parser/             ← hoisted to the top (a transitive dep of express)
  cookie/
  ...hundreds more, all real files, all duplicated across projects
```

*What just happened:* every package is a real, full copy of files inside this project's `node_modules`. Multiply by every project on your machine and the disk usage - and the duplicated download work - adds up fast.

## How pnpm changes the shape

pnpm attacks both costs with one idea: **store every package exactly once on your whole machine, then link it into projects.**

It keeps a single **content-addressed store** (typically under your home directory). "Content-addressed" means each file is stored under a key derived from its contents - so identical files are stored once, period, no matter how many packages or projects use them. When you install, pnpm doesn't copy files into `node_modules`; it creates **hard links** from the global store into the project. The project's `node_modules` becomes a set of links pointing at the one shared copy.

```mermaid
flowchart LR
  S["global content-addressed store (one copy of each file)"]
  P1["project A node_modules - links"]
  P2["project B node_modules - links"]
  S --> P1
  S --> P2
```

*Ten projects share one physical copy of React; each project's node_modules only links to it.*

Two consequences fall out of this:

- **Disk and speed.** A package you already have anywhere on your machine isn't downloaded or copied again - it's linked. Second and third projects that need React reuse the stored copy, so installs are fast and the disk cost is paid once, not per project.
- **A symlinked layout that is *strict*.** This is the part that matters more than speed.

📝 **Terminology.** *Content-addressed store* - a place where each file is keyed by a hash of its bytes, so duplicates collapse to one. *Hard link* - two directory entries pointing at the exact same data on disk; not a copy. This is the same family of trick Git uses internally to avoid storing identical content twice.

## The strictness: phantom dependencies

Here's the bug npm's flat layout hides. Because hoisting puts transitive packages at the *top* of `node_modules`, your code can `require('cookie')` - a package you never put in `package.json` - and it *works*, purely because Express happened to pull `cookie` up to where your code can see it. That's a **phantom dependency**: you depend on something you never declared.

It's a time bomb. The day Express stops depending on `cookie`, or a new install hoists it somewhere else, your import breaks - and nothing in *your* code or `package.json` changed to explain it.

```console
# Under npm's flat node_modules, this WRONGLY works:
$ node -e "require('cookie')"      # 'cookie' was never in package.json
# (no error - it's hoisted to the top level)

# Under pnpm, the same code fails fast:
$ node -e "require('cookie')"
Error: Cannot find module 'cookie'
```

*What just happened:* pnpm's layout only links packages you *actually declared* into the top level of `node_modules`; everything transitive lives in a nested, non-hoisted structure your code can't reach by accident. So the phantom import that npm silently allowed, pnpm rejects immediately. That early failure is a feature - it surfaces an undeclared dependency now, on your machine, instead of mysteriously in production later.

⚠️ **Gotcha.** This same strictness is why a project that "worked fine on npm" sometimes throws "cannot find module" the moment you switch to pnpm. That is pnpm telling the truth: the code was relying on a phantom dependency all along. The fix is to add the missing package to `package.json` where it belonged - not to fight the tool.

## The one CI command everyone should use

Phase 1 promised reproducible installs; here's how you actually enforce them. A plain `npm install` is allowed to *modify* the lockfile if it thinks the manifest and lock have drifted - convenient locally, dangerous in CI, where you want the build to use exactly what was committed and to *fail loudly* if it can't.

```console
$ npm ci
npm warn ... (nothing to warn about here)
added 412 packages in 4s
```

*What just happened:* `npm ci` ("clean install") deletes `node_modules`, then installs **strictly from the lockfile** - no version re-resolution. If `package.json` and the lockfile disagree, it *errors out* instead of quietly fixing things. That's exactly the behavior you want in automated builds: identical, frozen, and reliable. The equivalents are `pnpm install --frozen-lockfile` and `yarn install --immutable`.

⚠️ **Gotcha.** This is the root-cause fix for the "works locally, breaks in CI" dependency mystery from Phase 1. If CI runs a lock-respecting frozen install and *still* differs from your machine, the difference is real and committed - not random - and now you can actually find it. Make every CI pipeline use the frozen variant; it's a one-line change that ends a whole genre of bug.

## Choosing between the three

There's no universal winner, but the trade-offs are short and stable:

```text
manager   strengths                                  watch out for
--------  -----------------------------------------  --------------------------------------
npm       ships with Node, universal, zero setup     flat layout hides phantom deps; per-
                                                     project disk copies
pnpm      fast, disk-cheap (shared store), strict     strictness can expose latent bugs on
          (catches phantom deps), great workspaces    migration; symlinks confuse a few old tools
yarn      mature workspaces, large ecosystem;         classic vs modern (PnP) modes differ a
          fast modern versions                        lot - know which one a repo uses
```

*What just happened:* the plain summary is - npm is the safe default that's always there; pnpm wins on speed, disk, and correctness for anything non-trivial or monorepo-shaped; Yarn is a strong choice especially where a team already standardized on it. **What matters more than the choice: pick one per repo and commit its lockfile.** Mixing managers in one project produces conflicting lockfiles and exactly the non-reproducibility this whole guide is about avoiding.

⚠️ **Gotcha.** Commit *one* lockfile per repo. Seeing `package-lock.json`, `pnpm-lock.yaml`, *and* `yarn.lock` side by side is a red flag - it means different people used different tools, and none of the locks can be trusted. Delete the strays, agree on a manager, regenerate the one true lock.

## In the wild

When a large team migrates to pnpm, the first day is often noisy: a handful of builds fail with "cannot find module" for packages everything seemed to use. That's not pnpm being broken - it's pnpm cashing in years of accumulated phantom dependencies all at once. Teams that understand the mental model fix it in an afternoon by declaring what was always implicitly used. Teams that don't, blame the tool and revert. The difference is exactly the understanding this guide set out to give you.

## Recap

1. **npm/Yarn build a flat, hoisted `node_modules`** with a full per-project copy of every package - which is why the folder gets huge and why **phantom dependencies** sneak in.
2. **pnpm uses one content-addressed store** and links packages into projects, so each file lives on disk once: fast installs, low disk, **strict** layout.
3. pnpm's strictness **catches phantom dependencies early** by refusing to expose anything you didn't declare - a feature, even when it breaks a migrating project.
4. **Use the frozen install in CI** - `npm ci`, `pnpm install --frozen-lockfile`, or `yarn install --immutable` - so builds honor the lockfile exactly and fail loudly on drift.
5. **Pick one manager per repo and commit its single lockfile.** That, more than which tool you chose, is what keeps installs reproducible.

That's the whole model: a manifest of intent, a lockfile of truth, ranges that need a leash, and a store that decides how it all lands on disk. With those four ideas, no package-manager surprise stays surprising for long. For where these installed builds go next, see [Build & Release Basics](/guides/build-and-release-basics).

```quiz
[
  {
    "q": "What is a phantom dependency?",
    "choices": [
      "A package listed in the lockfile but not installed",
      "A package your code uses that you never declared in package.json, reachable only by accident of hoisting",
      "A dev dependency that ships to production",
      "A circular dependency between two packages"
    ],
    "answer": 1,
    "explain": "Flat, hoisted node_modules lifts transitive packages to the top level, so your code can import something it never declared. It works until the tree shifts - then it breaks for no apparent reason."
  },
  {
    "q": "How does pnpm's content-addressed store save disk space and time?",
    "choices": [
      "It compresses node_modules into a single archive",
      "It deletes unused packages nightly",
      "It stores each file once globally and links it into each project instead of copying",
      "It downloads packages on demand at runtime"
    ],
    "answer": 2,
    "explain": "Each file is stored once in a global store keyed by its contents, then hard-linked into projects. Ten projects needing React share one physical copy, so installs are fast and disk is paid once."
  },
  {
    "q": "Why prefer npm ci (or the frozen-lockfile equivalent) over plain install in CI?",
    "choices": [
      "It installs faster by skipping the lockfile",
      "It upgrades dependencies to the latest versions automatically",
      "It installs strictly from the lockfile and errors if the manifest and lock disagree",
      "It works without a package.json"
    ],
    "answer": 2,
    "explain": "ci installs exactly what the lockfile pins and fails loudly on drift instead of quietly rewriting the lock. That gives CI identical, reproducible, reliable builds."
  }
]
```
