Semantic Versioning Explained: MAJOR, MINOR, PATCH and Why It Matters in CI/CD
Every time you run 'npm install react' or 'pip install requests', some machine somewhere picks a specific version of that package to give you. If that choice is wrong — say it grabs a newer version that changed how a function works — your app breaks at 2 AM on a Friday. Version numbers are the guardrails that prevent this chaos, and semantic versioning is the agreed-upon language that makes those numbers actually mean something across the entire software industry.
Before semantic versioning became the standard, version numbers were a free-for-all. One team might call their releases 1, 2, 3. Another might use dates like 20240101. A third might just pick numbers that felt right. The result was that you could never know whether upgrading from version 4 to version 5 of a library would silently break your code or work perfectly. Developers wasted enormous amounts of time reading full changelogs just to decide whether an upgrade was safe. Semantic versioning — often abbreviated as SemVer — solved this by assigning a strict, shared meaning to each part of a version number.
By the end of this article you'll know exactly what each number in a version like '3.14.2' means, when to bump which number in your own projects, how automated CI/CD pipelines use SemVer to decide whether to deploy or block a release, and the most common mistakes teams make that cause production incidents. You won't just understand the rules — you'll understand the reasoning behind them.
The Three-Number System: What MAJOR.MINOR.PATCH Actually Means
A semantic version is always written as three numbers separated by dots: MAJOR.MINOR.PATCH. Think of it like a home address with three levels of specificity — country, city, street. Each level tells you something different about how much has changed.
PATCH (the last number) is the smallest change. It means 'we fixed a bug, nothing else moved.' If your code works with version 2.4.5, it will work identically with 2.4.9. Safe to upgrade, no reading required.
MINOR (the middle number) means 'we added something new, but we didn't break anything old.' Your existing code still works exactly as before, but there are new features you can optionally use. Going from 2.4.5 to 2.7.0 is safe.
MAJOR (the first number) is the alarm bell. It means 'we changed something fundamental — old code may break.' Going from version 2.x.x to 3.0.0 requires you to read the migration guide, update your code, and test carefully. This is called a 'breaking change'.
The rule that ties it all together is called backward compatibility. MINOR and PATCH bumps must always be backward compatible — meaning they can never remove or rename existing features. Only a MAJOR bump is allowed to break that contract.
# This file illustrates which version number to bump # in common real-world scenarios. # Imagine your library is currently at version 2.4.5 current_version: "2.4.5" scenarios: # SCENARIO 1: You found a null-pointer bug in the login module and fixed it. # Nothing was added or removed. Behavior is identical except the bug is gone. # ACTION: Bump the PATCH number. Reset nothing else. bug_fix: change_description: "Fixed null pointer exception in AuthService.validateToken()" old_version: "2.4.5" new_version: "2.4.6" # PATCH went 5 -> 6. MAJOR and MINOR stay the same. is_breaking_change: false added_new_feature: false # SCENARIO 2: You added a new optional method getUserProfile() to the UserService. # Existing code that doesn't call getUserProfile() is completely unaffected. # ACTION: Bump the MINOR number. Reset PATCH to 0 (fresh slate for this feature). new_feature: change_description: "Added UserService.getUserProfile() method" old_version: "2.4.5" new_version: "2.5.0" # MINOR went 4 -> 5. PATCH resets to 0. is_breaking_change: false added_new_feature: true # SCENARIO 3: You renamed the core authenticate() method to login() AND # changed the return type. Any code calling authenticate() will now crash. # ACTION: Bump the MAJOR number. Reset MINOR and PATCH to 0. breaking_change: change_description: "Renamed authenticate() to login(), changed return type from bool to AuthToken" old_version: "2.4.5" new_version: "3.0.0" # MAJOR went 2 -> 3. MINOR and PATCH both reset to 0. is_breaking_change: true added_new_feature: false # THE RESET RULE (critical to remember): # When you bump MAJOR -> reset MINOR and PATCH to 0 # When you bump MINOR -> reset PATCH to 0, leave MAJOR alone # When you bump PATCH -> leave MAJOR and MINOR alone
# The version transitions shown are:
# Bug fix: 2.4.5 -> 2.4.6
# New feature: 2.4.5 -> 2.5.0
# Breaking: 2.4.5 -> 3.0.0
Version 0.x.x — The Special Case Every Beginner Misses
There's a fourth rule that catches almost everyone out: when MAJOR is zero, the normal rules are suspended.
Version 0.x.x means 'this is early development — anything can change at any time without warning.' It's the wild west phase of a project. A MINOR bump during 0.x.x is allowed to be a breaking change, because the library hasn't made a public stability promise yet. The moment you release 1.0.0, you're telling the world: 'this API is stable, and I will honour the SemVer contract going forward.'
This matters enormously in CI/CD pipelines. If your automated dependency updater sees a package jump from 0.8.2 to 0.9.0, it cannot assume that's safe just because only the MINOR number changed. A cautious pipeline should flag 0.x.x upgrades for manual review.
Pre-release labels are another related concept. You can append a hyphen and a label to signal that a version isn't production-ready: '1.0.0-alpha.1', '1.0.0-beta.3', '1.0.0-rc.2'. These come before the official release in terms of precedence — 1.0.0-alpha.1 is older than 1.0.0. A CI/CD pipeline should never automatically deploy a pre-release version to production.
Build metadata can also be appended with a plus sign: '1.0.0+build.20240115'. This is purely informational — two versions that differ only in build metadata are considered equal for the purpose of upgrade decisions.
#!/bin/bash # This script demonstrates how semantic versions are ordered # and how to check them in a CI/CD shell script. # --- PART 1: Understanding version precedence --- # Versions ordered from OLDEST to NEWEST (left to right): # # 1.0.0-alpha.1 # 1.0.0-alpha.2 # 1.0.0-beta.1 # 1.0.0-rc.1 # 1.0.0 <-- official release # 1.0.1 # 1.1.0 # 2.0.0 echo "=== Version Ordering Demo ===" echo "Pre-release tags come BEFORE the official release:" echo " 1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0" echo "" # --- PART 2: A simple bash function to check if a version is a pre-release --- # In CI/CD you often need to block pre-release versions from auto-deploying. is_prerelease_version() { local version_string="$1" # e.g. "1.0.0-beta.2" or "2.3.1" # If the version string contains a hyphen, it's a pre-release. # We use bash's built-in string matching for this check. if [[ "$version_string" == *"-"* ]]; then echo "BLOCKED: '$version_string' is a pre-release — not safe for production auto-deploy." return 1 # non-zero exit code signals 'yes, it is a pre-release' else echo "APPROVED: '$version_string' is a stable release — safe to proceed." return 0 # zero exit code signals 'not a pre-release' fi } # --- Testing the function with different version strings --- is_prerelease_version "2.3.1" # Stable release is_prerelease_version "1.0.0-alpha.1" # Alpha — block it is_prerelease_version "3.0.0-rc.2" # Release candidate — block it is_prerelease_version "1.5.0" # Stable minor release
Pre-release tags come BEFORE the official release:
1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0
APPROVED: '2.3.1' is a stable release — safe to proceed.
BLOCKED: '1.0.0-alpha.1' is a pre-release — not safe for production auto-deploy.
BLOCKED: '3.0.0-rc.2' is a pre-release — not safe for production auto-deploy.
APPROVED: '1.5.0' is a stable release — safe to proceed.
SemVer in CI/CD Pipelines: How Automated Tools Use These Numbers
Understanding the theory is one thing — seeing how CI/CD pipelines act on version numbers is where it becomes powerful in practice.
Package managers like npm, pip, and Cargo use SemVer range specifiers in config files. These are shorthand rules that say 'give me any version that satisfies these constraints.' The caret symbol (^) is the most common: '^2.4.5' means 'give me any version that's at least 2.4.5, but never go to 3.0.0.' The tilde (~) is stricter: '~2.4.5' means 'stay within PATCH updates only — never change the MINOR version.'
Automated tools like Dependabot, Renovate, and GitHub Actions use these rules to decide whether to auto-merge a dependency update or open a pull request for human review. A PATCH bump gets auto-merged. A MINOR bump gets a PR. A MAJOR bump gets a PR with a big warning label.
In your own CI pipeline, you can use SemVer to trigger different deployment strategies. A PATCH release might go straight to production. A MINOR release might go to staging first. A MAJOR release might require a manual approval gate, a feature flag rollout, and a rollback plan.
Conventional Commits is the system that feeds into this automatically. When developers write commit messages using a standard format ('fix:', 'feat:', 'feat!:'), tools like semantic-release can scan those messages, determine the correct version bump, tag the release, and publish to a package registry — all without a human deciding the version number.
# A GitHub Actions CI/CD workflow that: # 1. Runs on every push to main # 2. Reads the new version from package.json # 3. Decides the deployment strategy based on the version bump type # 4. Blocks pre-release versions from reaching production name: SemVer-Aware Deployment Pipeline on: push: branches: - main # Only triggers when code is merged to the main branch jobs: determine_deployment_strategy: name: Analyse Version and Choose Deploy Path runs-on: ubuntu-latest steps: - name: Check out the repository code uses: actions/checkout@v4 with: fetch-depth: 0 # Fetch full git history so we can compare tags - name: Read the current version from package.json id: read_version run: | # Extract the version field from package.json using jq (JSON parser) CURRENT_VERSION=$(jq -r '.version' package.json) echo "Current version: $CURRENT_VERSION" # Store the version so later steps can use it echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - name: Block pre-release versions from production run: | VERSION="${{ steps.read_version.outputs.current_version }}" # If the version contains a hyphen, it's a pre-release (e.g. 1.0.0-beta.1) if [[ "$VERSION" == *"-"* ]]; then echo "ERROR: Pre-release version '$VERSION' cannot be deployed to production." echo "Merge a stable release first." exit 1 # Fail the pipeline — this blocks the deployment entirely fi echo "Version '$VERSION' is stable. Proceeding with deployment." - name: Extract the MAJOR version number id: parse_version run: | VERSION="${{ steps.read_version.outputs.current_version }}" # Split on dots and grab the first segment (MAJOR number) MAJOR_VERSION=$(echo "$VERSION" | cut -d'.' -f1) echo "Major version: $MAJOR_VERSION" echo "major_version=$MAJOR_VERSION" >> $GITHUB_OUTPUT - name: Deploy PATCH or MINOR — straight to production # This step only runs if the major version hasn't changed from the last release # In a real pipeline you'd compare against the previous git tag run: | echo "Non-breaking change detected." echo "Running automated tests, then deploying directly to production..." # In a real workflow: ./scripts/deploy-to-production.sh - name: Notify team of successful deployment run: | VERSION="${{ steps.read_version.outputs.current_version }}" echo "Deployment complete. Released version $VERSION to production." # In a real workflow: post to Slack, PagerDuty, etc.
Current version: 2.4.6
Version '2.4.6' is stable. Proceeding with deployment.
Major version: 2
Non-breaking change detected.
Running automated tests, then deploying directly to production...
Deployment complete. Released version 2.4.6 to production.
# When a pre-release version (e.g. 2.5.0-beta.1) is pushed:
Current version: 2.5.0-beta.1
ERROR: Pre-release version '2.5.0-beta.1' cannot be deployed to production.
Merge a stable release first.
Error: Process completed with exit code 1.
Common Mistakes That Break Teams and Exactly How to Fix Them
Even teams that know the SemVer rules consistently make a handful of mistakes that cause production incidents or dependency hell. Here are the ones worth ingraining as habits.
The first category is wrong-direction bumps — bumping PATCH when you should have bumped MINOR, or worse, bumping MINOR when you should have bumped MAJOR. This is the most dangerous mistake because it silently violates the backward compatibility promise. A downstream team upgrades what looks like a safe PATCH fix and their code breaks.
The second category is forgetting to reset. Releasing version 2.5.7 after a MAJOR breaking change instead of 3.0.0 is a hard-to-notice mistake because 2.5.7 looks reasonable. Always apply the reset rule: MAJOR bump resets MINOR and PATCH to zero.
The third is shipping unstable code as a stable version. Releasing 1.0.0 when the API is still changing weekly sends the wrong signal. Stay on 0.x.x until you're genuinely ready to make the backward-compatibility promise.
Using Conventional Commits and a tool like semantic-release eliminates all three categories by automating the decision. The commit message format determines the bump type, and the tool handles the reset math and publishing. Once you set it up, version numbers become a pipeline output — not a human decision where mistakes happen.
#!/bin/bash # Conventional Commits format: <type>(<optional scope>): <short description> # # Tools like semantic-release scan these commit messages and # automatically determine which version number to bump. # # FORMAT RULES: # fix: -> bumps PATCH (2.4.5 -> 2.4.6) # feat: -> bumps MINOR (2.4.5 -> 2.5.0) # feat!: or a BREAKING CHANGE footer -> bumps MAJOR (2.4.5 -> 3.0.0) echo "=== CORRECT Conventional Commit examples ===" echo "" # PATCH bump: a bug fix git commit -m "fix(auth): resolve null pointer in token validation" # Result: 2.4.5 -> 2.4.6 # PATCH bump: a documentation-only change (no version bump at all) git commit -m "docs(readme): update installation instructions" # Result: no version change # MINOR bump: a new backwards-compatible feature git commit -m "feat(users): add getUserProfile endpoint to UserService" # Result: 2.4.5 -> 2.5.0 # MAJOR bump: exclamation mark after the type signals a breaking change git commit -m "feat!(auth): rename authenticate() to login(), return AuthToken instead of bool" # Result: 2.4.5 -> 3.0.0 # MAJOR bump alternative: use a BREAKING CHANGE footer in a multi-line commit git commit -m "feat(auth): redesign authentication flow BREAKING CHANGE: The authenticate() method has been removed. Callers must migrate to login() which returns an AuthToken object." # Result: 2.4.5 -> 3.0.0 echo "=== WRONG commits that would cause version confusion ===" # WRONG: Using 'fix' for something that actually adds a feature git commit -m "fix(users): add admin role support" # Should be feat! # WRONG: Using 'feat' for a breaking rename git commit -m "feat(auth): rename authenticate to login" # Should be feat! (MAJOR) echo "Tip: if you're unsure between fix and feat, ask:" echo "'Did I add something new?' -> feat" echo "'Did I only repair existing behaviour?' -> fix"
[main 3a1c4f2] fix(auth): resolve null pointer in token validation
-> semantic-release will bump PATCH: 2.4.5 -> 2.4.6
[main 9b2e1a0] docs(readme): update installation instructions
-> no version bump (docs changes are excluded by default)
[main 7d8f3c1] feat(users): add getUserProfile endpoint to UserService
-> semantic-release will bump MINOR: 2.4.5 -> 2.5.0
[main 4e9a2b7] feat!(auth): rename authenticate() to login()
-> semantic-release will bump MAJOR: 2.4.5 -> 3.0.0
=== WRONG commits that would cause version confusion ===
-> 'fix' used for a new feature — pipeline will only do PATCH bump
but consumers get new behaviour. Backward compat confusion!
-> 'feat' used for a breaking change — pipeline does MINOR bump
but code breaks for existing users. Production incident risk!
Tip: if you're unsure between fix and feat, ask:
'Did I add something new?' -> feat
'Did I only repair existing behaviour?' -> fix
| Aspect | MAJOR (x.0.0) | MINOR (1.x.0) | PATCH (1.0.x) |
|---|---|---|---|
| What changed | Breaking API change — old code may crash | New feature added — old code still works | Bug fixed — behaviour unchanged |
| Backward compatible? | No — migration required | Yes — safe to upgrade | Yes — safe to upgrade |
| MINOR resets to 0? | Yes — always | No change needed | No change needed |
| PATCH resets to 0? | Yes — always | Yes — always after MINOR bump | No — just increment |
| CI/CD auto-deploy safe? | No — requires human approval gate | Usually yes, after staging tests | Yes — typically auto-deployed |
| Real example | 2.4.5 → 3.0.0 (renamed core method) | 2.4.5 → 2.5.0 (new optional endpoint) | 2.4.5 → 2.4.6 (fixed login null pointer) |
| npm caret (^) allows it? | No — caret pins the MAJOR version | Yes — caret allows MINOR bumps | Yes — caret allows PATCH bumps |
| Risk level | High — read migration guide first | Low — review changelog | Minimal — nearly always safe |
🎯 Key Takeaways
- MAJOR.MINOR.PATCH is a three-part contract: PATCH = bug fixed, MINOR = feature added safely, MAJOR = something broke — read the migration guide before upgrading.
- The reset rule is non-negotiable: bump MAJOR and both MINOR and PATCH return to zero; bump MINOR and PATCH returns to zero. Skip this and your version history becomes untrustworthy.
- Version 0.x.x is a special wild-west phase with no backward-compatibility guarantee — MINOR bumps can be breaking. The promise doesn't start until 1.0.0.
- Conventional Commits ('fix:', 'feat:', 'feat!:') let CI/CD tools like semantic-release determine the correct version bump automatically, eliminating human error from what is otherwise a surprisingly easy decision to get wrong under deadline pressure.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using 'fix' commits for new features — Symptom: semantic-release only bumps PATCH (e.g. 2.4.5 → 2.4.6) but you've added new behaviour, causing consumers to miss the feature or worse, get unexpected behaviour under what looks like a safe patch — Fix: ask one question before every commit: 'Am I adding something new that didn't exist before?' If yes, it's 'feat:', not 'fix:'. Add this as a checklist item in your PR template.
- ✕Mistake 2: Forgetting to reset lower numbers after a MAJOR bump — Symptom: you release '3.2.7' instead of '3.0.0' after a breaking change, which makes the version history confusing and breaks tooling that expects post-major resets — Fix: remember the reset rule as a rhyme: 'Bump MAJOR, wipe the rest. Bump MINOR, wipe the PATCH.' If you use semantic-release, it handles this automatically.
- ✕Mistake 3: Publishing 1.0.0 too early before the API is stable — Symptom: you're still renaming core methods weekly but you've already released 1.0.0, so every rename forces a MAJOR bump, and after a month you're on version 7.0.0 with no real breaking changes from a user perspective — Fix: stay on 0.x.x until you've used the library in at least one real project and are confident the API design won't fundamentally change. The 0.x.x phase exists precisely for this exploration period.
Interview Questions on This Topic
- QYou're reviewing a pull request and the developer bumped the version from 2.3.1 to 2.4.0. Looking at the diff, they removed a public method from an existing class. What's wrong, and what should the version be?
- QWhat's the difference between the caret (^) and tilde (~) version specifiers in a package.json, and which SemVer rule does each one encode?
- QYour CI/CD pipeline is using Dependabot to auto-merge dependency updates. A junior engineer asks whether it's safe to auto-merge all updates from a package that's still on version 0.8.x. What do you tell them, and why?
Frequently Asked Questions
What is semantic versioning in simple terms?
Semantic versioning is a numbering system for software releases that uses three numbers — MAJOR.MINOR.PATCH — where each number carries a specific promise. PATCH means only a bug was fixed, MINOR means a new feature was added without breaking anything old, and MAJOR means something changed that might break existing code. It's a shared language so developers can instantly judge how risky an upgrade is just from the version number.
When should I increment the major version number?
Increment the MAJOR version any time you make a change that breaks backward compatibility — meaning existing code that works with the old version would fail or behave differently with the new version. Common examples include renaming or removing a public method, changing a function's parameter types, or restructuring how configuration files must be written. If someone upgrading your package would need to change their own code, that's a MAJOR bump.
Why does 1.0.0 matter so much — can't I just start at any version?
You can technically start anywhere, but 1.0.0 carries a specific meaning in SemVer: it signals that your public API is stable and you're committing to the backward-compatibility rules going forward. Anything below 1.0.0 (i.e. 0.x.x) is considered pre-stable, and MINOR bumps in that range are allowed to be breaking changes. Releasing 1.0.0 too early creates an implicit promise you may not be ready to keep, forcing frequent MAJOR bumps that signal instability to users.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.