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.
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 higherminimum, a narrowerenum, a stricterpattern, oradditionalPropertiesflipped fromtruetofalse— reject payloads that were previously valid. - Changed response status codes (for example, a documented
200becoming a204, or a400becoming a422) 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: true → false |
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.