versioning
Versioning
Learn how to evolve REST, GraphQL, and gRPC service contracts without unexpectedly breaking existing clients through compatibility rules, version-selection strategies, deprecation policies, migration plans, and automated contract testing.
Introduction
APIs evolve as products gain features, business rules change, security requirements become stricter, and implementation constraints are discovered. However, clients can depend on existing fields, status codes, validation rules, ordering, and behavioural details.
API versioning is the practice of managing contract changes so that clients can understand which API behaviour they are using and migrate between incompatible contracts in a controlled way.
Versioning helps manage changes to:
- Resource representations
- Request and response fields
- Endpoint paths
- Data types and formats
- Validation rules
- Error responses
- Pagination behaviour
- Authentication requirements
- Rate-limit semantics
- GraphQL schemas
- Protocol Buffer messages
- Business behaviour
Core idea: Versioning is not a substitute for backward compatibility. Prefer compatible evolution within an existing version, and introduce a new major contract only when clients cannot continue safely without changing their implementation.
In your System Design curriculum, Versioning is Topic 4.6 under API Design & Service Contracts. It follows pagination and precedes idempotency, authentication versus authorization, and rate-limit semantics.
Prerequisites
| # | Prerequisite | Why It Is Needed |
|---|---|---|
| 1 | REST | REST contracts can use URI, field, media-type, or other version-selection strategies. |
| 2 | RPC and gRPC | Typed service contracts require strict schema-compatibility rules. |
| 3 | GraphQL | GraphQL commonly evolves one schema through additive changes and deprecation. |
| 4 | Resource modeling | Changing resource identity, fields, relationships, or lifecycle can affect clients. |
| 5 | Pagination | Ordering, cursor formats, and page metadata are part of the API contract. |
| 6 | Automated testing | Compatibility should be verified before a changed contract is released. |
What Is an API Version?
An API version identifies a defined set of externally observable contract behaviour.
A version can cover:
- Available operations
- Resource and method names
- Request schemas
- Response schemas
- Field semantics
- Error contracts
- Authentication requirements
- Pagination and ordering
- Retry and idempotency behaviour
- Compatibility guarantees
Versioning vs Backward Compatibility
| Concept | Meaning |
|---|---|
| API versioning | Identifies and manages different forms of an API contract |
| Backward compatibility | Allows existing clients to continue working after a service change |
| Deprecation | Communicates that a supported contract element is planned for replacement or removal |
| Sunsetting | Ends support for a version or contract element according to a communicated policy |
| Migration | Moves clients from one supported contract to another |
Good compatibility discipline reduces how often a new major API version is necessary.
Backward-compatible Changes
A backward-compatible change allows supported existing clients to continue using the API without required code changes.
Commonly compatible changes can include:
- Adding a new optional request field
- Adding a new response field that clients are expected to ignore
- Adding a new endpoint
- Adding a new optional query parameter
- Adding a new GraphQL type or field
- Adding a new Protocol Buffer field with a new field number
- Adding a new optional capability
- Improving performance without changing semantics
Additive REST Change
Existing representation
{
"id": "cus_42",
"displayName": "Example Customer",
"status": "active"
}
Compatible extension
{
"id": "cus_42",
"displayName": "Example Customer",
"status": "active",
"preferredLanguage": "en"
}
This addition is compatible only when existing clients tolerate unknown response fields as required by the API's compatibility policy.
Breaking Changes
A breaking change requires one or more existing clients to modify their implementation to continue working correctly.
Examples include:
- Removing a field
- Renaming a field
- Changing a field's type
- Changing a field's meaning or unit
- Changing an optional field to required
- Tightening validation rules
- Removing an endpoint or method
- Changing an error-response structure
- Changing default pagination ordering
- Reducing an established maximum page size
- Changing authentication requirements unexpectedly
- Changing retry or idempotency behaviour
Type Change
{
"customerId": "42"
}
{
"customerId": 42
}
Even when both values represent the same identifier, changing from a string to a number can break parsing, generated clients, validation, and stored integrations.
Behavioural Breaking Changes
A change can preserve the schema and still break clients by changing behaviour.
Pagination Example
Existing ordering:
createdAt ascending
Changed ordering:
createdAt descending
Schema:
Unchanged
Client impact:
Items appear in a different sequence.
Stored cursors or synchronization logic
can become invalid.
Other behavioural changes can include:
- Changing rounding behaviour
- Changing time-zone interpretation
- Changing null to an empty collection
- Changing deletion from physical deletion to archival
- Changing which errors are retryable
- Changing the meaning of a status value
- Changing consistency guarantees
Review rule: Compatibility reviews must examine semantics and behaviour, not only JSON schemas or method signatures.
Major, Minor, and Patch Versions
Some API programs use semantic-style version numbers to classify releases.
| Component | General Purpose | Example |
|---|---|---|
| Major | Identifies an incompatible contract generation | 2.0.0 |
| Minor | Identifies compatible added functionality | 1.4.0 |
| Patch | Identifies compatible corrections | 1.4.2 |
The externally selected API version does not always need to expose the full
internal release number. For example, clients can select
v1 while documentation and deployments track
1.4.2.
URI Path Versioning
Path versioning places the major version in the URI.
GET /v1/customers/cus_42 HTTP/1.1
Host: api.example.com
GET /v2/customers/cus_42 HTTP/1.1
Host: api.example.com
Benefits
- The selected version is visible
- Requests are easy to test
- Routing rules are straightforward
- Documentation can be separated clearly
- Different major versions can run concurrently
Considerations
- Resource URIs differ between major versions
- Clients must change URLs during migration
- Shared implementation can become duplicated
- Too many active versions increase operational complexity
Header Versioning
Header versioning places the version in a request field.
GET /customers/cus_42 HTTP/1.1
Host: api.example.com
API-Version: 2
A custom header can keep resource paths stable, but the selected contract is less visible in ordinary links and browser navigation.
The contract must define:
- The exact header name
- Supported values
- Behaviour when the header is missing
- Behaviour for unsupported versions
- Cache-key behaviour
- Proxy and gateway forwarding
Media-type Versioning
A representation version can be selected through media-type negotiation.
GET /customers/cus_42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.customer.v2+json
HTTP/1.1 200 OK
Content-Type: application/vnd.example.customer.v2+json
This approach can express representation-level versions while preserving the resource URI. It requires consistent media-type negotiation, documentation, caching, and tooling support.
Query-parameter Versioning
GET /customers/cus_42?apiVersion=2 HTTP/1.1
Host: api.example.com
Query-parameter versioning is visible and simple to test. However, version selection becomes mixed with ordinary resource-query parameters and must be handled consistently by caches, gateways, documentation, and generated clients.
Date-based Versioning
Some services identify a contract using a date.
GET /customers/cus_42 HTTP/1.1
Host: api.example.com
API-Version: 2026-09-22
A date-based contract must define whether the date refers to a release, a compatibility snapshot, or another API behaviour set. A date alone does not explain whether a release contains breaking changes.
Comparing Version-selection Strategies
| Strategy | Example | Main Benefit | Main Consideration |
|---|---|---|---|
| URI path | /v2/orders |
Highly visible and easy to route | Changes public resource URLs |
| Custom header | API-Version: 2 |
Keeps URLs stable | Version is less visible |
| Media type | application/vnd.example.v2+json |
Supports representation negotiation | More complex for clients and tooling |
| Query parameter | ?apiVersion=2 |
Simple to test | Mixes contract selection with resource parameters |
| Date based | 2026-09-22 |
Identifies a contract snapshot | Compatibility meaning requires separate documentation |
Selection rule: Choose one clear version-selection strategy based on client usability, routing, caching, documentation, and lifecycle needs. Apply it consistently across the API.
Missing Version Behaviour
The API should define what happens when a client does not select a version.
Possible policies include:
- Require an explicit version
- Use a documented stable default
- Use the version encoded in the URI
- Reject requests without supported version information
Missing version means:
Use whichever version was deployed most recently.
Missing version means:
Use documented version 1
or reject the request.
The behaviour does not change silently
when version 2 is deployed.
PHP Path-version Routing
<?php
declare(strict_types=1);
$path =
parse_url(
$_SERVER['REQUEST_URI'],
PHP_URL_PATH
);
if (!is_string($path)) {
http_response_code(400);
exit(
'Invalid request URI.'
);
}
$segments =
array_values(
array_filter(
explode(
'/',
trim($path, '/')
),
static fn (
string $segment
): bool => $segment !== ''
)
);
$version =
$segments[0] ?? null;
$supportedVersions = [
'v1',
'v2'
];
if (!in_array(
$version,
$supportedVersions,
true
)) {
http_response_code(404);
header(
'Content-Type: application/problem+json'
);
echo json_encode(
[
'type' =>
'unsupported-api-version',
'title' =>
'Unsupported API version',
'status' => 404,
'detail' =>
'The requested API version is not supported.'
],
JSON_THROW_ON_ERROR
);
exit;
}
$resource =
$segments[1] ?? null;
if ($resource !== 'customers') {
http_response_code(404);
exit;
}
require $version === 'v1'
? 'controllers/v1/CustomerController.php'
: 'controllers/v2/CustomerController.php';
A production router should normalize paths, authorize requests, validate method support, apply observability, and avoid duplicating unchanged business logic between versioned controllers.
PHP Header-version Selection
<?php
declare(strict_types=1);
$requestedVersion =
$_SERVER['HTTP_API_VERSION'] ??
null;
if ($requestedVersion === null) {
http_response_code(400);
header(
'Content-Type: application/problem+json'
);
echo json_encode(
[
'type' =>
'missing-api-version',
'title' =>
'API version is required',
'status' => 400
],
JSON_THROW_ON_ERROR
);
exit;
}
$supportedVersions = [
'1',
'2'
];
if (!in_array(
$requestedVersion,
$supportedVersions,
true
)) {
http_response_code(400);
header(
'Content-Type: application/problem+json'
);
echo json_encode(
[
'type' =>
'unsupported-api-version',
'title' =>
'Unsupported API version',
'status' => 400
],
JSON_THROW_ON_ERROR
);
exit;
}
Separate Contract from Business Logic
Supporting two versions should not require copying the complete application implementation.
Version 1 Controller
|
| Maps v1 request to domain command
v
Shared Application Service
|
v
Domain and Persistence Logic
|
v
Version 1 response mapping
Version 2 Controller
|
| Maps v2 request to domain command
v
Same Shared Application Service
|
v
Domain and Persistence Logic
|
v
Version 2 response mapping
Version-specific adapters can translate between public contracts and shared internal domain operations.
GraphQL Versioning
GraphQL commonly evolves one schema through additive changes and field deprecation rather than introducing a new version for every change.
Additive Field
type Customer {
id: ID!
displayName: String!
status: CustomerStatus!
preferredLanguage: String
}
Deprecated Field
type Customer {
id: ID!
name: String
@deprecated(
reason: "Use displayName."
)
displayName: String!
}
A GraphQL deprecation process should include:
- A clear reason
- A supported replacement
- Operation-usage monitoring
- Client communication
- A documented removal policy
GraphQL Breaking Changes
- Removing a field
- Renaming a field
- Changing a field's output type incompatibly
- Making a nullable output field non-null without a reliable guarantee
- Adding a required input field without a default
- Removing an enum value
- Changing resolver semantics
gRPC and Protocol Buffer Versioning
Protocol Buffer contracts use stable numeric field identifiers. Compatibility depends on preserving field numbers and compatible wire meanings.
Additive Change
message Customer {
int64 id = 1;
string display_name = 2;
CustomerStatus status = 3;
string preferred_language = 4;
}
Reserve Removed Fields
message Customer {
int64 id = 1;
string display_name = 2;
reserved 3;
reserved "legacy_status";
CustomerStatus status = 4;
}
Removed field numbers and names should be reserved to prevent accidental reuse.
Package Versioning
package customer.v1;
package customer.v2;
A new versioned package can be introduced when an incompatible service or message contract is required.
Protocol Buffer Compatibility Rules
- Do not change the meaning of an existing field number.
- Do not reuse a removed field number.
- Reserve removed field names and numbers.
- Add fields with new field numbers.
- Avoid incompatible field-type changes.
- Keep enumeration zero values safe and meaningful as unspecified values.
- Do not assume all clients update simultaneously.
- Run schema-compatibility checks before deployment.
Consumer-driven Compatibility
Schema comparison alone might not identify every client dependency. A consumer can rely on behaviour that remains structurally valid.
Schema says:
status is a string.
Consumer assumes:
status is always active or inactive.
Server adds:
pending_review
Schema remains valid.
Consumer can still fail.
Compatibility testing should include representative consumer expectations, especially for critical internal or partner integrations.
Contract Testing
Automated compatibility checks can verify:
- Removed endpoints
- Removed fields
- Changed field types
- New required inputs
- Changed enum values
- Changed status-code contracts
- Changed pagination defaults
- Changed error schemas
- Protocol Buffer field-number reuse
- GraphQL breaking schema changes
OpenAPI Version Metadata
openapi: 3.1.0
info:
title: Customer API
version: 2.0.0
servers:
- url: https://api.example.com/v2
paths:
/customers/{customerId}:
get:
operationId: getCustomer
parameters:
- name: customerId
in: path
required: true
schema:
type: string
responses:
"200":
description: Customer found
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
components:
schemas:
Customer:
type: object
required:
- id
- displayName
- status
properties:
id:
type: string
displayName:
type: string
status:
type: string
enum:
- active
- inactive
Maintain a separate published contract for every supported major version and verify that deployed runtime behaviour matches it.
Deprecation Lifecycle
Deprecation is a managed process, not merely a label in documentation.
Identify replacement
|
v
Mark old contract as deprecated
|
v
Publish migration guide
|
v
Notify known consumers
|
v
Monitor remaining usage
|
v
Test replacement adoption
|
v
Apply announced retirement policy
|
v
Remove old contract
A deprecation notice should define:
- The affected version or element
- The reason for the change
- The supported replacement
- The migration procedure
- The support policy
- The retirement date when established
- A contact or support channel
Deprecation Response Fields
An API can communicate lifecycle information through response fields in addition to documentation and direct consumer communication.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 30 Sep 2026 23:59:59 GMT
Link: <https://developer.example.com/migrations/v1-to-v2>; rel="deprecation"
Content-Type: application/json
Use dates and links that match the published lifecycle policy. Do not add a retirement date until the organization has approved and can support it.
Migration Guide
A migration guide should focus on concrete client changes.
Field Migration
| Version 1 | Version 2 | Client Change |
|---|---|---|
name |
displayName |
Read and write the new field |
total number |
total.amount string plus currency |
Parse explicit money representation |
| Offset pagination | Cursor pagination | Store and return opaque cursors |
| Unstructured error | Problem-detail error | Read the new error envelope |
A useful migration guide includes:
- Before-and-after requests
- Before-and-after responses
- Field mapping
- Error mapping
- Pagination changes
- Authentication changes
- Code examples
- Validation checklist
- Rollback guidance
Running Versions Concurrently
Clients using v1
|
v
API Gateway
|
+--> v1 Contract Adapter --+
| |
Clients using v2 +--> Shared Application Services
| |
v |
API Gateway |
| |
+--> v2 Contract Adapter --+
Concurrent operation gives clients control over migration timing, but every active version adds implementation, testing, security, documentation, monitoring, and support cost.
Security and Versioning
An older version can remain contractually supported while requiring a security correction.
Security considerations include:
- Applying security patches to every supported version
- Removing unsafe functionality through an approved emergency process
- Preventing version selection from bypassing authorization
- Maintaining consistent credential validation
- Monitoring traffic to unsupported versions
- Preventing deprecated versions from receiving new sensitive features unintentionally
Security rule: Backward compatibility does not require preserving a security vulnerability. Security fixes need a documented risk, communication, and migration process.
Versioning Metrics
Useful lifecycle metrics include:
- Request volume by version
- Active consumers by version
- Error rate by version
- Latency by version
- Deprecated endpoint usage
- Deprecated field usage
- Unsupported-version requests
- Migration completion by known consumer
- Compatibility-test failures
- Security findings by supported version
Version usage must be measured using an authorized caller or application identity where permitted, rather than relying only on raw request volume.
Test Versioned APIs with curl
Path Version
curl -i \
-H 'Accept: application/json' \
https://api.example.com/v1/customers/cus_42
curl -i \
-H 'Accept: application/json' \
https://api.example.com/v2/customers/cus_42
Header Version
curl -i \
-H 'API-Version: 2' \
-H 'Accept: application/json' \
https://api.example.com/customers/cus_42
Media-type Version
curl -i \
-H 'Accept: application/vnd.example.customer.v2+json' \
https://api.example.com/customers/cus_42
Replace example endpoints and credentials with approved test values. Test missing, supported, deprecated, and unsupported version selections.
Versioning Review Workflow
- Document the proposed contract change.
- Compare the new contract with the released contract.
- Classify structural and behavioural compatibility.
- Identify affected consumers.
- Prefer an additive compatible design where possible.
- Introduce a new major contract when incompatibility is necessary.
- Publish updated machine-readable specifications.
- Run provider and consumer contract tests.
- Publish a migration guide and changelog.
- Deploy supported versions according to the lifecycle policy.
- Monitor adoption, errors, and deprecated usage.
- Retire the old version only through the communicated process.
Common Versioning Mistakes
Creating a New Version for Every Change
Compatible additive changes can normally evolve within the current contract without creating another major version.
Making Breaking Changes without a New Contract
Removing or redefining fields can silently break existing clients.
Assuming Schema Compatibility Guarantees Behaviour
Changes to ordering, defaults, validation, timing, or field meaning can break clients without changing the schema.
Defaulting to the Latest Version
Deploying a new version should not silently move clients to a different contract.
Maintaining Versions through Copied Business Logic
Duplicate implementations drift over time. Use version-specific contract adapters around shared application and domain logic where appropriate.
Deprecating without a Replacement
Clients need a supported alternative and a clear migration path.
Removing a Version without Usage Evidence
Monitor known consumers and deprecated traffic before retirement.
Changing Enum Behaviour Carelessly
Existing clients can fail when they assume the documented set of values is permanently closed.
Reusing Protocol Buffer Field Numbers
Old and new clients can interpret identical wire data as different fields.
Changing Pagination Ordering Silently
Default ordering and cursor semantics are part of the collection contract.
Maintaining Unsupported Versions Indefinitely
Every active version adds operational and security cost. Define a clear lifecycle policy.
Updating Documentation after Deployment
The contract, migration guide, tests, and documentation should be ready before clients encounter the changed behaviour.
Recommended Test Cases
| Test | Expected Evidence |
|---|---|
| Supported version | The requested contract version handles the request |
| Missing version | The documented default or explicit error is returned |
| Unsupported version | The request fails through a stable error contract |
| Additive response field | Existing clients continue processing the response |
| Old client against new deployment | The supported client contract remains functional |
| New client against old version | The client handles unavailable capabilities according to contract |
| Deprecated endpoint | The response and documentation communicate deprecation consistently |
| REST contract comparison | Breaking schema changes are detected before release |
| GraphQL schema comparison | Removed or incompatible fields are detected |
| Protocol Buffer comparison | Field-number reuse and incompatible changes are rejected |
| Authorization by version | An older version cannot bypass current security policy |
| Concurrent versions | Each version returns its documented representation |
API Versioning Best Practices
Recommended Practices
- Define a written API compatibility policy.
- Prefer additive backward-compatible changes.
- Create a new major version only for necessary incompatible changes.
- Review behavioural compatibility as well as schema compatibility.
- Use one consistent version-selection strategy.
- Define missing and unsupported version behaviour.
- Do not silently move clients to the latest version.
- Maintain a machine-readable contract for every supported major version.
- Separate version-specific adapters from shared business logic.
- Automate REST, GraphQL, and Protocol Buffer compatibility checks.
- Run consumer contract tests for critical integrations.
- Publish changelogs and migration guides before deployment.
- Deprecate before removing supported contract elements.
- Provide a supported replacement for deprecated functionality.
- Monitor version and deprecated-feature usage.
- Apply security fixes to every supported version.
- Prevent old versions from bypassing current authorization controls.
- Limit the number of concurrently supported major versions.
- Test rollback and parallel-version routing.
- Retire versions only through the documented lifecycle process.
Practice Exercise
Evolve the earlier versioned customer and order API from version 1 to version 2 while preserving version 1 for existing clients.
Requirements
- Publish separate OpenAPI contracts for version 1 and version 2.
- Keep the version 1 contract operational.
- Rename
nametodisplayNamein version 2. - Replace a numeric total with an explicit money representation.
- Move version 2 order listing from offset to cursor pagination.
- Introduce a consistent problem-detail error format in version 2.
- Use version-specific request and response adapters.
- Reuse shared application and domain services.
- Define missing and unsupported version responses.
- Add automated contract-difference checks.
- Add consumer tests for version 1 clients.
- Publish a version 1 to version 2 migration guide.
- Add deprecation metadata after lifecycle approval.
- Monitor request volume by version.
- Document the controlled version 1 retirement criteria.
Migration Matrix
| Contract Area | Version 1 | Version 2 | Migration Action |
|---|---|---|---|
| Customer name | name |
displayName |
Update request and response mapping |
| Order total | Numeric amount | Amount string and currency | Adopt explicit money object |
| Pagination | Offset and limit | Opaque cursor and limit | Store and return continuation cursor |
| Error format | Plain message | Structured problem detail | Update client error parser |
| Version selection | /v1 |
/v2 |
Update endpoint base path |
Frequently Asked Questions
What is API versioning?
API versioning manages different contract generations so clients can use and migrate between supported behaviours predictably.
Does every API change require a new version?
No. Additive backward-compatible changes can normally be introduced within an existing major version.
What is a breaking change?
A breaking change requires an existing supported client to change its implementation to continue working correctly.
Can a change be breaking without changing the schema?
Yes. Changes to field meaning, ordering, validation, errors, consistency, or retry behaviour can break clients while preserving the schema.
Which API versioning strategy is best?
There is no universal choice. Select a strategy based on client usability, routing, caching, documentation, negotiation, and lifecycle requirements, then apply it consistently.
Should a missing version use the latest API?
No. Missing-version behaviour should be stable and documented. It should not change silently whenever a new version is deployed.
How is GraphQL versioned?
GraphQL commonly evolves one schema through additive fields and controlled deprecation. Incompatible removals still require a managed migration.
How is gRPC versioned?
gRPC contracts evolve through compatible Protocol Buffer changes. A new versioned package can be introduced when an incompatible service contract is necessary.
Why must removed Protocol Buffer field numbers be reserved?
Reserving them prevents old and new clients from interpreting the same wire field number as different data.
What is API deprecation?
Deprecation communicates that a currently supported version or contract element is planned for replacement or future removal.
When can an old version be removed?
Removal should follow the published lifecycle policy after migration guidance, consumer communication, usage monitoring, and approved retirement criteria.
What comes after versioning?
The next topic is idempotency, followed by authentication versus authorization and rate-limit semantics.
Key Takeaway
API versioning manages incompatible contract evolution, while backward compatibility allows existing clients to continue working without a required migration. Prefer additive changes, review behavioural as well as structural compatibility, and introduce a new major version only when incompatibility is necessary. Use one consistent selection strategy, publish machine-readable contracts, automate compatibility checks, provide migration guidance, monitor adoption, apply security fixes to all supported versions, and retire older versions only through a documented deprecation and lifecycle process.