Learn/Advanced Topics

JSON Migration & Versioning — Evolving Schemas Without Breaking Clients

Every successful API eventually needs to change its JSON response shape. Adding a field is easy. Renaming one breaks everything. This guide covers how to evolve JSON schemas safely, version APIs strategically, and migrate clients without downtime.

Breaking vs Non-Breaking Changes

Change Impact Classification
ChangeImpactExample
Add optional fieldNon-breaking+ "avatar_url": "https://..."
Add required fieldBreaking for consumers that validate strictly+ "tenant_id": "..." (required)
Remove fieldBreaking- "legacy_id" removed
Rename fieldBreaking"name" → "full_name"
Change typeBreaking"age": "30" → "age": 30
Nest flat fieldBreaking"city" → "address": {"city": "..."}
Change enum valuesBreaking"status": "active" → "enabled"
Add enum valuePotentially breaking"role": "viewer" (new value)

API Versioning Strategies

1. URL Path Versioning (Most Common)

1GET /v1/users/42 → {"name": "Alice", "email": "[email protected]"}
2GET /v2/users/42 → {"fullName": "Alice Chen", "email": "[email protected]", "role": "admin"}

Tip

URL path versioning is the most developer-friendly approach. It is visible, cacheable, and easy to test with curl. Used by GitHub, Stripe, Twilio, and most major APIs.

2. Header Versioning

1GET /users/42
2Accept: application/vnd.myapi.v2+json
3
4# Or custom header
5GET /users/42
6X-API-Version: 2

3. Date-Based Versioning (Stripe Pattern)

1GET /users/42
2Stripe-Version: 2026-04-02
3
4# API key is pinned to a version at creation time
5# Requests without explicit version use the pinned version
StrategyProsConsUsed By
URL path (/v2/)Visible, cacheable, simpleDuplicates routesGitHub, Twilio
HeaderClean URLs, flexibleHarder to test, not cacheableAzure, some internal APIs
Date-basedGranular, automatic pinningComplex to implementStripe
Query param (?v=2)Easy to addPollutes URLs, cache issuesGoogle Maps (legacy)

Migration Patterns

1. Parallel Fields (Transition Period)

Keep both old and new field names during a transition window:

v1.5 — transitional responsejson
1{
2 "name": "Alice Chen",
3 "fullName": "Alice Chen",
4 "email": "[email protected]",
5 "_deprecations": {
6 "name": "Use 'fullName' instead. 'name' will be removed on 2026-09-01."
7 }
8}

2. Response Transformer

Version-aware serializertypescript
1interface User {
2 id: number;
3 fullName: string;
4 email: string;
5 role: string;
6}
7
8function serializeUser(user: User, version: number) {
9 if (version === 1) {
10 return {
11 id: user.id,
12 name: user.fullName, // v1 clients expect "name"
13 email: user.email,
14 };
15 }
16
17 return {
18 id: user.id,
19 fullName: user.fullName,
20 email: user.email,
21 role: user.role,
22 };
23}

3. JSON Migration Script

Data migration from v1 to v2typescript
1interface V1User {
2 name: string;
3 email: string;
4 address: string; // flat string in v1
5}
6
7interface V2User {
8 fullName: string;
9 email: string;
10 address: { // structured object in v2
11 street: string;
12 city: string;
13 country: string;
14 };
15}
16
17function migrateV1ToV2(v1: V1User): V2User {
18 const parts = v1.address.split(', ');
19 return {
20 fullName: v1.name,
21 email: v1.email,
22 address: {
23 street: parts[0] ?? '',
24 city: parts[1] ?? '',
25 country: parts[2] ?? '',
26 },
27 };
28}

JSON Schema Composition for Versioning

v1.schema.jsonjson
1{
2 "$id": "https://api.example.com/schemas/user/v1",
3 "type": "object",
4 "required": ["id", "name", "email"],
5 "properties": {
6 "id": { "type": "integer" },
7 "name": { "type": "string" },
8 "email": { "type": "string", "format": "email" }
9 }
10}
v2.schema.json — extends v1json
1{
2 "$id": "https://api.example.com/schemas/user/v2",
3 "allOf": [
4 { "$ref": "v1.schema.json" },
5 {
6 "required": ["role"],
7 "properties": {
8 "fullName": { "type": "string" },
9 "role": { "type": "string", "enum": ["admin", "editor", "viewer"] },
10 "avatar_url": { "type": "string", "format": "uri" }
11 }
12 }
13 ]
14}

Deprecation Lifecycle

API Deprecation Timeline
Deprecation headerstext
1HTTP/1.1 200 OK
2Content-Type: application/json
3Deprecation: true
4Sunset: Sat, 01 Sep 2026 00:00:00 GMT
5Link: <https://api.example.com/v2/users>; rel="successor-version"
6
7{"name": "Alice", ...}

Best Practices

  • Default to additive changes — add new fields, never remove or rename
  • Use contract testing to detect breaking changes automatically in CI
  • Provide a migration guide with every version bump
  • Include deprecation headers (Sunset, Deprecation) on old versions
  • Support at least 2 versions simultaneously during transition
  • Log which clients are still using deprecated versions
  • Don't rename fields — add the new name and deprecate the old
  • Don't change field types without a new version

Try It — Validate a Versioned Response

Try It Yourself

A transitional response with both old and new field names

Frequently Asked Questions

What is a breaking change in a JSON API?
A breaking change is any modification that causes existing clients to fail: removing a field, changing a field type (string → number), renaming a field, changing the structure (object → array), or altering enum values. Adding new fields is generally non-breaking.
Should I version my JSON API with URL paths or headers?
URL path versioning (/v2/users) is the most common and easiest for clients. Header versioning (Accept: application/vnd.api+json;version=2) is cleaner but harder to test. Content-type versioning is most RESTful but least practical. Most teams use URL path versioning.
How do I maintain two versions of a JSON API simultaneously?
Use a versioned router that dispatches to different handlers per version. Share common business logic and only branch at the serialization layer. Add deprecation headers to older versions. Set a sunset date and communicate it clearly in documentation.
What is JSON Schema composition for versioning?
Use JSON Schema $ref, allOf, and oneOf to compose versioned schemas from shared definitions. A v2 schema can extend v1 with additional fields using allOf: [{$ref: "v1.schema.json"}, {properties: {newField: ...}}]. This keeps schemas DRY and makes the evolution visible.
How do Stripe and GitHub handle API versioning?
Stripe uses date-based versioning (2026-04-02). Each API key is pinned to a version and requests include a Stripe-Version header. GitHub uses URL path versioning but also Accept headers for preview features. Both maintain backward compatibility by defaulting to the version the client was created with.