Your package.json says "react": "^18.3.1". You run npm install today and get 18.3.1. Your coworker clones the repo next month and gets 18.4.0. Your CI server builds on Friday and gets 18.3.2. Same source code, three different dependency trees. This is the problem lock files solve — and the problem package manager migrations can reintroduce if you're not careful.
This article breaks down how lock files work, why semantic versioning fails in practice, and how to migrate from npm to pnpm without losing the version guarantees your project depends on.
Table of Contents
- What Lock Files Do and Why You Need Them
- Semver: The Theory vs. Reality Gap
- Migration Risk Matrix
- Safe Migration Playbook
- Managing Lock Files in Git
1. What Lock Files Do and Why You Need Them
package.json Declares Ranges, Not Exact Versions
Open any frontend project's package.json and you'll see dependency declarations like this:
{
"dependencies": {
"react": "^18.3.1",
"axios": "~1.7.0",
"lodash": "4.17.21"
}
}
These three notations mean very different things:
| Syntax | Meaning | Actual Install Range |
|---|---|---|
"^18.3.1" |
Allow minor + patch upgrades |
>= 18.3.1 and < 19.0.0
|
"~1.7.0" |
Allow patch upgrades only |
>= 1.7.0 and < 1.8.0
|
"4.17.21" |
Exact version | Only 4.17.21
|
Most projects use ^ (caret). When you write "react": "^18.3.1", npm might install 18.3.1, 18.3.2, 18.4.0, or even 18.99.0 — depending on what's latest in the registry at the moment you run npm install.
Same package.json, Different Time, Different Result
Say you initialized your project in January and npm install resolved react@18.3.1. Three months later, a new teammate clones the repo and runs npm install. React has since published 18.3.2. They get 18.3.2.
For a well-maintained library like React, a patch bump is usually safe. But not every package is React — and that's where things break down.
Lock Files Turn Ranges into Snapshots
Lock files (package-lock.json for npm, pnpm-lock.yaml for pnpm, yarn.lock for Yarn) record the exact version of every dependency resolved during a particular install — including transitive dependencies you never declared directly.
A typical project might list 10 dependencies in package.json, but those 10 packages pull in 200+ transitive dependencies. The lock file pins all 200+ to exact versions.
With a lock file in place:
-
Your teammate clones the repo and runs
npm ci(notinstall) — they get the exact same versions you have - Your CI server builds with the exact same versions
- Production deploys won't break because "some package happened to publish a new patch while we were deploying"
package.json describes intent. The lock file records fact.
npm install vs. npm ci
This distinction trips up a lot of developers:
| Command | Behavior | Use Case |
|---|---|---|
npm install |
Resolves versions from package.json ranges, may update the lock file |
Local dev, adding new dependencies |
npm ci |
Installs strictly from the lock file; fails if the lock file and package.json are out of sync |
CI/CD, team collaboration, production deploys |
In CI environments, always use npm ci (or pnpm install --frozen-lockfile) — never npm install.
2. Semver: The Theory vs. Reality Gap
What the Spec Promises
Semantic Versioning is built on a simple contract:
MAJOR.MINOR.PATCH
- MAJOR: incompatible API changes
- MINOR: backward-compatible new features
- PATCH: backward-compatible bug fixes
Under this contract, pinning the major version with ^ should make minor and patch upgrades safe.
Reality disagrees.
Semver Violations in the Wild
Here are well-known cases where patch or minor versions introduced breaking changes:
Case 1: TypeScript
TypeScript explicitly does not follow semver. Its minor releases (e.g., 5.3 → 5.4) frequently include breaking changes to the type system. If your project relies heavily on type inference, a single minor bump can produce dozens of compilation errors. This is why many projects pin TypeScript with ~ or an exact version.
Case 2: esbuild
esbuild spent its formative years in 0.x territory (where semver says any change can be breaking), yet many bundler tools depend on it with ranges like ^0.21.0. When esbuild ships 0.22.0, bundling behavior can change silently.
Case 3: PostCSS Plugin Ecosystem
PostCSS minor upgrades have broken plugin compatibility, manifesting as incorrect CSS output — a particularly insidious bug because the build doesn't fail, the output is just wrong.
Case 4: The left-pad Incident (2016)
Not a semver violation per se, but a stark illustration of transitive dependency fragility: an 11-line package was unpublished from npm and broke thousands of projects, including Babel and React Native.
Why Lock Files Are Production's Last Line of Defense
After you develop and test locally, the lock file guarantees that:
- CI builds with the exact versions you tested
- Production deploys with the exact versions you tested
- Rolling back two months from now restores the exact versions you tested
Without a lock file, every single deploy is a dice roll on whether some transitive dependency's patch update changed behavior. This isn't a theoretical risk — in any project with a deep dependency tree, it's a near-certainty over time.
3. Migration Risk Matrix
Why Migrate
pnpm has become the dominant package manager in the frontend ecosystem. Compared to npm, its core advantages are:
-
Strict dependency isolation: npm's flat
node_moduleslets code import undeclared dependencies (phantom dependencies). pnpm's symlink structure eliminates this entirely. - Faster installs: A content-addressable store shares packages across projects.
- Less disk usage: Identical packages are stored only once.
But migrating means replacing your lock file — and that's where the risk lives.
Risk Assessment by Project Size
| Dimension | Small / Early-Stage Project | Large / Long-Running Project |
|---|---|---|
| Dependency count | < 50 (including transitive) | Hundreds or thousands |
| Version drift risk | Low — fewer deps, easy to spot issues | High — any transitive dep upgrade can introduce problems |
| Phantom dependencies | Unlikely — small codebase, simple dep graph | Likely — legacy code may implicitly rely on npm's flat structure |
| Verification cost | Low — one build + manual check | High — full test suite + staging validation required |
| Historical traceability needs | Low — young project, rare rollback scenarios | High — lock file history is critical for incident investigation |
| Delete lock and reinstall | Acceptable | Unacceptable |
Three Migration Strategies
Strategy A: Delete the old lock file, run pnpm install
rm -rf node_modules package-lock.json
pnpm install
- All dependencies re-resolved to latest versions within
package.jsonranges - Transitive dependency versions are completely uncontrolled
- Lock file git history is severed
Best for: new projects with few dependencies and high fault tolerance.
Strategy B: Lossless import with pnpm import
pnpm import # Import exact versions from package-lock.json
rm package-lock.json # Remove old lock file after successful import
pnpm install # Install dependencies
- All dependency versions match the original lock file exactly
- Transitive dependencies are precisely preserved
- Zero version drift
Best for: any project, and the strongly recommended default for large projects.
Strategy C: Gradual migration
Run Strategy B on a branch, put it through a full test cycle, then merge to main.
git checkout -b chore/migrate-to-pnpm
pnpm import
rm package-lock.json
pnpm install
# Run all tests
pnpm test
pnpm build
pnpm e2e
# Deploy to staging and verify
# Merge to main once confirmed
Best for: production services with high-availability requirements.
Decision Flowchart
4. Safe Migration Playbook
4.1 pnpm import: The Key Command
pnpm import is the officially recommended way to migrate to pnpm from another package manager. It reads these lock file formats:
-
package-lock.json(npm) -
yarn.lock(Yarn Classic / Yarn Berry) -
npm-shrinkwrap.json(legacy npm format)
Usage:
# Verify the old lock file exists
ls package-lock.json # or yarn.lock
# Import
pnpm import
# Confirm pnpm-lock.yaml was generated
ls pnpm-lock.yaml
# Remove the old lock file
rm package-lock.json
# Install dependencies
pnpm install
Verify version consistency after import:
# Check installed dependency tree
pnpm list --depth=0
# Verify critical dependency versions
pnpm list react react-dom vite
4.2 Handling Phantom Dependencies
After migrating from npm to pnpm, the most common errors aren't version mismatches — they're phantom dependencies.
What's a phantom dependency?
npm's flat node_modules structure hoists all packages (including transitive dependencies) to the node_modules/ root. This means your code can import any installed package, even if it's not declared in your package.json.
// "ms" is NOT in package.json
// But "debug" depends on "ms", and npm hoists it to node_modules/
// So this works under npm but breaks under pnpm
import ms from 'ms' // npm: works | pnpm: Module not found
pnpm's symlink structure prevents this — you can only import packages explicitly declared in package.json.
How to fix:
Explicitly add any undeclared packages you're actually using:
# Find all failing imports
pnpm build 2>&1 | grep "Module not found"
# Add each one as an explicit dependency
pnpm add ms
In large projects, you might need to fix dozens of these. It's a one-time cost that improves your project's long-term health — explicit dependency declarations make the dependency graph clearer and more maintainable.
4.3 Declare the packageManager Field
After migration, declare your package manager and version in package.json:
{
"packageManager": "pnpm@10.29.2"
}
This field serves three purposes:
- Corepack (built into Node.js) automatically uses the correct pnpm version based on this field
-
GitHub Actions'
pnpm/action-setup@v4reads it to determine which version to install - Team members who accidentally run npm instead of pnpm get a warning from Corepack
4.4 Update CI/CD Pipelines
All CI workflows need to be updated after migration. Here's a GitHub Actions example:
Before (npm):
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
After (pnpm):
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 # Installs pnpm
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm' # Switch to pnpm cache
- run: pnpm install --frozen-lockfile # Equivalent to npm ci
- run: pnpm build
Key changes:
- Add
pnpm/action-setup@v4— reads thepackageManagerfield and installs the matching version - Change
cachefrom'npm'to'pnpm' - Replace
npm ciwithpnpm install --frozen-lockfile(same behavior: strict install from lock file) - Remove
cache-dependency-path: package-lock.json(no longer needed)
5. Managing Lock Files in Git
Always Commit the Lock File
This cannot be overstated: lock files must be committed to your Git repository.
Not committing the lock file means:
- Every team member might install different dependency versions
- CI builds aren't reproducible
- You can't precisely roll back to a known-good state
A common mistake is adding the lock file to .gitignore — never do this.
Lock Files as a Debugging Tool
When a mysterious production bug appears, the lock file's git history is one of your most powerful debugging tools.
Scenario: Users report that page styles broke after a certain date, but the code diff shows no obvious CSS changes.
Investigation:
# Use git bisect to find the offending commit
git bisect start
git bisect bad HEAD
git bisect good v1.2.0
# During bisect, inspect dependency changes per commit
git diff HEAD~1 -- pnpm-lock.yaml | head -100
# Discovery: postcss was bumped from 8.4.31 to 8.4.32
# That patch version changed how a certain CSS rule was processed
If you'd deleted and regenerated the lock file, this trail goes cold — you can no longer trace when and which install introduced the problematic version.
Resolving Lock File Merge Conflicts
Lock file merge conflicts are a frequent occurrence in team development. Never attempt to resolve them manually — lock files aren't meant for human editing.
The correct approach:
# When you hit a lock file conflict:
# 1. Accept one side (usually the target branch)
git checkout --theirs pnpm-lock.yaml
# 2. Regenerate the lock file
pnpm install
# 3. Commit
git add pnpm-lock.yaml
git commit
For npm:
git checkout --theirs package-lock.json
npm install
git add package-lock.json
git commit
pnpm install (without --frozen-lockfile) re-resolves the lock file based on package.json declarations while preserving existing version pins as much as possible. This is far safer than manually merging JSON or YAML.
.gitignore Configuration
# Dependencies — never commit
node_modules/
# Lock files — MUST be committed, do NOT add them here!
# Do NOT ignore package-lock.json
# Do NOT ignore pnpm-lock.yaml
# Do NOT ignore yarn.lock
If your project exclusively uses pnpm and you want to prevent accidental commits of other lock files:
# Using pnpm only — exclude other lock files
package-lock.json
yarn.lock
Appendix: Quick Reference
Command Cheat Sheet
| Operation | npm | pnpm |
|---|---|---|
| Install all dependencies | npm install |
pnpm install |
| Install from lock file (strict) | npm ci |
pnpm install --frozen-lockfile |
| Add a dependency | npm install axios |
pnpm add axios |
| Add a dev dependency | npm install -D vitest |
pnpm add -D vitest |
| Remove a dependency | npm uninstall axios |
pnpm remove axios |
| Run a script | npm run build |
pnpm build |
| Execute a local binary | npx vitest |
pnpm vitest or pnpm exec vitest
|
| Import from npm lock file | — | pnpm import |
Migration Checklist
- ☐ Confirm the project has adequate test coverage (or at minimum, a passing build)
- ☐ Run
pnpm importto import from the existing lock file - ☐ Delete the old lock file (
package-lock.json/yarn.lock) - ☐ Run
pnpm installto install dependencies - ☐ Fix any phantom dependency errors
- ☐ Add
"packageManager": "pnpm@x.x.x"topackage.json - ☐ Update all CI workflows (GitHub Actions / GitLab CI / etc.)
- ☐ Update installation instructions in the README
- ☐ Update
.gitignore(optional: exclude other package managers' lock files) - ☐ Run the full test suite + build verification
- ☐ Notify team members to install pnpm and update their local environments
Written in March 2026. Tool versions referenced: pnpm 10.x, npm 11.x.

Top comments (0)