Detecting Breaking Changes with oasdiff
This walkthrough belongs to Breaking Change Detection in the API Versioning & Deprecation reference. oasdiff compares two OpenAPI documents and classifies what changed between them. This page covers wiring it into a pull request pipeline so the classification is automatic, credible and hard to bypass by accident.
The decision trigger
| Symptom | Cause |
|---|---|
| A field rename shipped and a consumer broke | no diff gate on pull requests |
| The diff reports changes for a pure file reorganisation | unbundled documents compared |
| The gate is disabled because it “always fails” | diffing the main branch instead of the release |
| Reviewers cannot see what changed additively | only the breaking check runs, no changelog |
| A suppression added last year still hides a rule | no expiry on ignore entries |
| The SDK version bump was chosen by hand | exit status not wired into release derivation |
Spec snippet: what a finding looks like
// oasdiff breaking --format json (abridged)
[
{
"id": "request-property-became-required",
"level": 3,
"operation": "POST",
"path": "/invoices",
"source": "openapi.yaml",
"text": "the request property 'customer_id' became required"
},
{
"id": "response-property-removed",
"level": 3,
"operation": "GET",
"path": "/invoices/{invoiceId}",
"text": "the response property 'estimated_total' was removed for status '200'"
}
]
Level 3 is ERR, level 2 is WARN and level 1 is INFO. The id is the stable identifier to use in suppression files and in any policy you write — the text is human wording that may change between releases.
Step-by-step
Step 1 — Run it locally first
# From a blank terminal — Docker needs no local Go toolchain
docker pull tufin/oasdiff:latest
# Every difference, including cosmetic ones
docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
diff /specs/baselines/current.yaml /specs/dist/openapi.yaml --format text | head -40
# Human-meaningful changes, grouped by severity
docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
changelog /specs/baselines/current.yaml /specs/dist/openapi.yaml --format text
# Only the backward-incompatible ones — this is the gate
docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
breaking /specs/baselines/current.yaml /specs/dist/openapi.yaml --fail-on ERR
The three subcommands answer different questions: diff is exhaustive and noisy, changelog is what you show a reviewer, breaking is what you gate on.
Step 2 — Wire the gate into pull requests
# .github/workflows/api-compat.yml
name: API compatibility
on: [pull_request]
jobs:
compat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Bundle the head revision
run: npx @redocly/cli bundle openapi.yaml -o /tmp/head.yaml --dereferenced=false
- name: Breaking-change gate
run: |
docker run --rm -v /tmp:/head -v "$PWD/baselines:/base" tufin/oasdiff:latest \
breaking /base/current.yaml /head/head.yaml \
--format githubactions \
--fail-on ERR \
--exclude-elements description,examples,title
- name: Changelog for reviewers
if: always()
run: |
{
echo "## API changes in this pull request"
docker run --rm -v /tmp:/head -v "$PWD/baselines:/base" tufin/oasdiff:latest \
changelog /base/current.yaml /head/head.yaml --format text
} >> "$GITHUB_STEP_SUMMARY"
--exclude-elements description,examples,title is worth adding early: prose edits are not contract changes, and a gate that fires on a typo fix in a description gets disabled within a month.
Step 3 — Suppress with a paper trail
# .oasdiff-ignore.yaml — checked by CI for expiry
- id: response-property-removed
path: /v1/legacy-report
method: GET
reason: >
`estimated_total` has returned null since 2025-03 and is unused by all
six remaining consumers (verified from access logs, ticket API-2291).
approved_by: platform-team
expires: 2026-12-31
- id: request-property-became-enum
path: /v1/invoices
method: POST
reason: >
`currency` was documented as a free string but the server has always
rejected non-ISO values; the schema now matches reality.
approved_by: billing-team
expires: 2026-09-30
# scripts/check_suppressions.py — run in CI with the current date injected
import datetime, sys, yaml
TODAY = datetime.date.fromisoformat(sys.argv[1]) # e.g. 2026-07-31
entries = yaml.safe_load(open(".oasdiff-ignore.yaml")) or []
problems = []
for e in entries:
for field in ("id", "path", "reason", "approved_by", "expires"):
if not e.get(field):
problems.append(f"suppression missing `{field}`: {e}")
if e.get("expires") and datetime.date.fromisoformat(str(e["expires"])) < TODAY:
problems.append(f"suppression for {e['id']} on {e['path']} expired {e['expires']}")
for p in problems:
print(f"::error::{p}")
print(f"{len(entries)} suppression(s) checked, {len(problems)} problem(s)")
sys.exit(1 if problems else 0)
Step 4 — Understand what it cannot see
oasdiff compares documents, so it inherits their limits. These changes pass cleanly:
| Change | Why the diff misses it |
|---|---|
| Default sort order reversed | not expressed in the document |
| Default page size lowered | the parameter default may be unchanged in the spec |
| A validation rule added in code only | never appeared in the schema |
An error type URI reworded |
usually only an example |
| Response latency doubled | not a document property |
An optional field now always null |
still optional, still typed |
Cover these with the contract tests described in Contract Tests vs Schema Diffs. A diff and a test suite have almost disjoint coverage, which is why running both is not redundant.
Choosing the baseline is the setting that most often decides whether the gate is trusted:
Standard compliance
| Concern | Standard | Note |
|---|---|---|
| Document structure compared | OpenAPI 3.0 / 3.1 | Both are supported inputs |
| Reference resolution | JSON Schema Core 2020-12 §8.2.3 | Bundle first for stable results |
| Compatibility direction | not standardised | The request/response asymmetry is convention, not RFC |
| Semantic versioning of SDKs | SemVer 2.0.0 | Major on breaking findings |
There is no RFC that defines “breaking API change”; the classification is a widely shared convention that tools encode slightly differently. That is an argument for writing your own policy document naming which rule ids you gate on, rather than treating any single tool’s defaults as authoritative.
SDK / codegen downstream effect
# release.sh — the diff decides the bump, not the release engineer
if docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
breaking /specs/baselines/current.yaml /specs/dist/openapi.yaml --fail-on ERR >/dev/null 2>&1
then
BUMP=minor
else
BUMP=major
fi
echo "next SDK release: $BUMP"
npm --prefix sdk/typescript version "$BUMP" --no-git-tag-version
Deriving the bump mechanically is what makes a consumer’s ^4.2.0 range trustworthy: a major bump means the client surface changed, every time, with no exceptions for a rushed Friday release. The consumer side of that promise is covered in Semver Ranges for Generated SDK Clients.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Diffing the main branch instead of the release | Keep a baselines/current.yaml updated at each release |
| Comparing unbundled multi-file specs | Bundle both revisions first |
Gating on WARN level |
Gate on ERR; surface warnings to reviewers |
| Suppressions with no expiry | Require owner, reason and expiry; fail on expired entries |
| Failing on description edits | --exclude-elements description,examples,title |
| Treating the diff as complete coverage | Add contract tests for behavioural change |
FAQ
What is the difference between oasdiff diff, changelog and breaking?
They answer three different questions from one comparison. diff reports every structural difference between the documents, including reordering and description edits — useful for debugging, far too noisy for a gate. changelog filters that down to changes a human would consider meaningful and groups them by severity, which makes it the right thing to post into a pull request summary so reviewers see additive changes too. breaking reports only the subset classified as backward-incompatible and sets a non-zero exit status, which is what belongs in the CI gate and in your version-bump derivation.
Why does oasdiff report a break when I only moved a schema between files?
Because it compared the documents as written, and in an unbundled multi-file specification the reference targets genuinely differ — ./components/order.yaml#/Order is not the same string as ./schemas/billing.yaml#/Order, even though both resolve to identical content. Bundle both revisions into single self-contained documents before diffing and the reorganisation disappears entirely, because the bundled output is identical. This is the same reason bundling matters for publishing, covered in Reusable Schema Components and $ref Cycles.
Should a warning-level finding fail the build?
Generally no. The value of a breaking-change gate is that it is credible: when it fires, everyone believes something is genuinely wrong. Fail on ERR only, and route WARN findings into the review summary where a human decides. Then, when experience shows that a particular warning-level rule has actually broken your consumers — response enum additions are the usual candidate — promote that specific rule to error rather than raising the global threshold. A gate that fails on everything is a gate that gets bypassed.
Related
- Breaking Change Detection — up-link: the section defining what counts as breaking
- API Versioning & Deprecation — up-link: the parent reference for versioning and retirement
- Contract Tests vs Schema Diffs — covering the behaviour a diff cannot see
- Adding Required Request Fields Safely — staging the break this tool will flag
- Generating API Changelogs from OpenAPI Diffs — publishing the same diff to consumers