Skip to main content

Breaking Change Detection for API Contracts

A backward-incompatible change that reaches production is a silent outage: existing clients keep sending the requests they always sent and start getting rejected, or they parse a response field that has quietly vanished. Breaking change detection moves that failure left, into the pull request, by diffing every proposed contract against the last shipped one and refusing to merge changes that would break live consumers. This guide extends API Contract Fundamentals & Tool Selection and focuses on the Gate stage of the design-to-governance lifecycle: turning a human review question (“is this safe to ship?”) into a deterministic, automated CI signal.

When to Use This Approach

Automated diff gating earns its keep once an API has consumers you do not control on the same release cadence. Reach for it when:

  • You publish an API consumed by other teams, external partners, or mobile clients that cannot be redeployed in lockstep.
  • Your contracts live in version control as OpenAPI, AsyncAPI, or Protocol Buffers, alongside or generated from code as covered in Schema-First vs Code-First Workflows.
  • You run microservices where one provider change can ripple across many contract-tested consumers.
  • You already enforce a versioning or deprecation policy and need a mechanism to enforce it consistently rather than by reviewer vigilance.
  • Pull requests routinely touch the spec and you want reviewers to focus on intent, not on manually scanning diffs for removed fields.

If your API has exactly one consumer that you deploy together every time, a diff gate adds friction without much payoff. Everywhere else, it pays for itself the first time it catches a deleted field before release.

The Detection Pipeline

The core idea is a three-stage pipeline: diff the new spec against a baseline, classify each change by severity, then gate the merge on that classification. The diagram below shows how a pull request flows through it.

Breaking change detection pipeline: diff, classify, gate A baseline spec and a pull-request spec feed a diff tool, which produces a change set that is classified into additive, non-breaking, and breaking, then routed to a gate that either passes the merge or blocks it pending an exception.

Baseline spec main branch

PR spec proposed change

Diff oasdiff

Additive new field, new path

Non-breaking description, example

Breaking removed field, 4xx change

CI Gate exit code + comment

Merge allowed

Blocked unless exception

Prerequisites

Pin the tool versions so results are reproducible across developer machines and CI runners. The examples below use oasdiff 1.11, openapi-diff 2.1 (as a legacy alternative), Spectral 6.11, Optic 0.59, and Buf 1.32 for Protocol Buffers.

# oasdiff — Go binary, the primary breaking-change engine
brew install oasdiff            # macOS
# or: go install github.com/oasdiff/oasdiff@v1.11.0

# Spectral — governance linting (custom rules)
npm install -D @stoplight/spectral-cli@6.11.0

# Optic — local-first diff with a hosted PR reviewer
npm install -g @useoptic/optic@0.59.0

# Buf — protobuf breaking-change detection
brew install bufbuild/buf/buf  # installs buf 1.32+

Your contract must be resolvable to a single document at diff time. If you split definitions across files with $ref, bundle first so the diff sees the fully dereferenced shape. The OpenAPI Specification Deep Dive covers modular $ref composition; bundle it with the Redocly CLI or oasdiff reads multi-file specs directly when given the root document.

Step 1: Define What “Breaking” Means

Before automating anything, agree on the classification, because the tools encode opinionated defaults you may need to override. The table later in this guide is the canonical list; the essentials are:

  • Removed or renamed response fields, endpoints, parameters, or enum values break consumers that read them.
  • Newly required request properties, or a parameter changed from optional to required, break consumers that omit them.
  • Tightened constraints — a smaller maxLength, a higher minimum, a narrower enum, a stricter pattern, or additionalProperties flipped from true to false — reject payloads that were previously valid.
  • Changed response status codes (for example, a documented 200 becoming a 204, or a 400 becoming a 422) break clients that branch on the code.
  • Loosened response constraints and added optional response fields are additive, not breaking, because existing clients ignore what they do not read.

The asymmetry matters: tightening requests and loosening responses are safe; tightening responses and loosening requests are not. Encode this once and let the diff tool apply it on every PR.

Step 2: Diff Against a Baseline With oasdiff

The baseline is the contract you are comparing against — almost always the version currently on main. In CI you reconstruct it from git, then run the diff. The detailed walkthrough lives in detecting breaking changes with openapi-diff in CI; the core command is:

# Reconstruct the shipped baseline from the main branch.
git show origin/main:openapi.yaml > /tmp/baseline.yaml

# Compare baseline (revision) against the PR spec (revision).
# --fail-on ERR makes oasdiff exit non-zero on breaking changes.
oasdiff breaking /tmp/baseline.yaml openapi.yaml \
  --format text \
  --fail-on ERR

oasdiff breaking reports only backward-incompatible changes, each with a stable ID, a severity (ERR for breaking, WARN for risky-but-allowed), and the exact path. The --fail-on ERR flag is what converts the report into a gate: the process exits 1 the moment a breaking change appears, and CI marks the job red.

For a machine-readable artifact you can post as a PR comment or store, emit JSON and the full change set:

# Full change set (additive + non-breaking + breaking) as JSON.
oasdiff diff /tmp/baseline.yaml openapi.yaml --format json > diff.json

# Breaking-only summary as JSON, with severity counts.
oasdiff breaking /tmp/baseline.yaml openapi.yaml \
  --format json \
  --severity-levels ERR,WARN > breaking.json

Step 3: Layer Governance Rules With Spectral

A diff tool answers “did the shape change incompatibly?” but not “did this change follow our process?” Policies such as “a removed field must have carried deprecated: true for one release first” or “every operation must declare a Sunset header before deletion” are governance rules, and Spectral is built for them. Write them as a custom ruleset — the full technique is in writing custom Spectral rules for API governance.

# .spectral.yaml — Spectral 6.11 governance ruleset
extends: ["spectral:oas"]
rules:
  # Operations slated for removal must be deprecated first.
  require-deprecation-before-removal:
    description: Operations must be marked deprecated before deletion.
    given: "$.paths[*][get,post,put,patch,delete]"
    severity: warn
    then:
      field: deprecated
      function: truthy
    # Applied selectively in CI to endpoints flagged for removal.

  # Every deprecated operation must advertise a sunset date.
  deprecated-needs-sunset:
    description: Deprecated operations must document a Sunset response header.
    given: "$.paths[*][?(@.deprecated == true)].responses[*].headers"
    severity: error
    then:
      field: Sunset
      function: truthy

Run Spectral alongside oasdiff in the same job. The diff catches the structural break; Spectral catches the process violation — for example, deleting an endpoint that was never deprecated.

npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml --fail-severity=error

Step 4: Add Optic for Local-First Review

oasdiff is excellent in CI but runs after the fact. Optic shifts the same check onto the developer’s machine and into the PR conversation, capturing the diff as the spec changes and rendering a human-readable summary of what broke. It is especially useful when specs are generated from code and engineers do not edit YAML directly. The setup is detailed in catching breaking changes with Optic.

# Compare the current spec against the version on main, applying
# Optic's built-in breaking-change ruleset.
optic diff openapi.yaml --base origin/main --check
# optic.yml — enable the standard breaking-changes ruleset
ruleset:
  - breaking-changes
  - require-ids        # stable operationId on every operation

The --check flag exits non-zero on a rule violation, so the same command works as a local pre-commit guard and as a CI step. Optic’s strength is the rendered review: it groups changes by endpoint and labels each as breaking or safe, which lowers the cost of the reviewer’s decision.

Step 5: Wire the Gate Into the Pull Request

The final step composes the diff, the governance lint, and an explicit exception mechanism into one job. The gate must block breaking changes by default while leaving a visible, reviewed escape hatch for intentional major-version changes.

# .github/workflows/contract-gate.yml
name: API Contract Gate
on:
  pull_request:
    paths: ["openapi.yaml", "proto/**", ".spectral.yaml"]
jobs:
  contract-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # full history so origin/main is reachable

      - name: Reconstruct baseline
        run: git show origin/main:openapi.yaml > /tmp/baseline.yaml

      - name: Install oasdiff
        run: go install github.com/oasdiff/oasdiff@v1.11.0

      # Breaking changes fail the job unless the PR carries the
      # 'breaking-change-approved' label (a reviewed, deliberate bump).
      # contains(...) on the labels array reads the approval flag.
      - name: Detect breaking changes (enforce)
        if: "!contains(github.event.pull_request.labels.*.name, 'breaking-change-approved')"
        run: oasdiff breaking /tmp/baseline.yaml openapi.yaml --format text --fail-on ERR

      - name: Detect breaking changes (report only)
        if: "contains(github.event.pull_request.labels.*.name, 'breaking-change-approved')"
        run: oasdiff breaking /tmp/baseline.yaml openapi.yaml --format text || true

      - name: Governance lint
        run: npx @stoplight/spectral-cli lint openapi.yaml \
               --ruleset .spectral.yaml --fail-severity=error

      # Protobuf services gated with Buf in the same workflow.
      - name: Protobuf breaking check
        run: buf breaking --against '.git#branch=main'

The label-based override keeps deliberate breaks honest: a human applies breaking-change-approved only after deciding to ship a new major version, and the action records that decision in the PR’s audit trail rather than letting the change slip through unremarked.

Spec/Schema Reference

The table below maps common contract changes to their classification and the option that controls each tool’s behavior. Severity defaults reflect oasdiff 1.11.

Change Type oasdiff severity (default) Controlling option / effect
Remove a response property breaking ERR --fail-on ERR blocks; clients reading the field break
Remove an endpoint or operation breaking ERR api-path-removed change ID; always backward-incompatible
Add a new required request property breaking ERR --fail-on ERR; old clients omit it and get rejected
Make an optional parameter required breaking ERR request-parameter-became-required change ID
Tighten a constraint (smaller maxLength, narrower enum) breaking ERR rejects payloads that were previously valid
additionalProperties: truefalse breaking ERR undocumented fields now rejected at the edge
Change a documented status code breaking ERR response-success-status-removed; clients branching on it break
Rename an enum value breaking ERR treated as remove + add; old value rejected
Add a new optional response property additive INFO safe; existing clients ignore it
Add a new endpoint additive INFO safe; no existing consumer depends on it
Loosen a response constraint (larger maxLength) non-breaking INFO responses stay within prior bounds
Edit a description or example non-breaking INFO documentation only; no wire impact
Add an optional request parameter non-breaking INFO clients that omit it keep working

