What does semantic versioning (`major.minor.patch`) mean, and what is the difference between `^` and `~` in the versions in `package.json`?
Show answer — try answering out loud first
Semver uses three numbers: major.minor.patch. major goes up when there are changes that break compatibility; minor goes up when compatible features are added; patch goes up when bugs are fixed without breaking anything. The ^ (caret) prefix allows updating minor and patch (but not major); the ~ (tilde) prefix allows updating only patch. And the package-lock.json stores the exact versions that were actually installed, so that every team member and server gets identical, reproducible installs.
A version like 4.19.2 reads like this:
4= major: incompatible changes. Updating from4.xto5.0.0can break your code; you'll probably have to adjust things.19= minor: new features that don't break what came before. Going from4.19to4.20gives you new features and everything old keeps working.2= patch: bug fixes, with no new features or breaking changes. From4.19.2to4.19.3it's only fixes.
The prefixes tell npm how far it can update when installing:
^(caret): "accept any version that doesn't change the major". It's npm's default prefix.^4.19.0allows installing4.19.5,4.20.0,4.99.9... but never5.0.0.
~(tilde): "accept only patch changes within the same minor".~4.19.0allows installing4.19.1,4.19.9... but not4.20.0.
- No prefix (
4.19.0): an exact, fixed version, it doesn't update to anything.
The logic: ^ is more permissive (it trusts that minor and patch don't break things), ~ is more conservative (it only trusts the bug fixes).
The role of package-lock.json: the package.json stores ranges (^4.19.0), not exact versions. The problem is that "the range ^4.19.0" today can resolve to 4.19.2 and tomorrow to 4.20.0, giving different installs on different machines. The package-lock.json solves that: it records the exact version installed of each package and of each sub-dependency, plus its integrity hash. That way, when another person (or the server) runs npm install, they get exactly the same dependency tree. That's why the package-lock.json must be committed to the repository.
Ranges in the package.json and which versions they accept:
{
"dependencies": {
"express": "^4.19.0",
"cors": "~4.19.0",
"lodash": "4.19.0"
}
}
"express": "^4.19.0"(caret): accepts>=4.19.0and<5.0.0. It would install, for example,4.21.0or4.19.7, but never5.0.0."cors": "~4.19.0"(tilde): accepts>=4.19.0and<4.20.0. It would install4.19.1,4.19.8, but not4.20.0."lodash": "4.19.0"(no prefix): a fixed version, it installs exactly4.19.0.
Useful commands in the terminal:
# Install respecting the package-lock.json (reproducible, ideal in CI/production):
npm ci
# Install respecting the package.json ranges (may update within the range):
npm install
# See which EXACT version of a package ended up installed:
npm ls express
Note: npm ci deletes node_modules and installs pinned to what the package-lock.json says, without touching it. It's what's used in continuous integration and deployments to guarantee identical builds.
Confusing ^ with ~, or believing that ^ can jump a major. ^4.19.0 never reaches 5.0.0, it only moves within 4.x. Another classic mistake is not committing the package-lock.json to the repository (or deleting it): without it, each npm install can resolve the ranges to different versions, and the dreaded "it works on my machine" shows up because everyone ended up with different versions of the dependencies.
"Semver uses three numbers: major, minor, and patch. The major changes when something breaks compatibility, the minor when compatible features are added, and the patch when bugs are fixed without breaking anything. In the package.json, the caret, the circumflex symbol, allows updating minor and patch but not major, so ^4.19.0 can go up to just before 5.0.0; the tilde is stricter and only allows patch, so ~4.19.0 stays within 4.19. And to make installs reproducible there's the package-lock.json, which stores the exact versions of the whole dependency tree; that's why I commit it to the repo and in CI I use npm ci, which installs pinned to that lock."
The latest published version of a package is 2.8.4. You have these three entries. Which version would each one install?
{
"a": "^2.3.0",
"b": "~2.3.0",
"c": "2.3.0"
}
See answer
a: "^2.3.0"-> installs2.8.4. The caret allows bumping minor and patch within the major2, so it grabs the newest one available below3.0.0.b: "~2.3.0"-> installs the latest2.3.xavailable (for example2.3.9if it existed; if2.3.0is the only2.3.x, it stays at2.3.0). The tilde does not jump to2.8.4because that would change the minor.c: "2.3.0"-> installs exactly2.3.0. A fixed version, it doesn't update.