jsmanifest logojsmanifest

Semantic Versioning: When to Bump Major, Minor, or Patch

Semantic Versioning: When to Bump Major, Minor, or Patch

Learn when to bump major, minor, or patch versions in your npm packages. Real-world examples and decision trees to master semantic versioning and avoid breaking your users' code.

While I was looking over some pull requests the other day, I came across a heated discussion about whether adding a new optional parameter to a function should bump the major or minor version. The team was split, and I realized something: most developers (myself included for the longest time) treat versioning as an afterthought.

Little did I know that getting versioning wrong would cost me hours of debugging production issues. I once released what I thought was a "minor feature addition" that broke three different projects depending on my library. The angry GitHub issues that followed taught me a valuable lesson: version numbers aren't just decoration—they're a contract with your users.

Why Version Numbers Matter More Than You Think

I was once guilty of bumping versions based on how I felt about the changes. Big refactor? Major version! Small tweak? Patch! This approach worked until it didn't. When I finally decided to learn semantic versioning properly, I discovered it's actually a communication protocol between you and everyone depending on your code.

Think about it this way: when you specify "lodash": "^4.17.0" in your package.json, you're saying "give me any version from 4.17.0 up to (but not including) 5.0.0." You're trusting that the maintainers won't break your code within that range. That trust is built on semantic versioning.

The stakes are real. A miscategorized breaking change means your users wake up to broken builds. A feature marked as a patch means automated tools might skip it entirely. Version numbers guide everything from dependency resolution to deployment pipelines to code review processes.

The Three-Number System: Major.Minor.Patch Explained

Semantic versioning follows a simple format: Major.Minor.Patch (like 2.4.7). Each number serves a specific purpose, and understanding this purpose is what separates developers who version confidently from those who guess.

PATCH (the last number): Backward-compatible bug fixes only. Nothing breaks, nothing new is added—you just fixed something that was broken.

MINOR (the middle number): Backward-compatible functionality additions. You can add features, deprecate things (with warnings), or improve performance—but existing code keeps working exactly as before.

MAJOR (the first number): Breaking changes. Anything that requires users to modify their code. This is your "breaking glass" number.

Luckily we can look at concrete examples to make this crystal clear.

Semantic versioning visual guide

When to Bump PATCH: Bug Fixes and Backward-Compatible Changes

I cannot stress this enough! A patch bump means fixing something that was already supposed to work. If your documentation said it should work one way, but it didn't, that's a patch.

Here's a real scenario I encountered. I had this utility function:

function formatCurrency(amount: number): string {
  return `$${amount.toFixed(2)}`
}
 
// Bug: Negative numbers displayed incorrectly
formatCurrency(-42.5) // Returns "$-42.50" instead of "-$42.50"

The fix is clearly a patch bump:

function formatCurrency(amount: number): string {
  const formatted = Math.abs(amount).toFixed(2)
  return amount < 0 ? `-$${formatted}` : `$${formatted}`
}
 
// Now correctly returns "-$42.50"

This is a patch (1.2.3 → 1.2.4) because:

  • The function signature didn't change
  • Existing correct behavior stays the same
  • Only broken behavior is fixed
  • No new features were added

Other classic patch scenarios include fixing memory leaks, correcting typos in error messages, and updating internal dependencies that don't affect the API.

When to Bump MINOR: Adding Features Without Breaking Things

Minor bumps are your bread and butter for evolving libraries. You're adding value without forcing anyone to change their code. When I finally understood this, I started releasing updates way more frequently.

Let's look at a practical example:

// Version 1.2.0 - Original API
interface User {
  id: string
  name: string
}
 
function getUser(id: string): Promise<User> {
  return fetch(`/api/users/${id}`).then(res => res.json())
}
 
// Version 1.3.0 - Added optional caching
interface User {
  id: string
  name: string
}
 
interface GetUserOptions {
  useCache?: boolean
  cacheTime?: number
}
 
function getUser(
  id: string, 
  options?: GetUserOptions
): Promise<User> {
  if (options?.useCache) {
    // Check cache first
  }
  return fetch(`/api/users/${id}`).then(res => res.json())
}

