Skip to main content
TF
By Rohit V.9 min readArticle

Semantic Versioning (SemVer) Explained for Developers

TF
ToolsFuel Team
Web development tools & tips
Notebook with version numbers and code planning on a desk

Photo by Unsplash on Unsplash

MAJOR.MINOR.PATCH, Decoded

> Quick answer: Semantic Versioning (SemVer) is the `MAJOR.MINOR.PATCH` scheme almost every package uses. MAJOR bumps on a breaking change that's incompatible with the old API. MINOR bumps when new, backward-compatible functionality is added. PATCH bumps for backward-compatible bug fixes. In `package.json`, the caret (`^`) allows MINOR and PATCH updates but not MAJOR — `^4.17.21` accepts up to but not including `5.0.0`, and it's npm's default. The tilde (`~`) is stricter, allowing only PATCH updates — `~1.2.3` accepts up to but not including `1.3.0`. Pinning an exact version (`1.2.3`, no symbol) locks it completely.

For a long time I treated the `^` in my `package.json` as decoration. Then a "minor" update quietly broke a build at 2am, and I learned the hard way that those symbols are instructions with real consequences. Understanding SemVer is the difference between confident dependency updates and playing roulette every time you run `npm install`.


This post breaks down what each number means, exactly how `^` and `~` change which versions you accept, the special rules for `0.x` packages that trip everyone up, and the lockfile that makes any of it reproducible.

What Each Number Promises

A version like `4.17.21` is a promise from the package author about compatibility. Reading left to right:

MAJOR (the `4`) — increments when the author makes a breaking change. Code that worked on `3.x` might not work on `4.x`. New major version = read the changelog, expect to do migration work. This is the number that can ruin your day if you let it update automatically.

MINOR (the `17`) — increments when new functionality is added in a backward-compatible way. You get new features, but everything that worked before still works. Upgrading a minor version should be safe in theory.

PATCH (the `21`) — increments for backward-compatible bug fixes. No new features, no breaking changes, just fixes. These are the safest updates and the ones you almost always want.

The whole system rests on authors honoring this contract. A well-behaved package never sneaks a breaking change into a minor or patch release. In reality, mistakes happen — a "patch" occasionally breaks something — which is exactly why lockfiles and testing matter, and why some teams pin exact versions for critical dependencies.


There's also pre-release and build metadata: `1.0.0-beta.2` or `1.0.0+build.456`. The pre-release tag (`-beta.2`) marks an unstable version that sorts *before* the stable `1.0.0`, and you have to opt into it explicitly. You'll mostly see these when testing release candidates. The
official SemVer specification lays out every rule precisely, including how pre-release versions are ordered.

The deeper idea behind all this is *communication*. A version number is the cheapest possible changelog — before you read a single line of release notes, the version bump alone tells you how nervous to be. A patch? Update without thinking. A minor? Skim the notes for the new feature. A major? Block out time. That's the whole social contract, and it only works if both sides honor it: authors version honestly, and consumers set their ranges to match their risk tolerance. When a package routinely violates SemVer — sneaking breaking changes into minors — the community notices fast, because everyone's automated updates start breaking. The reputation hit is real, which is part of why the system mostly holds up despite having no enforcement mechanism. It's just a convention, but it's a convention with teeth because the cost of breaking it lands on the author.

Caret vs Tilde — The Symbols That Matter

Terminal window showing npm install output

Photo by Unsplash on Unsplash

Those symbols in front of your dependency versions decide how far updates are allowed to drift. Get them wrong and you either miss security fixes or invite breaking changes.

Caret (`^`) — minor and patch. `^4.17.21` means "this version or any newer one that doesn't change the leftmost non-zero number." So it accepts `4.17.22`, `4.18.0`, `4.99.99` — anything below `5.0.0`. It assumes minor and patch updates are safe (per the SemVer contract) but blocks the major bump. This is npm's default when you `npm install` something, which is why you see `^` everywhere.

Tilde (`~`) — patch only. `~1.2.3` means "this version or any newer patch," so it accepts `1.2.4`, `1.2.9`, but not `1.3.0`. It's the conservative choice: you get bug fixes but no new minor features that might, despite the author's intent, introduce something unexpected.

Exact (no symbol). Just `1.2.3` locks that precise version. Nothing updates automatically. This is the most predictable and the most maintenance-heavy — you have to manually bump to get any fix.

The practical guidance I follow: the default caret is fine for most apps where you want fixes and features without thinking too hard. For production stability or a critical dependency, tilde or exact pinning reduces surprises. The
tilde-vs-caret breakdown on NodeSource is a good reference if you want more examples. Either way, your lockfile is what actually freezes the resolved versions — the range in `package.json` is just the *allowed window*.

The 0.x Trap Everyone Hits

Here's the rule that catches even experienced developers: SemVer treats `0.x` versions specially, and the caret behaves differently below `1.0.0`.

The logic is that anything in the `0.x` range is considered unstable — the author is saying "this isn't production-ready, the API may change at any time." So SemVer's rule is that for `0.x`, the *minor* number acts like a major. A `0.2.0` → `0.3.0` bump can include breaking changes, because the project hasn't committed to a stable API yet.


This changes what `^` does. For a normal package, `^4.2.3` allows up to `5.0.0`. But `^0.2.3` only allows up to `0.3.0` — not all of `0.x`. The caret protects the leftmost *non-zero* number, which for `0.2.3` is the `2`. So:


- `^4.2.3` → accepts `<5.0.0` (minor + patch) - `^0.2.3` → accepts `<0.3.0` (patch only, effectively) - `^0.0.3` → accepts `<0.0.4` (locked to that exact patch)


I've been bitten by assuming `^0.5.0` would happily update to `0.6.0`. It won't, because `0.6.0` is treated as a potential breaking change. If you depend on a `0.x` package and want minor updates, you have to widen the range manually or wait for the author to ship `1.0.0`.


The takeaway: when a package is still `0.x`, expect more friction and read its changelog carefully. A lot of popular tools sat at `0.x` for years, and their version bumps don't follow the same comfortable assumptions you make about a mature `4.x` library.


There's a related debate worth knowing about: some maintainers deliberately stay on `0.x` forever to signal "move fast, don't assume stability," while others argue that anything people depend on in production should commit to `1.0.0` and real SemVer. I lean toward the second camp — once a package has thousands of dependents, hiding behind `0.x` to dodge the SemVer contract feels like having it both ways. But as a *consumer*, your job is just to read the signal correctly: a `0.x` dependency is the author telling you they reserve the right to break things, so pin it tighter than you would a mature library. I'll often use an exact version or a tilde for a `0.x` package and a caret for anything past `1.0.0`. It's a small adjustment that has saved me from a surprising number of "why did the build break overnight" mornings, because those overnight breaks almost always trace back to a `0.x` dependency doing exactly what `0.x` is allowed to do.

Lockfiles — Where Reproducibility Lives

Code editor showing a configuration file with dependencies

Photo by Unsplash on Unsplash

Version ranges describe what you'll *accept*; lockfiles record what you actually *got*. This distinction is the thing that makes builds reproducible, and skipping it is how "works on my machine" happens.

When you run an install, your package manager resolves every range to a concrete version and writes the full tree — including the resolved versions of nested dependencies — into a lockfile: `package-lock.json` for npm, `yarn.lock` for Yarn, `pnpm-lock.yaml` for pnpm. The next person who installs gets the exact same versions you did, regardless of what new releases shipped in the meantime.


A couple of rules I treat as non-negotiable:


-
Commit your lockfile. Always. It's the single source of truth for what your app was built against. Without it, two developers with the same `package.json` can end up with different dependency trees because the ranges resolved to different versions on different days. - Use `npm ci` (or the equivalent) in CI. It installs strictly from the lockfile and fails if `package.json` and the lockfile disagree, so your pipeline builds exactly what you tested locally. - Review lockfile changes in PRs. A surprise jump in a transitive dependency shows up there, which is your chance to catch a sketchy update before it ships.

If you're weighing which package manager handles all this best, I compared them in
npm vs Yarn vs pnpm — they differ in lockfile format and install speed but follow the same SemVer rules. And when I'm generating version strings or unique identifiers for releases, the ToolsFuel UUID generator and the rest of the free developer tools cover the small utilities that come up around release work. SemVer plus a committed lockfile is what turns dependency management from a gamble into something boring and predictable — which is exactly what you want it to be.

Frequently Asked Questions

What does MAJOR.MINOR.PATCH mean in versioning?

It's the Semantic Versioning format. MAJOR increments on a breaking, incompatible API change. MINOR increments when new backward-compatible functionality is added. PATCH increments for backward-compatible bug fixes. So going from 4.17.21 to 4.18.0 adds features safely, while 4.x to 5.0.0 means breaking changes you'll need to migrate for.

What's the difference between ^ and ~ in package.json?

The caret (^) allows minor and patch updates but not major — ^4.17.21 accepts anything below 5.0.0, and it's npm's default. The tilde (~) is stricter, allowing only patch updates — ~1.2.3 accepts up to but not including 1.3.0. Caret gets you new features plus fixes; tilde gets you only fixes, which is safer for production-critical dependencies.

Why does ^0.2.3 behave differently from ^4.2.3?

Because SemVer treats 0.x versions as unstable. The caret protects the leftmost non-zero number, so for ^4.2.3 that's the 4 (allowing up to 5.0.0), but for ^0.2.3 it's the 2 (allowing only up to 0.3.0). This means a 0.x package won't auto-update across minor versions even with a caret, since those bumps can include breaking changes. Read the changelog carefully for any 0.x dependency.

Should I commit my package-lock.json file?

Yes, always. The lockfile records the exact resolved versions of every dependency, including nested ones, so everyone who installs gets an identical tree. Without it, the same package.json can resolve to different versions on different days, causing 'works on my machine' bugs. In CI, use npm ci to install strictly from the lockfile. If you're choosing a package manager, see [npm vs Yarn vs pnpm](/blog/npm-vs-yarn-vs-pnpm-which-package-manager-2026).

Is the caret update guaranteed not to break my code?

Not guaranteed — it depends on the author honoring the SemVer contract. The caret assumes minor and patch releases are backward-compatible, but mistakes happen and a 'patch' occasionally breaks something. That's why committing a lockfile and running your test suite before deploying matters. For critical dependencies, pinning exact versions or using the tilde reduces the surface for surprises.

What do pre-release tags like 1.0.0-beta.2 mean?

A pre-release tag marks an unstable version that sorts before the stable release — 1.0.0-beta.2 comes before 1.0.0. You have to opt into pre-releases explicitly; a normal version range won't pull them in automatically. You'll mostly encounter them when testing release candidates or early builds of a library before its stable launch.

Try ToolsFuel

23+ free online tools for developers, designers, and everyone. No signup required.

Browse All Tools