Skip to main content
Adopt

IPA-116: Backwards Compatibility

APIs are fundamentally contracts with users, and users often write code against APIs that are then launched into a production service with the expectation that it continues to work. Therefore, it is important to understand what constitutes a backward-compatible change and what constitutes a backward-incompatible change.

Guidance

  1. Existing client code written against a published version must not be broken by minor changes to the service.

    paths:
    /users/{userId}:
    get:
    responses:
    "200":
    content:
    application/json:
    schema:
    type: object
    properties:
    id:
    type: string
    name:
    type: string
    nickname: # newly added optional field
    type: string
    Why:

    A new optional response field is additive. A client that ignores unknown fields keeps working unchanged, so the existing contract still holds.

    paths:
    /users/{userId}:
    get:
    responses:
    "200":
    content:
    application/json:
    schema:
    type: object
    properties:
    id:
    type: string
    # name was removed from the response
    Why:

    Removing a field a client already reads breaks code that depends on it, even though the change looks small from the producer side.

    1. Take the previously published version of the spec and the proposed new revision of the same version.

    2. Diff the two: for every operation, compare request and response schemas, parameters, status codes, and media types.

    3. Classify each difference as additive (new optional element) or subtractive/altering (removed, renamed, retyped, newly required).

    4. Report any subtractive or altering change as a break of the existing contract.

  2. An API producer must consider syntax breaking changes before publishing a revision.

    # Two versions side by side; the v2 path adds an optional query parameter only.
    paths:
    /orders:
    get:
    parameters:
    - name: status
    in: query
    required: false
    schema:
    type: string
    Why:

    The revision was reviewed against the prior shape and limited to an additive, optional parameter, so no caller's existing request becomes invalid.

    paths:
    /orders:
    get:
    parameters:
    - name: status
    in: query
    required: true # was optional in the published version
    schema:
    type: string
    Why:

    The revision shipped without comparing against the published shape. Promoting an existing parameter to required rejects requests that omit it.

    1. Locate the previously published version and the proposed revision of the same major version.

    2. For each operation, inspect the wire-level shape: parameters, request body schema, response schema, status codes, media types, and required flags.

    3. Confirm a documented comparison exists and that every shape difference was classified as breaking or non-breaking.

    4. Report any revision that altered the wire shape without that comparison having been done.

  3. An API producer must consider semantic breaking changes before publishing a revision, even when the wire shape is unchanged.

    paths:
    /jobs/{jobId}:
    get:
    responses:
    "200":
    content:
    application/json:
    schema:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, RUNNING, DONE, FAILED]
    description: >-
    Lifecycle state. The set of values and the meaning of each
    value is stable across this version.
    Why:

    The meaning of each value is fixed for the life of the version, so a client that branches on status keeps interpreting responses correctly.

    paths:
    /jobs/{jobId}:
    get:
    responses:
    "200":
    content:
    application/json:
    schema:
    type: object
    properties:
    status:
    type: string
    enum: [PENDING, RUNNING, DONE, FAILED]
    description: >-
    DONE now means "accepted for processing" rather than
    "completed". The shape is identical to the prior version.
    Why:

    The shape is unchanged but the meaning of DONE flipped, so existing logic that treats DONE as finished now misbehaves. Detecting this requires understanding server behavior, not just the spec.

    1. Identify operations whose wire shape is unchanged between the published version and the revision.

    2. For each, read the implementation and changelog to find changed behavior: altered field semantics, changed defaults applied server-side, reordered or filtered results, changed side effects.

    3. Assess whether existing client logic that relied on the prior behavior would now produce wrong outcomes.

    4. Report any behavioral change that alters the meaning of a response without a corresponding new version.

  4. Backwards compatibility must not be broken by a change without issuing a new API version.

    # A breaking rename is delivered under a new version, leaving the old one intact.
    paths:
    /v1/users/{userId}:
    get:
    responses:
    "200":
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/UserV1"
    /v2/users/{userId}:
    get:
    responses:
    "200":
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/UserV2"
    Why:

    The incompatible shape lives in a new version. Callers on the old version are untouched and migrate on their own schedule.

    # The published v1 response shape is changed in place.
    paths:
    /v1/users/{userId}:
    get:
    responses:
    "200":
    content:
    application/json:
    schema:
    $ref: "#/components/schemas/UserV2" # breaking change in v1
    Why:

    An incompatible change was applied to a published version. Every caller breaks at once with no opt-in and no migration path.

    1. Diff each published version against its proposed revision and collect every breaking difference.

    2. For each breaking difference, confirm it is introduced under a new version identifier and that the prior version retains its old behavior.

    3. Report any breaking change applied in place to an already published version.

  5. An API should leverage extensible design where applicable, so that future additions do not force a breaking change.

    components:
    schemas:
    Notification:
    type: object
    properties:
    channel:
    type: string
    enum: [EMAIL, SMS] # open-ended set, callers tolerate new values
    details:
    type: object
    additionalProperties: true
    Why:

    Modeling channel as an extensible enum and details as an open object lets new channels and fields land additively, without an incompatible change.

    components:
    schemas:
    Notification:
    type: object
    properties:
    emailOnly:
    type: boolean # bakes in a single channel
    additionalProperties: false
    required: [emailOnly]
    Why:

    A boolean and a closed object hard-code today's behavior. Supporting another channel later requires removing or reshaping a required field, which is breaking.

    1. Identify schema points where future growth is likely: status and type fields, mode flags, and containers for variable detail.

    2. Check whether those points are modeled extensibly: enums that callers can tolerate adding to, objects rather than booleans for concepts that may grow, and nested objects for groups of related fields.

    3. Flag designs where a foreseeable addition would require a breaking change because the current model is closed.

  6. New functionality may be added to existing versions, provided no incompatibilities are introduced.