This is a minor bump (1.2.0 → 1.3.0) because:

  • Old code getUser('123') still works exactly the same
  • New functionality is opt-in via the optional second parameter
  • The return type hasn't changed
  • Default behavior is unchanged

I've seen developers worry about adding optional parameters, but this is perfectly safe. The key word is optional. If omitting the parameter gives you identical behavior to the previous version, you're golden.

When to Bump MAJOR: Breaking Changes and API Redesigns

Major bumps are when you break the contract. In other words, existing code will need modifications to work with your new version. I used to avoid major bumps because they felt scary, but sometimes they're necessary for the long-term health of your project.

Here's when you absolutely must bump major:

// Version 1.x.x - Old API
function calculate(x: number, y: number): number {
  return x + y
}
 
// Version 2.0.0 - Breaking change (parameter object)
interface CalculateParams {
  x: number
  y: number
  operation?: 'add' | 'subtract' | 'multiply'
}
 
function calculate(params: CalculateParams): number {
  const { x, y, operation = 'add' } = params
  switch (operation) {
    case 'add': return x + y
    case 'subtract': return x - y
    case 'multiply': return x * y
  }
}
 
// Old code breaks: calculate(5, 3)
// New code required: calculate({ x: 5, y: 3 })

This requires a major bump (1.x.x → 2.0.0) because every single call to calculate() must be updated. There's no backward compatibility here.

Other major bump triggers include:

  • Removing public methods or properties
  • Changing function signatures
  • Renaming exported modules
  • Changing behavior of existing functionality (even if it's a "fix")
  • Dropping support for Node.js versions or browsers

Breaking changes decision tree

Real-World Scenarios: Making the Right Versioning Decision

Let me share some scenarios that tripped me up:

Scenario 1: You fix a bug, but the fix changes observable behavior

I once had a function that was supposed to trim whitespace but didn't. Users started relying on that bug. When I fixed it, I broke their code. Even though it's technically a bug fix, if users might depend on the current behavior, it's a major bump.

Scenario 2: You add a required parameter

Adding any required parameter is a major bump, period. But adding an optional parameter with a sensible default? Minor bump.

Scenario 3: You deprecate something without removing it

Deprecation with a warning? Minor bump. Actually removing it? Major bump. Wonderful! This gives users time to migrate.

Scenario 4: Performance improvements that change timing

If your function returns the same data but 10x faster, and nothing else changes? Patch bump. But if the speed change could affect race conditions or timing-dependent code? I'd argue for a minor bump to be safe.

Scenario 5: Internal refactoring

Refactoring internal code with zero API changes? Patch bump. The rule is simple: if users can't observe the difference (except in bug fixes or performance), it's a patch.

Gray Areas: Deprecations, Bug Fixes That Change Behavior, and Edge Cases

The hardest decisions come in gray areas. I've spent hours debating these with teammates, and here's what I've learned:

Deprecation Strategy: When you deprecate something, add warnings in a minor release. Actually remove it in the next major release. This two-step approach respects your users' time.

Bug Fixes That Feel Like Features: If you fix a bug that makes something work for the first time, but the documentation always said it should work that way? Patch. If the documentation was wrong or unclear? I lean toward minor to be cautious.

Dependency Updates: Updating a dependency that doesn't affect your public API? Patch. But if that dependency has breaking changes that leak into your API? Major bump.

TypeScript Type Changes: Adding stricter types is technically a breaking change for TypeScript users, even if the runtime behavior is identical. I treat these as major bumps because they break type checking.

The rule I follow: when in doubt, bump higher. It's better to over-communicate breaking changes than to surprise users with broken builds. Your users will appreciate the transparency.

Automating Version Bumps in Your CI/CD Pipeline

Manual versioning is error-prone. I used to forget to bump versions entirely or pick the wrong number. Luckily we can automate this with tools that read your commit messages.

Conventional Commits is a game-changer. Structure your commits like:

  • fix: corrected calculation error → patch bump
  • feat: added optional caching → minor bump
  • feat!: changed API to use config object → major bump

Tools like semantic-release or standard-version read these and bump versions automatically. Your CI/CD pipeline can handle releases without human intervention for patches and minors, while gating major bumps behind manual approval.

This has saved me countless hours and eliminated versioning mistakes. The key is consistency in commit messages, which also improves your project's history.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!