package.json says what you want. package-lock.json records what you got.
You write "express": "^4.18.2", which is a range covering every 4.x release. The lockfile pins the one exact version that was installed, plus every transitive dependency underneath it, so the next person who installs gets a byte-identical tree instead of whatever is newest that day.
Both belong in git. The rest of this explains why, and what goes wrong when they disagree.
What is package.json?
package.json is the project manifest file. It describes your project and lists the dependencies your application needs.
It typically includes:
- Project name and version
- Scripts (
npm start,npm build, etc.) - Dependencies and dev dependencies
- Metadata (author, license, etc.)
Example
{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2"
}
}
Key Point
When you write "express": "^4.18.2", the ^ means npm can install any compatible version, such as 4.18.2, 4.19.0, or any 4.x.x release.
This means installs may differ over time, which can cause problems.
Reading a version range
The character in front of the version decides how much drift you are allowing. This is the whole reason the lockfile has to exist.
| Range | Matches | Allows |
|---|---|---|
4.18.2 | exactly 4.18.2 | nothing |
~4.18.2 | 4.18.x | patch releases |
^4.18.2 | 4.x.x | minor and patch |
^0.4.2 | 0.4.x | patch only, because 0.x treats minor as breaking |
* or latest | anything | everything, including major versions |
^ is the npm default, so unless you have deliberately changed it, every dependency you install is a range rather than a version. That is the correct default for a library and a liability for an application, and the lockfile is what makes it safe.
[!NOTE] The
^0.xrule catches people out. Under semver, a package below 1.0 has no stability guarantee, so npm narrows^to patch-only there. A jump from0.4.2to0.5.0is treated as breaking and will not be installed automatically.
What is package-lock.json?
package-lock.json is automatically generated by npm. It locks the exact versions of every installed dependency, including sub-dependencies.
It ensures:
- Same dependency tree across all environments
- Same versions for everyone on the team
- Reproducible installs across machines
Example (simplified)
{
"dependencies": {
"express": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
"integrity": "sha512-..."
}
}
}
This file guarantees that everyone installs the exact same version, even if package.json allows flexibility.
Main Differences
| Feature | package.json | package-lock.json |
|---|---|---|
| Purpose | Defines dependencies | Locks exact versions |
| Created by | Developer | npm automatically |
| Should you edit? | Yes | No |
| Version flexibility | Yes | No |
| Included in Git? | Yes | Yes |
| Controls sub-dependencies | No | Yes |
Why Both Files Are Needed
Think of it like this:
package.json→ What you wantpackage-lock.json→ What you actually got
Example scenario:
- You install today → version 1.2.3
- Your teammate installs tomorrow → version 1.2.5
- App breaks due to subtle differences
With package-lock.json, everyone installs exactly 1.2.3. No surprises.
When Does package-lock.json Update?
It updates when:
- You run
npm install - You add or remove packages
- You run
npm update
[!TIP] A lock file for a real project runs to thousands of lines, and a merge conflict in one is genuinely unpleasant to read. Dropping it into the JSON Parser collapses the tree so you can find the one dependency that actually changed.
Should You Commit package-lock.json?
Yes, always commit it to Git.
Benefits:
- Reproducible builds
- Faster installs (npm can skip resolution)
- Prevents "works on my machine" issues
npm install vs npm ci
This is the practical payoff of having a lockfile, and the part most people never switch on.
npm install | npm ci | |
|---|---|---|
| Reads | package.json, then the lockfile | the lockfile only |
| Can change the lockfile | yes | no, it errors instead |
| Needs an existing lockfile | no | yes |
node_modules | updated in place | deleted and rebuilt |
| Use it | while developing | in CI and any reproducible build |
npm install treats the lockfile as a suggestion. If package.json allows a newer version than the lockfile pins, it will happily resolve upward and rewrite the lockfile as a side effect, which is how a lockfile change sneaks into a pull request nobody meant to touch.
npm ci treats the lockfile as the source of truth. If the two files disagree it fails rather than guessing:
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json or npm-shrinkwrap.json are in sync. Please update
npm error your lock file with `npm install` before continuing.
npm error
npm error Missing: is-odd@3.0.1 from lock file
That error is the feature. It means someone edited package.json without running an install, and the build stopping is better than the build silently producing a different tree from the one that was tested.
Use npm ci in CI, in Docker builds, and anywhere reproducibility matters. It is also faster, because it skips resolution entirely.
lockfileVersion, and why the diff exploded
The first line of the lockfile is a format version:
{ "name": "my-app", "lockfileVersion": 3 }
- v1: npm 6. No transitive integrity data.
- v2: npm 7 to 8. Backwards compatible with v1 readers, which is why it was so large: it carried both formats at once.
- v3: npm 9 and later. Drops the v1 compatibility block, so the file is considerably smaller.
If a colleague's install produces a 20,000 line diff on a file you did not touch, this is usually why: someone is on a different major npm version and the format is being rewritten. Fix it by agreeing a version, ideally with an engines field and a .nvmrc.
When the two files disagree
The failure mode nobody warns you about. package.json is the file humans edit, and the lockfile is generated, so they drift:
- Someone hand-edited
package.json. The lockfile still pins the old version.npm installfixes it,npm cifails loudly. - A merge brought in two different dependency sets. Do not hand-resolve the lockfile. Take either side, then run
npm installto regenerate it from the mergedpackage.json. - A transitive dependency published a bad release. Use
overridesto force a version without waiting for the direct dependency to update:
{
"overrides": {
"semver": "7.5.4"
}
}
To see what is actually installed rather than what was requested, npm ls <package> prints the resolved tree and shows you which parent pulled it in.
Quick Analogy
package.json= Shopping listpackage-lock.json= Receipt with exact brands and quantities
Conclusion
Both files serve different but complementary purposes:
- Use package.json to declare what dependencies you need
- Use package-lock.json to guarantee everyone gets the same versions
Never delete package-lock.json unless you intentionally want to regenerate your entire dependency tree. If you think you need to, the fix is usually npm ci on a clean checkout instead.
One change worth making today: switch your CI job from npm install to npm ci. It is a one-word edit, it makes your builds reproducible, and it turns a silent dependency drift into a failed build with a clear message.
Tools in this post
Related Tool
JSON Parser & Formatter
Validate, format, and minify JSON data with error highlighting.
Try it freeTagged with
Written by
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
Related Articles
All articles →Why TypeScript Generics Are More Powerful Than You Think
A deep dive into TypeScript's generic type system, from basic usage to advanced patterns like conditional types, infer, and mapped types that will make your code safer and more expressive.
Docker for Developers: From Zero to Production-Ready in One Guide
Docker is non-negotiable in modern development. This guide takes you from installing Docker to running a full multi-service production stack with zero fluff and working code at every step.
Run Two Claude Code Accounts at Once (Personal + Office)
Claude Code has no account switcher yet, but one environment variable lets you keep a personal and an office login active at the same time. Here's the full setup.