Versioning: Semantic Versioning Guide

Version numbers aren't arbitrary. They're a communication protocol between library authors and consumers. When you see a version jump from 2.3.1 to 3.0.0, that signals something fundamentally...

Key Insights

  • Semantic versioning uses three numbers (MAJOR.MINOR.PATCH) to communicate the nature of changes: breaking changes increment MAJOR, new features increment MINOR, and bug fixes increment PATCH.
  • Package managers interpret version ranges differently—understanding the distinction between caret (^), tilde (~), and exact pinning prevents dependency hell and unexpected breakages.
  • Automating version management through conventional commits and tools like semantic-release removes human error and ensures consistent, meaningful version numbers across your project’s lifecycle.

Introduction to Semantic Versioning

Version numbers aren’t arbitrary. They’re a communication protocol between library authors and consumers. When you see a version jump from 2.3.1 to 3.0.0, that signals something fundamentally different than a jump to 2.3.2.

Semantic Versioning (SemVer) formalizes this communication. Created by Tom Preston-Werner, co-founder of GitHub, SemVer provides a specification for version numbers that conveys meaning about the underlying changes. The format is simple: MAJOR.MINOR.PATCH. But the implications run deep.

Without a versioning standard, dependency management becomes guesswork. Does upgrading from 1.4 to 1.5 break your application? Who knows. With SemVer, you know exactly what to expect. This predictability enables package managers to automatically resolve dependencies, CI pipelines to make intelligent upgrade decisions, and developers to assess risk before updating.

The Three Version Numbers Explained

Each component in MAJOR.MINOR.PATCH serves a specific purpose. Understanding when to increment each number is the foundation of semantic versioning.

MAJOR increments when you make incompatible API changes. This is the nuclear option—it tells consumers “your existing code may break.” Removing a public method, changing a function’s signature, renaming a class, or altering return types all qualify as breaking changes.

MINOR increments when you add functionality in a backward-compatible manner. New methods, new optional parameters with defaults, new classes—anything that extends capability without breaking existing usage. Consumers can upgrade safely and gain new features.

PATCH increments for backward-compatible bug fixes. The API remains identical; only the implementation changes to correct faulty behavior. Security patches typically fall here unless they require API changes.

Here’s a practical decision flowchart:

Did you change the public API?
├── No → Did you fix a bug?
│   ├── Yes → Increment PATCH
│   └── No → No version change needed
└── Yes → Is existing code still compatible?
    ├── Yes → Increment MINOR
    └── No → Increment MAJOR

Let’s see this in action with a real API:

# Version 1.0.0 - Initial release
class UserService:
    def get_user(self, user_id: int) -> dict:
        """Returns user data as a dictionary."""
        return {"id": user_id, "name": "John"}

# Version 1.0.1 - PATCH: Fixed bug where None was returned for valid IDs
class UserService:
    def get_user(self, user_id: int) -> dict:
        if user_id <= 0:
            raise ValueError("Invalid user ID")
        return {"id": user_id, "name": "John"}

# Version 1.1.0 - MINOR: Added new method, existing code unaffected
class UserService:
    def get_user(self, user_id: int) -> dict:
        if user_id <= 0:
            raise ValueError("Invalid user ID")
        return {"id": user_id, "name": "John"}
    
    def get_users_batch(self, user_ids: list[int]) -> list[dict]:
        """New feature: batch retrieval."""
        return [self.get_user(uid) for uid in user_ids]

# Version 2.0.0 - MAJOR: Changed return type from dict to User object
class User:
    def __init__(self, id: int, name: str):
        self.id = id
        self.name = name

class UserService:
    def get_user(self, user_id: int) -> User:  # Breaking change!
        if user_id <= 0:
            raise ValueError("Invalid user ID")
        return User(user_id, "John")

The jump to 2.0.0 breaks existing code that expected dict access patterns like user["name"]. That’s a breaking change, period.

Pre-release and Build Metadata

SemVer supports additional labels for pre-release versions and build metadata. These follow the patch number, separated by hyphens and plus signs respectively.

Pre-release versions indicate unstable releases that might not satisfy compatibility requirements. They follow this format:

1.0.0-alpha
1.0.0-alpha.1
1.0.0-beta.2
1.0.0-rc.1
2.0.0-alpha+build.456

Precedence rules matter here. Pre-release versions have lower precedence than the associated normal version:

1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0

Build metadata comes after a plus sign and is ignored for precedence:

1.0.0+20240115
1.0.0+build.123
1.0.0-beta+exp.sha.5114f85

Two versions differing only in build metadata are considered equal for precedence purposes. Use build metadata for CI build numbers, git commit hashes, or timestamps—information useful for traceability but not version comparison.

