LabHub

Blog

API Versioning & Evolution Strategy Complete Guide 2025: Breaking-Change-Free API Evolution, Deprecation, Sunset

한국어English日本語

TL;DR


1. The Intrinsic Difficulty of API Evolution

1.1 The Fate of Public APIs

"Once an API is public, it lives forever." — Hyrum's Law

Internal code can be refactored freely. External APIs are different:

1.2 Types of Changes

ChangeImpactCompatibility
Add new endpointNoneYes
Add new response fieldNoneYes
Add optional fieldNoneYes
Remove response fieldBreakingNo
Rename fieldBreakingNo
Change field typeBreakingNo
Add required fieldBreakingNo
Change error code meaningBreakingNo
Change default behaviorBreakingNo

Rule: Adding is safe, removing/changing is dangerous.

1.3 The Cruelty of Hyrum's Law

"With a sufficient number of users of an API, any observable behavior will be depended on by somebody."

Real examples:

Conclusion: "It's fine to change what's not in the official docs" is wrong. Every observable behavior is part of the API.


2. Four Versioning Strategies

2.1 URL Path Versioning

GET /v1/users/123
GET /v2/users/123

Pros:

Cons:

Adopters: GitHub (/v3/), Twitter, Stripe (URL is /v1/ but actual version via header)

2.2 Header Versioning

GET /users/123 HTTP/1.1
Stripe-Version: 2024-04-15

Pros:

Cons:

Adopters: Stripe (most famous), Azure

2.3 Content Negotiation

GET /users/123 HTTP/1.1
Accept: application/vnd.example.user.v2+json

Pros:

Cons:

Adopters: GitHub (optional, Accept: application/vnd.github.v3+json)

2.4 Query Parameter

GET /users/123?version=2
GET /users/123?api_version=2024-04-15

Pros:

Cons:

Adopters: Some simple APIs

2.5 Comparison Table

ApproachClean URLDebuggingCacheAdopters
URL PathNoExcellentExcellentGitHub, Twitter
HeaderExcellentPoorGoodStripe, Azure
Content NegotiationExcellentOKExcellentGitHub (optional)
Query ParamOKExcellentGoodSimple APIs

3. Stripe's Ingenious Date-Based Versioning

3.1 Core Idea

Stripe expresses versions as dates:

Every change is identified by a date.

3.2 Client Usage

import stripe

# Use account default version
stripe.api_version = "2024-04-15"

# Or per-request
stripe.Charge.create(
    amount=2000,
    currency="usd",
    api_version="2023-10-16"  # Old behavior
)

3.3 Stripe's Secret — the Transformation Layer

The server has exactly one codebase (the latest version).

For each request:

  1. Check client version
  2. Transform request to latest version (forward transform)
  3. Process
  4. Transform response to client version (backward transform)
Client (v2020)[transform] → latest code → [transform]Client (v2020)

Effects:

3.4 Transformation Example

v2020 to v2024 change: added description field to response.

# Transformation function
def transform_to_v2020(response):
    if "description" in response:
        del response["description"]  # v2020 clients don't know this field
    return response

v2020 to v2024 change: amount changed from integer to object.

def transform_to_v2020(response):
    if isinstance(response.get("amount"), dict):
        response["amount"] = response["amount"]["value"]
    return response

3.5 Stripe's Changelog

Each version change is documented precisely:

2024-04-15

  • Added description field to Charge object
  • amount field type changed from integer to AmountObject
  • Default currency is now derived from account settings

Migration guide: ...

This level of transparency is the core of trust.


4. GraphQL's Versionless Evolution

4.1 Core Philosophy

GraphQL doesn't use versions. Instead, it evolves field by field:

type User {
  id: ID!
  name: String!
  
  # Deprecated field
  email: String @deprecated(reason: "Use 'emailAddress' instead. Will be removed 2025-12-31")
  emailAddress: String!
}

4.2 Clients Request Exactly What They Need

query {
  user(id: "123") {
    id
    name
    emailAddress  # Request only the new field
  }
}

Existing clients keep requesting email and continue working. New clients use emailAddress.

No over-fetching = adding new fields is free.

4.3 Deprecation Tracking

The GraphQL server collects usage statistics:

Apollo Studio and Hasura Cloud provide this functionality.

4.4 GraphQL's Limits

Adopters: GitHub, Shopify, Twitter, Airbnb


5. Semantic Versioning and APIs

5.1 SemVer Basics

MAJOR.MINOR.PATCH
v1.2.3

5.2 Library vs API

Library: SemVer fits naturally.

Web API: hard to apply.

Reality: Web APIs usually expose only major versions (/v1, /v2).

5.3 SemVer's Limits

When v2.0.0 ships, every user must migrate. Incremental evolution is hard.

Stripe's approach (date-based) or GraphQL's approach (versionless) is more elegant.


