WierdX — Programming Reference All tutorials →
Developer reference · Practical tutorials · CS fundamentals
Tools & Techniques

Semantic Versioning Explained: What Version Numbers Actually Mean

Version numbers look like decoration until you upgrade a dependency and something breaks. Semantic versioning (SemVer) encodes a compatibility promise directly into the version string — a promise that package managers enforce and that library authors are expected to keep.

Published June 28, 2026

The version string 2.4.1 carries a specific meaning under SemVer: major version 2, minor version 4, patch version 1. Each component has a defined rule about when it must be incremented, and those rules exist to let package managers and developers make decisions about safe upgrades without reading changelogs for every update.

The three components

The SemVer specification (semver.org) defines each component precisely:

  • PATCH (the rightmost number) increments for backwards-compatible bug fixes. A consumer running 2.4.0 can safely upgrade to 2.4.1 because the public API has not changed and the only difference is a corrected behavior.
  • MINOR (the middle number) increments when new functionality is added in a backwards-compatible manner. Upgrading from 2.3.0 to 2.4.0 is safe: existing callers still work, and new callers can opt into new features.
  • MAJOR (the leftmost number) increments when backwards-incompatible changes are made. Upgrading from 1.x to 2.0.0 requires reading the migration guide because something your code calls may have changed signature, been removed, or now behaves differently.
# Examples of what each version bump signals

# 1.2.3 -> 1.2.4: a bug fix, safe to auto-upgrade
# 1.2.3 -> 1.3.0: a new feature added, safe to auto-upgrade
# 1.2.3 -> 2.0.0: breaking change, read the changelog before upgrading

# package.json version range syntax (npm / yarn)
{
  "dependencies": {
    "express": "^4.18.0",   # caret: allow minor/patch bumps, pin major (4.x.x)
    "lodash": "~4.17.0",    # tilde: allow patch bumps only (4.17.x)
    "uuid": "9.0.0"         # exact pin: no automatic updates
  }
}

Version ranges and what they mean

Package managers accept version range specifiers that describe which releases are acceptable:

Caret ranges (^) are the npm default. ^4.18.0 accepts any version >=4.18.0 <5.0.0. The assumption is that minor and patch releases within the same major version are safe to install automatically.

Tilde ranges (~) are more conservative. ~4.17.0 accepts any version >=4.17.0 <4.18.0 — only patch updates within the same minor release.

Exact pins install only the specific version. This eliminates surprises but also means security patches in dependencies are not automatically applied. Projects that pin exactly must update dependencies deliberately and frequently.

# Python pip / requirements.txt equivalents
requests>=2.28.0,<3.0.0    # compatible with SemVer major constraint
flask~=2.3.0               # tilde: 2.3.x only (pip compatible release operator)
sqlalchemy==2.0.23         # exact pin

# Checking what version is installed
pip show requests          # shows installed version
npm list express           # shows installed version and dependency tree

Version 0.x: the development caveat

A critical SemVer rule that frequently surprises developers: when the major version is 0 (0.y.z), anything may change at any time. The public API is considered unstable. A change from 0.4.0 to 0.5.0 can be breaking even though only the minor number changed.

This means ^0.4.0 in npm does not behave like a normal caret range — it pins to 0.4.x only (treating the minor number like a major for the purpose of compatibility). If you are publishing a library, reaching 1.0.0 is a commitment to consumers that you will follow the full SemVer contract from that point on.

Pre-release and build metadata

SemVer extends the three-part version with optional suffixes:

# Pre-release versions (lower precedence than the release)
1.0.0-alpha       # early development, expect instability
1.0.0-alpha.1     # numbered pre-release
1.0.0-beta.3      # beta phase
1.0.0-rc.1        # release candidate

# Build metadata (ignored in precedence comparisons)
1.0.0+20260628    # build date metadata
1.0.0-rc.1+sha.abc123  # pre-release with build hash

# Precedence: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0

# Most package managers will not install pre-release versions
# unless you explicitly request one:
npm install express@next       # installs the "next" dist-tag
pip install flask==3.0.0rc1    # explicit pre-release pin

Versioning your own library

The hardest part of SemVer is deciding what counts as a "breaking change." The specification defines it as any change that requires existing consumers to modify their code to keep working. Common breaking changes include:

  • Removing a public function or class.
  • Changing a function's parameter signature (renaming a required parameter, changing its type, reordering parameters).
  • Changing the return type of a function.
  • Narrowing accepted input (a function that accepted strings now requires a specific object).
  • Raising an exception where none was raised before (if callers do not expect it).

Non-breaking changes that warrant a MINOR bump: adding a new optional parameter with a default value, adding a new public function, adding a new optional field to a returned object.

# Breaking change: requires a MAJOR bump
# Before (v1.x): def connect(host, port=5432)
# After (v2.0): def connect(host, port=5432, *, timeout_ms)  <-- timeout_ms is now required
# Callers that did not pass timeout_ms will now get a TypeError

# Safe addition: MINOR bump only
# Before (v2.3.x): def connect(host, port=5432)
# After (v2.4.0): def connect(host, port=5432, timeout_ms=5000)
# New optional parameter with default; no existing caller breaks

The practical advice: bump MAJOR versions less often than you think you need to. Deprecate old behavior before removing it, using a MINOR bump plus a deprecation warning that tells callers what to change and when the old form will be removed. This gives consumers a migration window and avoids a reputation for unstable APIs.

Lock files

Version ranges in package.json or requirements.txt express intent. Lock files (package-lock.json, yarn.lock, poetry.lock, Pipfile.lock) record the exact resolved version of every dependency and transitive dependency at a point in time. Check lock files into version control for applications; omit them for libraries (so consumers can resolve to the best compatible versions for their own dependency tree). Lock files are what make builds reproducible across machines and over time.