Semantic versioning gives each release a number in the form MAJOR.MINOR.PATCH and assigns a meaning to each part. A dependency that moves from 4.2.1 to 4.3.0 says it added features without breaking existing use. A move to 5.0.0 says something you rely on may have changed.

That promise is what lets a package manager install a newer copy of a library automatically. It is also why a build can break on a Monday when nobody on the team touched the code. The Semantic Versioning 2.0.0 specification defines the numbering rules; npm’s version ranges and lockfile decide which of those numbers actually end up in your project.

What each number promises

The specification starts with a requirement that is easy to skip: software using semantic versioning must declare a public API, either in code or in documentation. The version number then describes changes to that API, not to the code in general.

From the specification’s summary:

  • MAJOR goes up for incompatible API changes.
  • MINOR goes up for functionality added in a backward compatible way. It must also go up when any public API feature is marked as deprecated.
  • PATCH goes up for backward compatible bug fixes.

When the minor number rises, the patch number resets to 0. When the major number rises, both reset. Numbers are compared as integers, so 1.10.0 comes after 1.9.0.

The specification also says a released version must never be modified. Any change, including a fix to a broken release, ships as a new version.

Version 0.x and pre-releases

Major version zero is for initial development. The specification says that under 0.y.z “anything MAY change at any time” and the public API should not be considered stable.

A pre-release adds a hyphen and identifiers after the patch number, as in 2.0.0-beta.2. A pre-release ranks below the normal version it leads to, and the specification notes it might not meet that version’s compatibility promise.

Build metadata goes after a plus sign, as in 1.0.0+20130313144700. It is ignored when versions are compared.

How npm turns a version into a range

When you run npm install some-library, npm writes a range into package.json, not only the exact version it installed. The save-prefix setting defaults to ^, the caret.

The caret allows updates that keep the left-most non-zero number the same. The node-semver documentation, the parser npm uses, gives the exact boundaries:

Range in package.json Versions it accepts What can arrive later
^1.2.3 >=1.2.3 <2.0.0-0 New minor and patch releases of 1.x
^0.2.3 >=0.2.3 <0.3.0-0 Patch releases of 0.2.x only
^0.0.3 >=0.0.3 <0.0.4-0 Nothing newer
~1.2.3 >=1.2.3 <1.3.0-0 Patch releases of 1.2.x
1.2.3 Exactly 1.2.3 Nothing newer

The 0.x rows are deliberately narrower. node-semver explains that many authors treat the second number of a 0.x version as the breaking-change signal, so the caret stops before it.

Pre-releases are excluded by default. A range such as ^1.2.3 will not pick up 1.3.0-beta.1 unless the range itself names a pre-release on the same major, minor and patch numbers, or the includePrerelease option is set.

A worked example

Suppose your app’s package.json lists "date-helper": "^3.4.0", a hypothetical library. You installed 3.4.0 in March.

In June the maintainer publishes 3.5.0, which adds a function and, by mistake, changes the default time zone of an existing one. The range accepts 3.5.0, because 3.5.0 is below 4.0.0. A fresh install without a lockfile picks it up, and a test that compares formatted dates fails.

Nothing in your repository changed. The version number promised compatibility and the release did not keep the promise. The specification covers this case: once the maintainer notices, the fix is a new minor release that restores compatibility, and the broken version stays published, because released versions are not edited.

What protects you in the meantime is the lockfile, not the range.

The lockfile does the pinning

npm writes package-lock.json whenever it changes node_modules or package.json. npm’s documentation describes it as a record of the exact tree that was generated, so later installs can reproduce it, and says it is intended to be committed to source control.

For builds, npm ci is the stricter command. It requires an existing lockfile, exits with an error if the lockfile and package.json disagree, and never writes to either file.

In the example above, a project that committed its lockfile and built with npm ci keeps installing 3.4.0. The 3.5.0 release only arrives when someone updates the lockfile on purpose and the tests run against it.

The lockfile applies to the project at the root. npm notes that package-lock.json cannot be published and is ignored anywhere other than the root project, so a library you publish does not pin versions for the apps that install it.

What to check before you rely on a version number

  1. Is the dependency at 1.0.0 or later? Under 0.x, the specification makes no stability promise.
  2. Does the project document its public API? The version number only describes changes to what is declared.
  3. Which range did npm save? The default caret accepts every later minor release of the same major version.
  4. Is package-lock.json committed, and does your build use npm ci?
  5. Do you read the changelog and run tests when the lockfile changes?

Semantic versioning is a convention maintainers follow, not something the registry enforces. The specification itself says to verify that upgrades “function as advertised.” For a related question about what installed software can reach, see our guide to browser extension permissions.