package.json vs package-lock.json: The Dependency Battle Behind Every Node.js App
There is a small moment almost every Node.js developer experiences.
You clone a project:
git clone https://github.com/example/my-app.git
cd my-app
npm install
npm startAnd everything works.
You come back three months later, run:
npm installAnd suddenly…
💥 Something breaks.
A dependency changed.
A transitive dependency changed.
A bug appeared.
A security patch changed behavior.
Or, even worse, it works on your laptop but fails in CI/CD.
Then you look at the repository and see:
package.json
package-lock.jsonBoth seem to contain dependencies.
So the obvious question is:
Why does Node.js need two files to describe dependencies?
The answer is much more interesting than it first appears.
📦 package.json is what you WANT
Think of package.json as the contract for your application.
It tells npm:
“These are the packages my application needs, and these are the kinds of versions I’m willing to accept.”
For example:
{
"dependencies": {
"express": "^5.1.0",
"axios": "^1.12.0"
}
}The important part is:
^5.1.0This does not necessarily mean:
Install exactly Express 5.1.0.
It means roughly:
Give me a compatible version within the allowed semver range.
So if a newer compatible version exists, npm may install it.
That’s useful for developers because you don’t want to manually update every dependency every time a compatible patch release appears.
🔒 package-lock.json is what you ACTUALLY GOT
Now comes the interesting part.
Suppose your package.json says:
"express": "^5.1.0"At the time you ran:
npm installnpm might resolve that to:
express 5.1.0But Express itself depends on other packages.
Those packages have dependencies.
Those dependencies have dependencies.
You end up with a dependency tree:
your-app
│
├── express
│ ├── dependency-A
│ │ ├── dependency-X
│ │ └── dependency-Y
│ └── dependency-B
│ └── dependency-Z
│
└── axios
└── dependency-Cpackage-lock.json records this resolved dependency tree.
It contains information such as:
- exact package versions
- resolved package locations
- integrity hashes
- dependency relationships
- nested dependencies
So you can think of it as:
package.json = dependency requirements
package-lock.json = exact dependency resolution
🎯 Here’s the easiest analogy
Imagine you’re ordering food.
package.json
You say:
“I want pizza.”
That’s your requirement.
package-lock.json
The restaurant records:
“Large chicken pizza, thin crust, extra cheese, delivered to this address.”
That’s the exact result.
Next time you order using the record, you get essentially the same thing.
Without the lock file, npm has more freedom to resolve the request again.
😈 The hidden problem: dependencies have dependencies
This is where things become really important for DevOps.
Imagine your package.json contains:
{
"dependencies": {
"my-framework": "^2.0.0"
}
}You install it today.
You get:
my-framework 2.0.3But internally:
my-framework 2.0.3
└── helper-library 4.1.2Three months later, my-framework releases:
2.4.0and its dependency changes:
helper-library 4.5.0Now someone else clones your project.
If npm resolves dependencies from scratch, they could potentially get:
my-framework 2.4.0
└── helper-library 4.5.0while your original environment had:
my-framework 2.0.3
└── helper-library 4.1.2Same:
package.jsonDifferent:
node_modules/And that’s a nightmare for reproducible deployments.
🔐 This is why package-lock.json matters
With a lock file committed to Git:
Git repository
│
├── package.json
└── package-lock.jsonyour CI/CD pipeline can reproduce the dependency tree much more reliably.
For example:
npm ciis specifically designed for clean, reproducible installation from the lock file.
A typical CI pipeline might therefore use:
npm ci
npm test
npm run buildrather than:
npm install
npm test
npm run build⚔️ npm install vs npm ci
This distinction is extremely important.
npm install
Generally used when you’re developing.
It can:
- install dependencies
- update
package-lock.json - resolve dependencies
- modify the lock file when necessary
Example:
npm install axiosThis can modify both:
package.json
package-lock.jsonnpm ci
ci means Clean Install.
It’s designed for CI/CD environments.
Example:
npm ciIt uses the lock file as the source of truth and installs the dependency tree described there.
It also expects the lock file and package.json to be in sync.
If they aren’t, npm can fail instead of silently resolving a new dependency tree.
That’s actually a good thing in CI.
You want the pipeline to say:
“The dependency definition changed but the lock file wasn’t updated.”
rather than:
“No problem, I’ll figure something out.”
🧠 So why not just put exact versions in package.json?
You might ask:
“Why not simply do this?”
{
"dependencies": {
"express": "5.1.0",
"axios": "1.12.0"
}
}That still doesn’t completely solve the problem.
Because Express itself has dependencies.
For example:
your application
└── express 5.1.0
├── package-A
├── package-B
└── package-CYou aren’t necessarily specifying the exact versions of those transitive dependencies in package.json.
That’s one of the major jobs of the lock file.
🌳 package.json vs package-lock.json
A useful mental model:
package.json
│
│
"What I need"
│
▼
npm dependency
resolver
│
▼
package-lock.json
│
│
"What to install"
│
▼
node_modulesOr even simpler:
package.json
↓
Requirements
package-lock.json
↓
Resolution
node_modules
↓
Reality🚨 What happens if you delete package-lock.json?
Suppose your project contains:
package.json
package-lock.jsonYou delete:
rm package-lock.jsonThen run:
npm installnpm has to resolve the dependency tree again.
Get Jahangir’s stories in your inbox
Join Medium for free to get updates from this writer.
It may produce a new:
package-lock.jsonPotentially with different versions of dependencies.
That doesn’t necessarily mean the application will break.
But it means you’ve thrown away an important piece of reproducibility information.
For production projects, that’s usually something you don’t want to do casually.
🛡️ What about security?
This is another reason the lock file matters.
Imagine your application has:
my-app
└── framework
└── vulnerable-libraryYou can use npm’s auditing capabilities to identify vulnerabilities in your dependency tree.
For example:
npm auditThe lock file helps npm understand the concrete dependency tree being used.
You can also inspect the dependency tree with:
npm lsFor example:
npm ls expressor:
npm lsThis is extremely useful when troubleshooting:
“Why is this vulnerable package installed when I never added it?”
The answer is often:
It’s a transitive dependency.
🤯 The package you never installed
Imagine:
"dependencies": {
"some-framework": "^3.0.0"
}You never installed:
left-padBut:
some-framework
↓
library-A
↓
library-B
↓
left-padSo:
npm ls left-padcould reveal:
my-app
└─┬ some-framework
└─┬ library-A
└─┬ library-B
└── left-padThis is one reason dependency management becomes complicated surprisingly quickly.
🐳 And this becomes even more important with Docker
Consider this Dockerfile:
FROM node:24
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "start"]Notice this:
COPY package*.json ./That copies:
package.json
package-lock.jsonThen:
RUN npm ciinstalls the locked dependency tree.
This also creates a useful Docker layer:
Dockerfile
│
├── COPY package*.json
│
├── RUN npm ci
│
└── COPY application sourceIf you change only your application code:
src/app.jsbut don’t change:
package.json
package-lock.jsonDocker can often reuse the dependency-install layer.
That makes builds faster.
🚀 CI/CD: This is where DevOps people should care
A production pipeline might look like:
steps:
- checkout
- install:
npm ci
- test:
npm test
- build:
npm run build
- deploy:
deploy-to-productionThe important part is:
npm cinot:
npm installBecause the goal of CI/CD is generally:
Developer machine
│
▼
Git
│
▼
CI
│
▼
Same dependency tree
│
▼
ProductionThe fewer variables you have, the easier the system is to reproduce.
🧩 Should package-lock.json be committed to Git?
For an npm application, generally yes.
Your repository should typically contain:
my-node-app/
├── package.json
├── package-lock.json
├── src/
├── test/
├── Dockerfile
└── .gitignoreYou normally should not commit:
node_modules/So your .gitignore would commonly contain:
node_modules/But:
package-lock.jsonshould normally stay tracked.
🔄 What if I change package.json manually?
Suppose you change:
"express": "^5.1.0"to:
"express": "^5.2.0"Now your dependency declaration and lock file may no longer represent the same desired state.
Run:
npm installnpm updates the lock file accordingly.
Then commit both:
git add package.json package-lock.json
git commit -m "Update express dependency"Think:
package.json changed
↓
dependency intent changed
↓
npm install
↓
lock file updated
↓
commit both🔥 The most important rule
Here’s the rule I would remember:
Don’t manually edit package-lock.json unless you really know why you’re doing it.
Let npm manage it.
For example:
npm install express@5.2.0rather than manually opening:
package-lock.jsonand changing versions yourself.
🧠 A DevOps mental model
If you’re coming from infrastructure as code, there’s actually a very nice analogy.
Think of:
package.jsonlike your desired configuration.
And:
package-lock.jsonlike the resolved plan/state of exactly what dependencies were selected.
Then:
node_modules/is the actual environment.
So:
package.json
│
│ desired dependencies
▼
package-lock.json
│
│ resolved dependencies
▼
node_modules
│
│ runtime
▼
ApplicationThis isn’t a perfect Terraform analogy, but it’s a useful mental model for understanding the roles.
🎯 One example that makes everything click
Imagine Alice creates a project today.
Her:
package.jsonsays:
{
"dependencies": {
"express": "^5.1.0"
}
}She runs:
npm installand gets:
express 5.1.0Her repository becomes:
package.json
package-lock.jsonBob clones it tomorrow.
If Bob runs:
npm cihe gets the dependency tree recorded by the lock file.
Now imagine six months later Express has released:
5.1.1
5.1.2
5.1.3The ^5.1.0 declaration may allow a newer compatible release.
But Bob’s existing lock file still tells npm which concrete version was resolved for the project.
That’s the fundamental reason lock files exist.
🧠 The 10-second memory trick
Remember:
JSON = WHAT
LOCK = EXACTLY WHAT
Or:
package.json
↓
"I need Express"
package-lock.json
↓
"Here is exactly which Express dependency tree we resolved"And for DevOps:
npm installfor development,npm cifor reproducible CI/CD.
Final picture
package.json
┌───────────────┐
│ Direct deps │
│ Version ranges│
│ Scripts │
│ Project info │
└───────┬───────┘
│
▼
npm
│
▼
package-lock.json
┌─────────────────────┐
│ Exact resolutions │
│ Dependency tree │
│ Integrity hashes │
│ Transitive deps │
└──────────┬──────────┘
│
▼
node_modules
│
▼
Your AppSo the next time you see both files in a repository, don’t think:
“Why are there two dependency files?”
Think:
package.jsondescribes the dependency contract.package-lock.jsonmakes that contract reproducible.
And that tiny distinction is one of the foundations of reliable Node.js builds and CI/CD.