Verification

A clean run on an additive-only PR produces a passing job:

$ oasdiff breaking /tmp/baseline.yaml openapi.yaml --fail-on ERR
No breaking changes.
$ echo $?
0

When a PR removes a field, the gate fails with a specific, actionable report:

$ oasdiff breaking /tmp/baseline.yaml openapi.yaml --fail-on ERR
1 breaking changes: 1 error, 0 warning

error  [response-property-removed]
       in API GET /transactions/{id}
       removed the response property 'settledAt' from the 200 response

$ echo $?
1

In GitHub Actions the failing step turns the check red and blocks the merge button when the branch protection rule requires the contract-gate check. The Buf step reports protobuf breaks in the same shape:

$ buf breaking --against '.git#branch=main'
proto/payment/v1/payment.proto:14:3:Field "1" with name "amount" on message
  "Charge" changed type from "int64" to "string".

Troubleshooting

oasdiff reports breaking changes that are not real

If oasdiff flags changes you consider safe, the cause is usually an unresolved $ref or a reordered-but-equivalent spec. Confirm both inputs bundle to the same canonical form, and check the change ID against the reference table. To accept a specific known-safe change, exclude it by ID with --exclude-elements or downgrade it in a --severity override rather than disabling the whole gate.

git show origin/main:openapi.yaml fails in CI

This fails when the checkout is shallow and origin/main is not present. Set fetch-depth: 0 on actions/checkout so full history is available, or explicitly git fetch origin main before the diff step. Without it the baseline reconstruction errors and the job cannot run.

Spectral passes locally but fails in CI

Almost always a version or ruleset-path mismatch. Pin @stoplight/spectral-cli@6.11.0 in devDependencies and invoke it via npx so CI uses the locked version, and pass an explicit --ruleset .spectral.yaml. A globally installed older Spectral on a developer machine can silently apply different defaults.

Buf reports a break on a field you only renamed

In Protocol Buffers, the field number is the contract, not the name. Renaming a field while keeping its number is source-incompatible but wire-compatible; deleting a field and reusing its number is a hard break. Use reserved for retired field numbers and names so Buf can distinguish an intentional retirement from an accidental reuse.

Every PR is blocked because the spec is generated and reorders keys

Code-first generators sometimes emit keys in nondeterministic order, producing noisy diffs. oasdiff is order-insensitive for most structures, but if you see churn, normalize the generated spec (sort keys, stable serialization) before committing. Treating the generated spec as a build artifact reviewed on every PR — the pattern described in Schema-First vs Code-First Workflows — keeps the baseline stable.

Frequently Asked Questions

What counts as a breaking change in an OpenAPI contract?

A change is breaking if it can cause a previously valid consumer request or response handler to fail. Removing a response field, deleting an endpoint or parameter, adding a new required request property, tightening a constraint such as maxLength or enum membership, and changing a documented status code are all breaking. Adding an optional response field or a new endpoint is additive and safe.

What is the difference between oasdiff and openapi-diff?

Both compare two OpenAPI documents and report changes, but oasdiff (Go, actively maintained) has a richer breaking-changes engine with stable change IDs, severity levels, and exit codes designed for CI. openapi-diff (Java/Node implementations) is simpler and older. For new pipelines oasdiff is the stronger default; openapi-diff remains common in legacy setups.

Can Spectral detect breaking changes by itself?

Not directly. Spectral lints a single document against rules; it has no built-in concept of comparing two versions. You detect breaking changes by combining a diff tool (oasdiff or openapi-diff) for version comparison with Spectral custom rules for governance constraints such as required deprecation periods or sunset headers.

How do I detect breaking changes in Protocol Buffers instead of OpenAPI?

Use Buf. Run buf breaking against a git ref or a stored image; it understands protobuf wire-compatibility and source-compatibility rules natively, flagging changes like reusing a field number, deleting a field, or changing a field type. Run it in the same PR gate as your OpenAPI diff.

Should a breaking change always fail the build?

Block by default, but allow an explicit, reviewed override. A breaking change is sometimes intentional and shipped behind a new major version or with a deprecation window. Gate so that breaking changes fail unless a labeled approval or a documented exception file is present, which keeps the decision visible rather than silent.

How does breaking-change detection relate to API versioning?

Detection is the mechanism that enforces a versioning policy. When the diff classifies a change as breaking, your policy decides the response: require a major-version bump, a new path prefix, or a deprecation period. The tool catches the change; the policy dictates the remedy.