6. Deprecation and Sunset

6.1 Deprecation Stages

  1. Announce: blog, email, changelog
  2. Mark in API: response header or field
  3. Monitor: track usage
  4. Reminder: notify users directly
  5. Sunset: retire (HTTP 410 Gone)

6.2 Deprecation Headers

RFC 8594: the HTTP Sunset header

HTTP/1.1 200 OK
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
Deprecation: Sat, 31 Dec 2024 23:59:59 GMT
Link: <https://api.example.com/docs/migration>; rel="deprecation"

Meaning:

6.3 Deprecation Messages (Response Body)

{
  "data": {...},
  "warnings": [
    {
      "code": "DEPRECATED_FIELD",
      "message": "Field 'email' is deprecated. Use 'emailAddress' instead.",
      "documentation_url": "https://api.example.com/docs/v2#email-deprecation",
      "sunset_date": "2025-12-31"
    }
  ]
}

6.4 Notifying Users

Technical:

Communication:

Stripe: sends automatic email about deprecated APIs in use.

6.5 Sunset Policy Example

UserSunset Period
Free users6 months
Paid users1 year
Enterprise2 years

Larger companies need more time to adapt.


7. Strategies to Avoid Breaking Changes

7.1 Prefer Additive Changes

Wrong change:

- "user_email"
+ "email"

Right change:

+ "email"  // add new field
  "user_email"  // keep old field (deprecated)

Return both fields together. Gives clients time to migrate.

7.2 New Endpoint vs Changing Existing

Bad: change the response shape of existing /users.

Good: new /v2/users endpoint, or /users?format=new.

7.3 Be Careful with Defaults

// v1
{ "page_size": 20 }  // default 20

// v2 — change to 50?
{ "page_size": 50 }  // Breaking! (changes pagination behavior)

Default-value changes are often breaking.

7.4 Optional → Required Is Breaking

- email: string?  // optional
+ email: string   // required

If existing clients don't send email, they fail. Don't add it.

7.5 Is Adding Enum Values Safe?

enum Status {
  ACTIVE,
  INACTIVE,
+ PENDING_REVIEW  // new value
}

Subtle: if the client handles the enum with a switch, a default case is required for the new value. If present, safe; if not, subtle bug.

Advice: adding enum values is technically backward compatible, but requires client code review.


8. Real-World API Evolution Case Studies

8.1 Stripe — the Elegance of Date-Based

8.2 GitHub — REST to GraphQL

Lesson: leave the old API alone, start a new paradigm separately.

8.3 Twilio — Major Version + Gradual Migration

8.4 Slack — Gradual Deprecation

8.5 AWS — Almost Never Breaks


9. Best Practice Checklist

9.1 Design Phase

9.2 On Change

9.3 At Sunset


10. The Future of API Evolution

10.1 OpenAPI 3.1 + JSON Schema

Automatic compatibility checks via schema:

10.2 AI-Based Migration

10.3 Standardizing Contract Testing


Quiz

1. What is the most common breaking change?

Answer: removing or renaming a response field. If clients are using that field, they break immediately. Safe alternative: add a new field and mark the old one deprecated (return both). Remove after a period. Other common breaking changes: field type changes (string to object), adding required fields, changing defaults, changing the meaning of enum values.

2. What are the advantages of Stripe's date-based versioning?

Answer: (1) Incremental migration — clients adopt new versions at their own pace, (2) Single codebase — the server maintains only the latest version and supports old clients via a transformation layer, (3) Clear changelog — the changes on each date are documented precisely, (4) Easy to test — you can explicitly test a specific-date version. The downside is that implementing the transformation layer is complex.

3. Why doesn't GraphQL use versions?

Answer: In GraphQL, clients request exactly the fields they need, so adding a new field has no impact on existing clients — they don't request it. Field removal is marked with @deprecated, and usage statistics are tracked so removal is safe. Result: an API that evolves forever without versions. Downside: not every change is compatible (type changes, enum value removal, etc.).

4. What is the role of the Sunset header?

Answer: an RFC 8594 standard HTTP header that tells clients "when this resource will be retired". Sunset: Sat, 31 Dec 2025 23:59:59 GMT. Clients can see this header and automatically recognize the migration schedule. Used together with Deprecation and Link headers (migration guide). It lets automated clients respond safely to the retirement schedule.

5. What does Hyrum's Law mean for API design?

Answer: "With a sufficient number of users of an API, any observable behavior will be depended on by somebody." In other words, you can't change things even if they aren't in the official documentation. Response field ordering, exact error message text, response time, ID sequentiality — all become "part of the API". Conclusions: (1) design carefully from day one, (2) consider every observable behavior when changing, (3) explicitly document unintended behavior as "do not depend on this behavior".


References

Comments

No comments yet.

Sign in to leave a comment