Skip to main content
luke@terminal:/blog$ ls
luke@terminal:/blog$ cat npm-package-json-vs-package-lock-json.md

I Updated a Package and package.json Didn't Change. Turns Out That's Normal.

Created: 2026-04-15 | 3 min read

I updated @manningworks/projex to 1.2.0. At least, I thought I did. The installed version was 1.2.0, confirmed with npm ls. But my package.json still said ^1.1.3.

Wait, what?

I assumed npm update would update package.json. That seems like the obvious thing it should do. It doesn't. And that's by design. But figuring out why took me through a bunch of npm documentation I'd never actually read properly.


What I Thought Happened

Here's what I assumed the workflow was:

  1. Run npm update
  2. New versions get installed
  3. package.json gets updated to reflect the new versions
  4. package-lock.json gets updated too

Steps 2 and 4 happened. Step 3 did not.

I stared at my package.json for a while wondering if I'd hallucinated the update. I hadn't. The package was definitely on 1.2.0 in node_modules. But the file that's supposed to declare my dependencies was lying to me.

Or so I thought.


What package.json Actually Does

I hit the npm docs. Started with the package-lock.json docs, then worked backward to how package.json handles versions. The first thing that clicked was realizing I'd been thinking about package.json wrong the whole time.

package.json doesn't store the exact version you have installed. It stores a semver range. The ^ prefix means "compatible with this version." ^1.1.3 means "anything from 1.1.3 up to, but not including, 2.0.0." (There's also ~ for patch-only updates, and no prefix pins the exact version. But ^ is what npm uses by default, which is why nearly every entry in your package.json has it.)

So when I had "@manningworks/projex": "^1.1.3" and npm installed 1.2.0, that's working as intended. 1.2.0 satisfies the range >=1.1.3 <2.0.0. The range didn't need to change.

I didn't know this. I thought the version in package.json was the version, not the minimum acceptable version.


What package-lock.json Does

This is the part I was fuzzy on. package-lock.json stores the exact version installed for every package in your dependency tree. Not ranges. Exact versions.

When you run npm install, npm looks at the ranges in package.json, resolves them to specific versions, installs those, and writes the exact resolutions to package-lock.json.

Next time someone clones your repo and runs npm install, they get the exact same versions. Not "whatever the latest is that satisfies the range." The same versions. That's the point.

From the npm docs on package-lock.json:

This file is intended to be committed into source repositories, and serves various purposes: describe a single representation of a dependency tree such that teammates, deployments, and continuous integration are guaranteed to install exactly the same dependencies.

So the two files work together:

  • package.json says "I want something in this range"
  • package-lock.json says "here's the specific version I chose and committed to"

Why npm update Doesn't Touch package.json

This is the part that felt wrong to me. (I'm on npm 11, for reference — the behavior may differ on older versions.) The official npm docs for npm update say:

Note that by default npm update will not update the semver values of direct dependencies in your project package.json.

Why? Because the semver range in package.json is a constraint, not a version. If ^1.1.3 already allows 1.2.0, there's no reason to change it to ^1.2.0. The range still works. The lockfile already reflects the actual installed version.

There's a configuration option called save that controls this. From the docs:

save — Default: true unless when using npm update where it defaults to false

So npm update explicitly opts out of writing to package.json. You can override it with npm update --save.


What Happens When You Use --save

I ran npm update --save to force package.json to update. And it did. But the result looked... wrong.

Before:

"@types/node": "^20",
"eslint": "^9",
"typescript": "^5"

After:

"@types/node": "^20.19.39",
"eslint": "^9.39.4",
"typescript": "^5.9.3"

Very specific. Down to major.minor.patch. That felt bad. Was I now pinned to exact versions?

No. The ^ is still there. ^5.9.3 still means >=5.9.3 <6.0.0. The range just starts from a higher floor.

Is This Good or Bad?

I'm still not totally sure, but I've settled on broad ranges being fine.

The lockfile is the source of truth for what's installed. package.json is the source of truth for what range you're willing to accept. If I pin ^5.9.3 in package.json, I'm just duplicating what the lockfile already tracks. The only scenario where the pinned version matters is if someone deletes the lockfile — and I'd rather solve that with "don't delete the lockfile" than by making package.json more restrictive.


What I'm Doing Going Forward

I'm going back to broad ranges in package.json. I'll use npm update without --save to keep the lockfile current, and let package.json stay as the loose constraint it's designed to be.

If I need to pin a specific version, I'll do it intentionally with npm install package@exact-version, not as a side effect of npm update --save.

The key thing I was missing: package.json and package-lock.json aren't saying the same thing in different ways. They're saying different things on purpose.

I wish someone had explained that to me before I spent a while being confused by a file that was working exactly as designed.