Syntax Breaking Changes

Changes considered breaking include:

  1. New required fields must not be added to an existing version of an API.

    # Added field is optional in the new revision.
    components:
    schemas:
    CreateOrderRequest:
    type: object
    properties:
    items:
    type: array
    couponCode: # added, not required
    type: string
    required: [items]
    Why:

    An added optional field leaves prior requests valid, because omitting it is still accepted.

    components:
    schemas:
    CreateOrderRequest:
    type: object
    properties:
    items:
    type: array
    couponCode:
    type: string
    required: [items, couponCode] # newly required
    Why:

    Marking a new field required rejects every existing request that omits it.

    1. For each request schema, diff the required array between the published version and the revision.

    2. Flag any entry present in the revision's required list but not in the published one.

    3. Report each newly required request field as a breaking change.

  2. Optional fields must not be removed from the request or response of an existing API version.

    components:
    schemas:
    UserResponse:
    type: object
    properties:
    id:
    type: string
    nickname: # optional field retained
    type: string
    Why:

    Keeping the optional field preserves the shape callers already read and send.

    components:
    schemas:
    UserResponse:
    type: object
    properties:
    id:
    type: string
    # nickname was deleted
    Why:

    Deleting an optional field breaks callers that read it from responses or set it in requests.

    1. For each request and response schema, list its declared properties in the published version and in the revision.

    2. Flag any property present in the published version but absent from the revision.

    3. Report each removed property as a breaking change.

  3. Guaranteed output fields must not be omitted or made nullable in an existing version.

    components:
    schemas:
    InvoiceResponse:
    type: object
    properties:
    total:
    type: number
    required: [total] # always returned, never null
    Why:

    A field guaranteed in the published version stays required and non-nullable, so callers can keep reading it unconditionally.

    components:
    schemas:
    InvoiceResponse:
    type: object
    properties:
    total:
    type: number
    nullable: true # was always present before
    Why:

    Allowing a previously guaranteed field to be null forces callers to handle a case that never used to occur, breaking code that assumed a value.

    1. For each response schema, identify fields that were guaranteed in the published version (present and non-nullable, typically in required).

    2. In the revision, confirm each such field is still present, required, and not marked nullable.

    3. Report any guaranteed output field that became optional, nullable, or absent.

  4. Existing API components must not be renamed or removed from an existing version of an API.

    components:
    schemas:
    UserResponse: # name unchanged across the version
    type: object
    properties:
    id:
    type: string
    Why:

    Stable component names keep $ref targets and generated client types intact.

    components:
    schemas:
    UserPayload: # renamed from UserResponse
    type: object
    properties:
    id:
    type: string
    Why:

    Renaming a component breaks every $ref to the old name and the generated types clients compiled against.

    1. Enumerate the keys under components (schemas, parameters, responses, and so on) in the published version.

    2. Confirm each key still exists under the same name in the revision.

    3. Report any component key that was renamed or removed.

  5. Default values provided for a field must not change.

    components:
    schemas:
    ListProjectsQuery:
    type: object
    properties:
    pageSize:
    type: integer
    default: 100 # unchanged from the published version
    Why:

    A stable default keeps the behavior of requests that omit the field identical to before.

    components:
    schemas:
    ListProjectsQuery:
    type: object
    properties:
    pageSize:
    type: integer
    default: 20 # was 100
    Why:

    Changing the default silently alters the result of every request that relied on the old default.

    1. For each schema property, read its default in the published version and the revision.

    2. Flag any property whose default value differs between the two versions.

    3. Report each changed default as a breaking change.

  6. Changes to path, query, request headers, or body content must not be made to an existing version.

    paths:
    /projects/{projectId}/tasks:
    post:
    parameters:
    - name: dryRun
    in: query
    required: false # additive only
    schema:
    type: boolean
    Why:

    Only an optional query parameter is added; the path and existing body and headers are untouched, so existing requests still succeed.

    paths:
    /projects/{projectId}/work-items: # path renamed from /tasks
    post:
    requestBody:
    content:
    application/json:
    schema:
    type: object
    required: [priority] # body now demands a new field
    Why:

    Renaming the path and demanding a new body field invalidates requests written against the published version.

    1. For each operation, compare the path template, query parameters, request headers, and request body schema between versions.

    2. Classify each difference as additive-and-optional or as an alteration to an existing element.

    3. Report any non-additive change to path, query, request headers, or body.

  7. Field names must not change or be removed.

    components:
    schemas:
    Order:
    type: object
    properties:
    createdAt: # name preserved
    type: string
    format: date-time
    Why:

    A stable field name keeps serialization and deserialization working for existing callers.

    components:
    schemas:
    Order:
    type: object
    properties:
    creationTime: # renamed from createdAt
    type: string
    format: date-time
    Why:

    Renaming a field means existing callers send and read a name the server no longer recognizes.

    1. For each schema, list its property names in the published version and the revision.

    2. Flag any name that disappeared or whose value moved under a differently named property.

    3. Report each renamed or removed field.
  8. Field types must not be changed.

    components:
    schemas:
    Product:
    type: object
    properties:
    price:
    type: number # type unchanged
    Why:

    Holding the declared type steady keeps the value parseable by clients generated against the published version.

    components:
    schemas:
    Product:
    type: object
    properties:
    price:
    type: string # was number
    Why:

    Changing the type breaks typed clients and any caller that parsed the value as the original type.

    1. For each property present in both versions, read its type and format.

    2. Flag any property whose type or format differs between the versions.

    3. Report each changed field type.
  9. Status codes must not change.

    paths:
    /orders/{orderId}:
    get:
    responses:
    "200": # still returned on success
    description: OK
    "404":
    description: Not found
    Why:

    A stable set of status codes lets callers keep branching on the same outcomes.

    paths:
    /orders/{orderId}:
    get:
    responses:
    "204": # success code changed from 200
    description: No content
    "404":
    description: Not found
    Why:

    Changing the success status code breaks callers that check for the original code.

    1. For each operation, list the response status codes declared in the published version and the revision.

    2. Flag any code that was removed or whose meaning was reassigned (a former success code dropped in favor of another).

    3. Report each altered or removed status code.
  10. Operation ids must not be updated or deleted.

    paths:
    /users/{userId}:
    get:
    operationId: getUser # unchanged
    Why:

    A stable operationId keeps generated method names and tooling references intact.

    paths:
    /users/{userId}:
    get:
    operationId: fetchUser # renamed from getUser
    Why:

    Renaming an operationId breaks generated SDK method names and any code that calls them.

    1. Collect every operationId in the published version.

    2. Confirm each one still exists, unchanged, on the same operation in the revision.

    3. Report any operationId that was renamed or deleted.

  11. Operation tags must not be updated or deleted.

    paths:
    /users/{userId}:
    get:
    operationId: getUser
    tags: [Users] # unchanged
    Why:

    Stable tags keep generated client grouping and documentation structure consistent for callers.

    paths:
    /users/{userId}:
    get:
    operationId: getUser
    tags: [Accounts] # changed from Users
    Why:

    Changing a tag reorganizes generated SDK namespaces and documentation that callers navigate by.

    1. For each operation, list its tags in the published version and the revision.

    2. Flag any tag that was renamed or removed from an existing operation.

    3. Report each altered or deleted operation tag.
  12. HTTP verbs must not be changed or deleted.

    paths:
    /users/{userId}:
    get: # verb preserved
    operationId: getUser
    Why:

    Keeping the verb on a path means existing requests reach the same operation.

    paths:
    /users/{userId}:
    post: # was get
    operationId: getUser
    Why:

    Moving an operation to a different verb makes every request that used the old verb fail to route.

    1. For each path, list the HTTP methods defined in the published version.

    2. Confirm each method still exists on the same path in the revision.

    3. Report any verb that was removed or whose operation moved to a different verb.

  13. Media types must not be changed or deleted.

    paths:
    /reports/{reportId}:
    get:
    responses:
    "200":
    content:
    application/json: # media type preserved
    schema:
    type: object
    Why:

    A stable media type keeps content negotiation and client parsing working as before.

    paths:
    /reports/{reportId}:
    get:
    responses:
    "200":
    content:
    application/xml: # was application/json
    schema:
    type: object
    Why:

    Changing the media type breaks clients that send the old Accept header or parse the old format.

    1. For each request body and response, list the media type keys under content in the published version.

    2. Confirm each media type still exists in the revision.

    3. Report any media type that was changed or removed.

  14. Existing resources must not be moved to a new URI.

    paths:
    /projects/{projectId}/tasks/{taskId}: # path preserved
    get:
    operationId: getTask
    Why:

    Keeping the URI stable means existing client requests still resolve to the same resource.

    paths:
    /tasks/{taskId}: # moved out from under /projects/{projectId}
    get:
    operationId: getTask
    Why:

    Moving a resource to a new URI makes every request to the old path return a not-found error.

    1. Enumerate every path template in the published version.

    2. Confirm each path still exists in the revision (matching the same operation by operationId).

    3. Report any operation whose path template changed.

  15. Existing resources must not be removed without previously being marked as sunsetting.

    paths:
    /orders/{orderId}:
    get:
    deprecated: true
    responses:
    "200":
    headers:
    Sunset: # announced before removal
    schema:
    type: string
    format: date-time
    Why:

    The resource is marked deprecated and announces a sunset date, giving callers a defined window to migrate before it is removed.

    paths:
    /invoices/{invoiceId}:
    # the entire path was deleted in this revision with no prior sunset notice
    get:
    operationId: getInvoice
    Why:

    Deleting a live resource that was never marked sunsetting breaks callers with no warning and no migration window.

    1. Identify paths present in the published version but absent from the revision.

    2. For each removed path, check the prior version's history for a deprecated: true marking and a Sunset header announced ahead of removal.

    3. Report any resource removed without a prior sunset announcement.

  16. Existing options within enum fields must not be changed or removed.

    components:
    schemas:
    Subscription:
    type: object
    properties:
    tier:
    type: string
    enum: [FREE, PRO, ENTERPRISE] # existing options retained
    Why:

    Keeping existing enum values lets callers that send or branch on them keep working.

    components:
    schemas:
    Subscription:
    type: object
    properties:
    tier:
    type: string
    enum: [FREE, PREMIUM] # PRO renamed, ENTERPRISE removed
    Why:

    Renaming or dropping an enum value rejects requests carrying the old value and breaks callers that match on it in responses.

    1. For each enum field, list its values in the published version and the revision.

    2. Flag any value present in the published version but missing from the revision (removed or renamed).

    3. Report each removed or changed enum option. New options added alongside the existing ones are allowed.

Changes considered non-breaking include:

  1. New output fields may be added to existing versions.

  2. Optional input parameters may be added to existing versions.

  3. Updates to underlying logic that produce different results without affecting the shape of the response may be added to existing versions.

  4. Changes to unstructured, human-readable string values may be added to existing versions, except for machine codes or formatted strings such as dates.

  5. Response headers may be added to existing versions.

  6. Changes to a resource's authorization may be added to existing versions.

  7. New options may be added to existing enums.

  8. Input and output fields may be marked as deprecated in existing versions.

Semantic Breaking Changes

Semantic-based breaking changes capture situations in which the behavior of a resource is changing without necessarily including a syntax-based breaking change that would produce a top-level error. These situations often require intensive data analysis and discovery to fully understand the extent of customer impact for the change.