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:
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.
"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 updatedeliberately moves dependencies up to the newest version your ranges allow, and rewrites the lockfile to the new pins.
$ 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.
📝 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):
# 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:
$ 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
- The three managers share one model — install / add / remove / run / update — with different verbs (
installvsadd, etc.). - Semver is
MAJOR.MINOR.PATCH; the promise is that only a MAJOR bump may break you. - 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. installrespects the lockfile;updatedeliberately moves up within ranges and rewrites the lock. Take upgrades on purpose, then review the diff and test.- 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.
[
{
"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."
}
]
← Phase 1: The Manifest and the Lockfile · Guide overview · Phase 3: node_modules, the pnpm Store, and the Gotchas →
Check your understanding 3 questions
1. In package.json, what does "express": "^4.19.2" allow?
2. With a committed lockfile present, what does a plain install do versus update?
3. What problem do workspaces solve?