Loading learning content...
Versioning only has meaning when you can distinguish between changes that require a new version and changes that don't. This distinction—between breaking and non-breaking changes—is the most critical skill in API lifecycle management.
Breaking changes violate the existing contract and may cause consumer integrations to fail. They must be isolated in new versions.
Non-breaking changes extend or improve the API without violating existing contracts. They can be deployed within the current version safely.
The challenge is that this classification isn't always obvious. What seems like a minor tweak can be breaking. What seems like a major addition might be perfectly safe. This page provides the comprehensive framework you need to make these distinctions confidently.
Here's the crucial asymmetry: you can never un-break something. Once a breaking change reaches consumers in a released version, the damage is done. Therefore, the default should be to assume a change is breaking until you've thoroughly analyzed why it isn't. Err on the side of caution.
Before classifying changes, we need to understand what constitutes the 'contract' that breaking changes violate.
The Explicit Contract:
This is what you've formally documented and promised:
The Implicit Contract:
This is more dangerous—behaviors that aren't documented but that consumers depend on:
Hyrum's Law:
Google engineer Hyrum Wright articulated a principle that every API designer should internalize:
"With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody."
This means consumers will depend on any consistent behavior, documented or not. A timestamp format, the order of array elements, the presence of whitespace—if it's consistent, someone will depend on it.
The best defense against implicit contracts becoming breaking changes is to explicitly document what is NOT guaranteed. For example: 'Field ordering in responses is not guaranteed and may change without notice.' This lets you freely modify unguaranteed aspects without violating explicit promises.
| Contract Type | Example | Modification Risk |
|---|---|---|
| Explicit (documented) | Response includes user.id field of type integer | Breaking if removed/changed |
| Implicit (behavioral) | user.tags array returns in creation order | Potentially breaking if changed |
| Incidental (random) | Request takes ~200ms to complete | Generally safe to change |
| Explicitly disclaimed | 'Field ordering may change' | Safe to change |
Some changes are unambiguously breaking—they will cause client integrations to fail immediately. Any of the following changes MUST go into a new major version:
user_id becoming userId breaks existing requests/users/{id}/posts becoming /users/{id}/articlesuser.email break when it's goneid from integer to UUID stringcreated_at to createdAtuser.email to user.contact.email1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// ❌ BREAKING: Field Removal// Before:{ "user": { "id": 123, "name": "John Doe", "email": "john@example.com" // Removed in new version }} // After (BREAKING - clients expecting 'email' will break):{ "user": { "id": 123, "name": "John Doe" }} // ❌ BREAKING: Field Type Change// Before:{ "order": { "id": 12345, // Integer "total": 99.99 }} // After (BREAKING - clients parsing integer will break):{ "order": { "id": "ord_a1b2c3", // String (UUID) "total": 99.99 }} // ❌ BREAKING: Structure Change// Before:{ "user": { "address": "123 Main St, City, 12345" // Single string }} // After (BREAKING - clients expecting string will break):{ "user": { "address": { // Now an object "street": "123 Main St", "city": "City", "zip": "12345" } }}These changes are breaking regardless of how you communicate them. Even with advance warning, extensive documentation, and migration guides—the technical reality remains: existing client code will fail. Always isolate these changes in a new major version.
Some changes are unambiguously safe and can be deployed within the current version without risk to existing consumers:
12345678910111213141516171819202122232425262728293031323334353637383940
// ✅ NON-BREAKING: Adding new fields// Before:{ "user": { "id": 123, "name": "John Doe" }} // After (safe - clients can ignore new fields):{ "user": { "id": 123, "name": "John Doe", "avatar_url": "https://cdn.example.com/123.jpg", // New field "created_at": "2024-01-15T10:30:00Z" // New field }} // ✅ NON-BREAKING: Adding optional parameters// Before:// POST /search// { "query": "shoes" } // After (safe - existing requests still work):// POST /search // { "query": "shoes", "filters": { "size": 10 } } // Optional new param// { "query": "shoes" } // Still works without filters // ✅ NON-BREAKING: Adding new endpoints// Before: /users, /products // After (safe - new endpoint doesn't affect existing ones):// /users, /products, /reviews // New endpoint added // ✅ NON-BREAKING: Loosening validation// Before: username must be 5-20 characters // After (safe - existing valid inputs still valid):// username must be 3-50 characters // Accepts more inputsThe general principle: adding is safe, removing is breaking, changing is breaking. If you only add new capabilities without modifying existing ones, you're generally safe. This is why forward-thinking API design leaves room for extension.
Between obvious breaking and obvious non-breaking changes lies a gray zone—changes that might break some consumers depending on implementation details. These require careful analysis:
Adding New Enum Values:
Seems safe, but can break clients that use exhaustive switch statements:
// Client code with exhaustive matching
switch (order.status) {
case 'pending': return handlePending();
case 'completed': return handleCompleted();
case 'cancelled': return handleCancelled();
default: throw new Error('Unknown status!'); // BREAKS with new status
}
If you add a new enum value like 'refunded', clients with exhaustive handling will fail. Well-designed clients should handle unknown values gracefully, but many don't.
Recommendation: Document that enums may expand. Add new values in minor versions but announce them clearly.
Changing Default Values:
If an optional parameter's default changes, clients relying on the default behavior experience different results:
// Before: GET /items?limit defaults to 10
// After: GET /items?limit defaults to 20
Clients expecting 10 items now get 20. Depending on their pagination logic, this could cause issues.
Recommendation: Treat default value changes as potentially breaking. Consider whether any consumer could reasonably depend on the current default.
Changing Null Semantics:
The difference between 'field absent' and 'field is null' matters to some implementations:
// Before: field absent if no value
{ "user": { "name": "John" } } // email absent
// After: field present but null
{ "user": { "name": "John", "email": null } } // email is null
Some parsing frameworks treat these differently.
Recommendation: Be consistent and document your null semantics. Changes here may be breaking for some consumers.
| Change | Breaking For | Safe For | Recommendation |
|---|---|---|---|
| Adding enum values | Exhaustive matchers | Tolerant parsers | Document enum expansion; warn before adding |
| Changing defaults | Clients relying on defaults | Clients specifying explicitly | Treat as potentially breaking |
| Null vs absent | Strict null checkers | Lenient parsers | Be consistent; document policy |
| Changing field ordering | Order-dependent parsers | Most JSON parsers | Disclaim ordering; test before changing |
| Changing string formats | Strict validators | Lenient parsers | Consider breaking; prefer new fields |
| Changing precision | High-precision use cases | Display-only use cases | Document precision guarantees |
If a change falls into the gray zone and you're unsure whether it will break consumers, treat it as breaking. The cost of an unnecessary version bump is far lower than the cost of breaking production integrations.
A subtle but important distinction exists between changes to the API's structure (what it looks like) and its behavior (what it does). Both can be breaking.
Structural Changes:
These affect the 'shape' of requests and responses:
Structural changes are usually obvious—the API literally looks different.
Behavioral Changes:
These change what the API does without changing its appearance:
Behavioral changes are insidious because the API appears unchanged:
// Request/response structure identical before and after
POST /orders
{ "product_id": 123, "quantity": 2 }
// But behavior changed:
// Before: Allowed any quantity
// After: Rejects quantity > 10 with validation error
The structural contract is preserved, but consumers expecting to order 50 items now receive errors.
Bug fixes present a philosophical challenge: if the API was 'wrong' according to documentation but consumers built on the buggy behavior, is fixing the bug breaking? Technically yes, practically it depends. For minor discrepancies, document the fix and monitor. For major behavior changes, consider a new version or a feature flag to opt into correct behavior.
Semantic Versioning (SemVer) provides a formal framework for communicating change types through version numbers:
MAJOR.MINOR.PATCH
│ │ │
│ │ └─ Bug fixes, no API changes
│ └─ New features, backward compatible
└─ Breaking changes
Applying SemVer to APIs:
MAJOR (X.y.z): Increment when making breaking changes
MINOR (x.Y.z): Increment when adding backward-compatible functionality
PATCH (x.y.Z): Increment for backward-compatible fixes
Practical API Versioning:
Most APIs don't expose full SemVer to consumers. Instead:
/v1/, /v2/This simplifies the consumer experience while maintaining semantic precision internally.
123456789101112131415161718192021222324252627282930313233
# API Changelog (Internal SemVer, Public Major Only) ## v2 (Public API Version) ### 2.3.0 (2024-06-15) - Minor- Added: `user.preferences` field in GET /users/{id}- Added: Optional `timezone` parameter in POST /events- Added: GET /analytics/overview endpoint ### 2.2.1 (2024-05-20) - Patch- Fixed: Improved rate limiting accuracy- Fixed: Documentation typo in error codes ### 2.2.0 (2024-04-10) - Minor- Added: `cursor` pagination option alongside offset- Added: `archived` status to order enum ## v1 (Public API Version) - DEPRECATED ### 1.15.0 (2024-03-01) - Minor (final v1 release)- Added: Deprecation warnings in response headers- Note: v1 sunset date: 2024-09-01 --- # Breaking Changes Log (Major Version Decisions) ## v1 → v2 Breaking Changes- REMOVED: `user.legacy_id` field- CHANGED: `order.id` from integer to UUID string - CHANGED: Error response format (code → error_code)- REMOVED: Basic Authentication support- CHANGED: Rate limit from 100/min to 60/minMaintain a meticulous changelog that classifies every change by type. This serves as both documentation for consumers and a forcing function for your team to think carefully about change classification. If you can't clearly categorize a change, you haven't thought it through.
The best way to handle breaking changes is to need fewer of them. Good API design anticipates evolution and leaves room for extension.
Response Envelopes:
Wrap responses in extensible envelopes:
{
"data": { ... actual data ... },
"meta": { ... pagination, timestamps, etc. ... },
"links": { ... HATEOAS links ... }
}
This structure lets you add new top-level concerns (error details, rate limit info, deprecation notices) without touching the data payload.
Prefer Objects Over Primitives:
// Problematic:
{ "avatar": "https://cdn.example.com/123.jpg" }
// Better:
{ "avatar": { "url": "https://cdn.example.com/123.jpg" } }
The object form allows adding width, height, thumbnail_url later without restructuring.
Use Explicit Null and Omission Policies:
Document whether absent means 'not applicable', 'unknown', 'null', or 'not requested'. This lets you add new optional fields without ambiguity.
Design Extensible Status/Type Enums:
// In documentation:
// "status" can be: "active", "inactive", "pending"
// Note: status values may be added in future versions;
// clients should handle unknown statuses gracefully.
Version Your Content Types Early:
Even if not using content negotiation for versioning, include version hints:
Content-Type: application/json; api-version=2
Understanding breaking vs non-breaking changes is central to API versioning. Let's consolidate the key insights:
What's Next:
Even with perfect classification, breaking changes will eventually be necessary. The final page of this module covers deprecation handling—how to communicate, transition, and eventually retire old API versions while minimizing disruption to your consumers.
You now have a comprehensive framework for classifying API changes. This skill is essential for maintaining consumer trust while evolving your API over time. Next, we'll cover the equally important topic of gracefully deprecating and retiring old versions.