The little symbol in front of a version number

If you have ever opened a package.json file (the file where a JavaScript project lists the code libraries it depends on), you have probably seen something like this:

{
  "dependencies": {
    "express": "^4.18.2",
    "left-pad": "~1.3.0",
    "chalk": "5.3.0"
  }
}

Three libraries, three different rules. The ^ (called a caret), the ~ (a tilde) and no symbol at all each tell npm, the tool that downloads these libraries, how much freedom it has to pick a newer version than the one you wrote. This article explains the rules and lets you try them.

Version numbers have three parts

Most JavaScript libraries number their releases in the style called semantic versioning (semver for short): three whole numbers separated by dots, MAJOR.MINOR.PATCH. In 1.4.2, the major part is 1, the minor part is 4 and the patch part is 2.

The convention is a promise from the library's author about what changed:

Part that goes upMeaningExample
PATCHA bug fix. Nothing you use should change.1.4.2 → 1.4.3
MINORNew features added. Old code should keep working.1.4.3 → 1.5.0
MAJORA breaking change. Your code may need edits.1.5.0 → 2.0.0

It is a promise, not a guarantee: authors are people and sometimes break it by accident. But it is what the symbols in front of a version are built around.

What each rule allows

Here is what npm will accept for each way of writing 1.2.3:

You writenpm may installPlain words
1.2.3only 1.2.3Exactly this one.
~1.2.31.2.3 up to, but not including, 1.3.0Bug fixes only.
^1.2.31.2.3 up to, but not including, 2.0.0Bug fixes and new features, nothing breaking.

The caret is the one you will meet most, because when you run npm install some-library, npm writes the version it installed into package.json with a ^ in front by default.

Try it: a pretend registry

A registry is the public warehouse that npm downloads libraries from. Below is a tiny pretend one for a library called cool-lib. Every box is a published version. Type a rule, or tap a preset, and the boxes that the rule allows light up. The solid green one is the version npm would actually install: the highest one that fits.

Then use the orange buttons to release a new version into the registry and see whether your rule lets it in.

Try ^1.2.3, then release 3.0.0. Then change the rule to ^0.2.3 and watch the 0.x boxes. Rules like 1.x and >=1.2.0 <2.0.0 work too.

Why 0.x versions are treated as risky

Did you notice what ^0.2.3 did? It allowed 0.2.9 but not 0.3.0. The caret's real rule is: keep the left-most number that is not zero the same. For ^1.2.3 that number is the 1. For ^0.2.3 it is the 2 (the zero before it does not count), so the minor part is frozen. For ^0.0.3 it is the final 3, so only that exact version fits.

The reason: a library numbered 0.something is conventionally still in early development, where any release might change things. npm therefore gets more careful the closer to zero you are.

Checking it with real code

npm uses a package called semver to do this matching, and you can use it yourself. This snippet asks: out of these published versions, which is the highest one each rule allows? (Install it first with npm install semver; I ran this exact code with Node 22.)

const semver = require('semver')
const published = ['1.2.3', '1.2.9', '1.3.0', '1.9.5', '2.0.0']

console.log(semver.maxSatisfying(published, '^1.2.3'))
console.log(semver.maxSatisfying(published, '~1.2.3'))
console.log(semver.maxSatisfying(published, '1.2.3'))

Output:

1.9.5
1.2.9
1.2.3

That matches what the widget above shows. The matcher in this page is a small re-implementation I wrote for the demo, and I checked it against the real semver package on thousands of version and rule combinations; they agreed every time.

So what: the lock file

If rules allow newer versions, two people running npm install a month apart could get different code. npm prevents that with a second file, package-lock.json. It records the exact version that was installed for every library, including the libraries your libraries depend on. The two files do different jobs:

package.json says what you allow. package-lock.json says what you got. When a lock file exists and still fits your rules, npm install reuses those exact versions. Running npm update asks npm to move to the newest versions your rules allow and rewrite the lock file. And npm ci, which is common on build servers, installs exactly what the lock file says.

For your own projects: keep the default ^ for most libraries, commit package-lock.json to Git along with package.json, and treat a major version jump (say 4 to 5) as something to read the release notes for before you accept it. Your rule will never do that jump on its own, which is the whole point.