Skip to main content
All posts

Software Engineering Fundamentals: Making Sense of Semantic Versioning

Bosh9 min read

Why Version Numbers Matter: A Lesson I Learned the Hard Way​

In my first year working as a developer, I didn't really understand the compatibility issues hidden behind package versions. I still remember seeing a new release of a package I used regularly and thinking:

“Oh, a new release! Version 5, too. Those new features on the website look pretty cool. Let's upgrade and try them out.”

Without much thought, I upgraded the package in my project from version 4 to version 5. And just like that, the whole project stopped working. After checking the docs, I discovered that some APIs I relied on had been removed. Even the way you imported the package had changed.

For example, this used to work:

import functionA from 'some-library';

But the new version required a named import:

import { functionA } from 'some-library';

I later learned that this was a major version bump, meaning it could include breaking changes and require changes to how I used the package. That experience was when I started to see that version numbers aren't just numbers. There's a logic and an intent behind them, with a set of rules called Semantic Versioning.

Later, my work also involved developing and maintaining packages. Once I was on the other side, maintaining the packages and their source, Semantic Versioning became even more important. How well you manage package versions directly affects collaboration, the user experience, and even product stability. In this article, I want to explore the core ideas of Semantic Versioning alongside some lessons from maintaining packages myself. I can't cover every detail here, so if you have time, I recommend reading the Semantic Versioning 2.0.0 specification alongside this article.


What Is Semantic Versioning? The Basic Rules and Examples​

Version numbers are a language for communicating changes. So how does that language work? That's where Semantic Versioning, or SemVer, comes in.

The SemVer Format: MAJOR.MINOR.PATCH​

SemVer uses a simple three-part format:

MAJOR.MINOR.PATCH

Each part has a meaning:

PartMeaning
MAJORBreaking changes: incompatible API changes
MINORNew features: backward-compatible additions
PATCHBug fixes: backward-compatible fixes

A few simple examples:

  • 1.2.3 → 1.2.4: fix a small bug (patch).
  • 1.2.3 → 1.3.0: add a feature (minor).
  • 1.2.3 → 2.0.0: introduce breaking changes; take care when upgrading (major).
tip

The point of SemVer is to communicate the intent of a change. A version number doesn't just say “the code is done.” It tells users what changed and whether it affects them.

Pre-release Versions and Build Metadata​

Beyond the core version number, SemVer supports additional identifiers for pre-release versions that aren't officially released yet or are intended for internal use:

Format: 1.0.0-<identifier>

Common identifiers include:

  • 1.0.0-alpha: very early; may be unstable.
  • 1.0.0-beta: more stable than alpha, but may still have issues.
  • 1.0.0-rc.1 (release candidate): a candidate for the official release.

📌 Pre-release versions aren't installed automatically unless you request one explicitly, for example with npm install some-lib@beta.

Which Number Should You Bump? Choosing Patch / Minor / Major with Component Library Examples​

Now that we've covered the three parts of a version number, let's apply them to a component library and build a feel for versioning. The decision can often be reduced to this:

If it breaks existing usage, bump major. If it adds a feature, bump minor. If it fixes a bug, bump patch.

🔴 Major (X.0.0): Breaking Changes​

A change that breaks existing code after an upgrade is a breaking change and should bump MAJOR.

✅ Removing or Changing an Existing API

// v1.x usage
<Button variant="outlined" />

// v2.0.0 removes the `variant` prop
<Button /> // 🚨 Error after upgrading: variant is no longer supported

✅ Changing Names or Usage

// v1.x
<Modal isOpen={true} />

// v2.0.0 renames it to open (closer to the HTML standard)
<Modal open={true} /> // 🚨 Requires changes to the calling code

✅ Changing a Component's Default Behavior

  • Changing Table from server-side pagination to client-side pagination directly changes what users see and how their data is handled.

✅ Changing TypeScript Types in an Incompatible Way

// v1.x
type InputProps = {
value: string | number;
}

// v2.0.0 accepts only string
type InputProps = {
value: string;
}

Users who previously passed a number will now get a type error.

A Rule of Thumb

If upgrading makes existing code stop working, it's a breaking change.

🟡 Minor (X.Y.0): New Features That Stay Compatible​

If you're adding something without breaking existing functionality, a MINOR bump is usually appropriate.

✅ Adding a Prop

// v1.2.0
<Tooltip content="Hi" />

// v1.3.0 adds the delay prop
<Tooltip content="Hi" delay={300} /> // ✅ Existing usage still works

✅ Supporting a New Event

// v1.2.0
<Button onClick={handleClick} />

// v1.3.0 adds onDoubleClick
<Button onClick={handleClick} onDoubleClick={handleDoubleClick} />

✅ Adding a Style Option or Variant