SemVer in Practice: Package Managers

Package managers interpret SemVer ranges to resolve dependencies. Understanding these operators prevents unexpected upgrades and version conflicts.

{
  "dependencies": {
    "exact": "2.3.1",
    "caret": "^2.3.1",
    "tilde": "~2.3.1",
    "range": ">=2.3.1 <3.0.0",
    "wildcard": "2.3.x",
    "latest": "*"
  }
}

Here’s what each resolves to:

Specifier Matches Explanation
2.3.1 Only 2.3.1 Exact version pinning
^2.3.1 2.3.1 to <3.0.0 Allows MINOR and PATCH updates
~2.3.1 2.3.1 to <2.4.0 Allows only PATCH updates
>=2.3.1 <3.0.0 2.3.1 to <3.0.0 Explicit range
2.3.x 2.3.0 to <2.4.0 Wildcard for PATCH

The caret (^) is npm’s default and the most common choice. It trusts that library authors follow SemVer—MINOR updates won’t break your code. The tilde (~) is more conservative, only allowing patch updates.

For pip (Python), the syntax differs:

requests==2.28.1      # Exact
requests>=2.28.1,<3   # Range (similar to ^)
requests~=2.28.1      # Compatible release (~=2.28.1 means >=2.28.1,<2.29.0)

Maven uses a different notation entirely:

<dependency>
    <groupId>com.example</groupId>
    <artifactId>library</artifactId>
    <version>[2.3.1,3.0.0)</version> <!-- Range: includes 2.3.1, excludes 3.0.0 -->
</dependency>

Automating Version Management

Manual version bumping is error-prone. Conventional Commits combined with automation tools eliminate guesswork.

Conventional Commits follow a structured format:

feat: add user batch retrieval endpoint      # Triggers MINOR bump
fix: resolve null pointer in user lookup     # Triggers PATCH bump
feat!: change user response format           # Triggers MAJOR bump
BREAKING CHANGE: user endpoint returns User object instead of dict

Here’s a GitHub Actions workflow using semantic-release:

name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: false

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Semantic Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npx semantic-release

Configure semantic-release in your package.json or .releaserc:

{
  "release": {
    "branches": ["main"],
    "plugins": [
      "@semantic-release/commit-analyzer",
      "@semantic-release/release-notes-generator",
      "@semantic-release/changelog",
      "@semantic-release/npm",
      "@semantic-release/github",
      ["@semantic-release/git", {
        "assets": ["package.json", "CHANGELOG.md"],
        "message": "chore(release): ${nextRelease.version}"
      }]
    ]
  }
}

This setup analyzes commits since the last release, determines the appropriate version bump, generates changelog entries, publishes to npm, and creates a GitHub release—all automatically.

Common Pitfalls and Best Practices

The 0.x.x trap: Versions before 1.0.0 are considered unstable. Anything can change at any time. Many projects languish at 0.x.x forever, which defeats SemVer’s purpose. Release 1.0.0 when your API is stable enough for production use. Perfection isn’t required—stability is.

Deprecation before removal: Never remove public API in a MINOR release. Instead, deprecate in a MINOR version (with warnings), then remove in the next MAJOR version. Give consumers time to migrate.

import warnings

# Version 2.3.0 - Deprecate old method
def get_user(user_id):
    warnings.warn(
        "get_user() is deprecated, use fetch_user() instead",
        DeprecationWarning,
        stacklevel=2
    )
    return fetch_user(user_id)

# Version 3.0.0 - Remove deprecated method
# get_user() no longer exists

Changelog discipline: Every version bump deserves a changelog entry. Tools like conventional-changelog generate these automatically from commit messages. Your changelog is the human-readable complement to version numbers.

Don’t backport features: If you’re maintaining multiple major versions, backport bug fixes (PATCH) but not features (MINOR). Features go in the latest major version only.

Beyond SemVer: Alternative Schemes

SemVer isn’t universal. Some projects benefit from alternatives.

CalVer (Calendar Versioning) uses dates: 2024.01.15 or 24.1. Ubuntu uses this (24.04 for April 2024). It’s useful when time-based releases matter more than API stability signals.

GitVersion derives versions from git history—branch names, tags, and commit counts. It’s powerful for complex branching strategies but adds tooling overhead.

When to skip SemVer: Internal tools with a single consumer, rapidly iterating prototypes, or projects where “breaking change” has no meaningful definition. If you’re the only consumer and deploy continuously, SemVer adds ceremony without value.

For libraries consumed by others, SemVer remains the gold standard. It’s a contract. Honor it, and your users will trust your releases. Break it, and they’ll pin to exact versions—defeating the entire purpose of semantic versioning.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.