// v1.3.0 adds variant="secondary"
<Button variant="secondary" />

✅ Adding a Component

// v1.4.0 adds AvatarGroup
<AvatarGroup>
<Avatar src="user1.jpg" />
<Avatar src="user2.jpg" />
</AvatarGroup>
A Rule of Thumb

A new feature that leaves users' existing code working is a minor change.

🟢 Patch (X.Y.Z): Bug Fixes and Internal Improvements​

The distinguishing feature here is that users keep writing the same code, but it works better or a bug is fixed.

✅ Fixing a Bug

// v1.2.3: Modal does not fire onClose (bug)

// v1.2.4, after the fix
<Modal open={true} onClose={() => console.log('Closed')} />

Usage stays the same, but the behavior is now correct.

✅ Fixing a Styling Issue

  • Fixing excessive padding on Button in Firefox.

✅ Improving Performance or Refactoring Without Changing the API

  • Adding virtualization to Table to improve rendering performance.
  • Removing unnecessary re-renders without affecting the public API.

✅ Improving TypeScript Definitions Without Affecting Existing Usage

// v1.2.3
type TextFieldProps = {
onChange: (value: any) => void;
}

// v1.2.4
type TextFieldProps = {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

Users get more specific types while their existing code continues to work.

✅ Updating Dependencies Without Changing External Behavior

chore(deps): update react to 18.2.0
A Rule of Thumb

If users don't need to change anything, but the behavior is more correct or more stable, it's a patch.


Common Questions About Semantic Versioning​

By now, you should have a reasonable grasp of Semantic Versioning. In practice, though, a few details still cause confusion. Let's go through some common questions and misconceptions.

❓I Only Changed Internal Logic, Not the API. Do I Still Need a Version Bump?​

✅ Yes.

Even without an API signature change, an implementation change can affect component behavior, such as return values or defaults. Bump the version and choose the appropriate part based on the impact.

📌 A common mistake: “Internal logic isn't part of the API.”

👉 Behavior is part of the API too. If users can observe a change, you're responsible for it.

❓Is Removing a Deprecated Feature Still a Breaking Change?​

✅ Yes. It's still a breaking change and needs a major bump.

Even if something has been deprecated for a long time, users can still depend on it while it exists. Removing it is still a breaking change.

⚠️ Deprecation is advance notice. It doesn't let you skip the versioning rules.

❓I Added a Prop but Also Changed the Component's Default Behavior. Is That Breaking?​

🟠 It depends, but usually yes.

For example:

// Previously, Tooltip always appeared on the right by default
<Tooltip content="Hi" />

// The new version adds `placement` but changes the default to bottom
<Tooltip content="Hi" />

The code looks the same, but the result on screen has changed, which can affect the user experience or layout. I'd recommend a major bump for this kind of change, or at least explaining it clearly in the release notes.

💡 If users get a different result without changing their code, that's usually a breaking change.

❓A Package Is at 0.1.0. Does That Mean It's Stable?​

❌ No.

Under SemVer, a 0.x.y version means the project is still in initial development, with no guarantee of API stability. A change to any part of the version could be breaking.

📌 A common assumption: “Surely 0.1.0 is the first release?”

👉 1.0.0 marks the first stable release, when the MAJOR/MINOR/PATCH compatibility rules come into play.

❓Can I Use Date-Based Versions Such as 2024.03.01 Instead?​

✅ Yes, but they serve a different purpose.

This is called CalVer (Calendar Versioning). It suits products that iterate quickly, such as Ubuntu and Chrome. If your project emphasizes API stability and compatibility, SemVer is better suited to communicating the intent of changes.

📌 CalVer tells you when something was released, but not what kind of change it contains. SemVer does the reverse.

🧩 What Do ^ and ~ Mean in npm?​

These symbols specify acceptable version ranges.

SymbolMeaningExample
^The latest version within the same major^1.2.3 → up to 1.999.999
~The latest version within the same minor~1.2.3 → 1.2.x

✅ ^ allows a wider range than ~. Choose based on how tightly you want to control updates.

🔐 What Does package-lock.json Do?​

Even when package.json specifies a range such as ^1.2.3, installation uses the exact versions recorded in package-lock.json.

This helps you:

  • Keep the team's installed package versions consistent.
  • Avoid mysterious bugs where something works today and breaks tomorrow.

📌 If you delete the lockfile and reinstall, you may get different versions.


Closing Thoughts​

Once our code is used by teammates, an open-source community, or even our future selves, versioning becomes part of the fundamentals. I hope this article gives you a clearer sense of how Semantic Versioning works in practice, whether you're maintaining packages, designing a component library, or simply curious about the thinking behind version numbers.

If you maintain a package or component library, or just want to take better care of a side project, try giving your next release a little more thought: which number should this change actually bump?


Reference​