Detect breaking API changes in CI with OpenAPI diffs (before your customers do)
Every API team has a postmortem that starts with "we didn't think that was a breaking change." A field that was always present becomes optional; an enum gains a value clients treat as exhaustive; a response code changes from 200 to 201; a query parameter that used to be ignored is now required. None of these show up in code review as dangerous, because code review shows the server diff, not the contract diff. The fix is boring and extremely effective: diff the OpenAPI document against the last released version in CI, classify changes automatically, and block the breaking ones. Here is how to set it up and what the classifier must understand.
What actually counts as breaking
Breaking means a change that can make a correctly written existing client fail. The list is longer than people expect:
| Change | Breaking? | Why |
|---|---|---|
| Remove an endpoint | Yes | Client 404s |
| Remove a request parameter | No (usually) | Extra params are tolerated |
| Add a required request parameter/field | Yes | Old clients omit it |
| Make an optional request field required | Yes | Same |
| Remove a response field | Yes | Clients read it |
| Add a response field | No | Additive |
| Make a response field optional/nullable | Yes | Clients dereference it |
| Narrow a type (integer → boolean) | Yes | Decoding fails |
| Widen a request type | No | Accepts more |
| Remove an enum value | Yes | Clients send/expect it |
| Add an enum value to a response | Yes* | Exhaustive switch/default-less clients break |
| Change status code semantics | Yes | Branching logic misses |
| Tighten validation (new max length) | Yes | Previously valid requests fail |
| Add an endpoint / optional param | No | Additive |
| Change description text | No | Narrative only |
*Response enum additions are the subtle one: pure schema evolution says additive, but a client matching exhaustively on a closed set can crash. Conservative teams flag them as "attention required" rather than hard-blocking.
The CI gate
The pipeline compares the merged spec on the branch against the last released revision:
# 1. Resolve and bundle (multi-file refs must be followed)
npx @redocly/cli bundle openapi/openapi.yaml --output /tmp/candidate.yaml
# 2. Fetch the last released document (git tag, artifact registry, or hosted version)
curl -s https://api.example.com/openapi/2.4.0.yaml -o /tmp/base.yaml
# 3. Classify the diff
npx @redocly/cli lint /tmp/candidate.yaml
# (openapi-diff tooling reports breaking / non-breaking sections)
openapi-diff /tmp/base.yaml /tmp/candidate.yaml --fail-on breakingThe exact tool varies — openapi-diff (Java), oasdiff (Go, with a rich ruleset and GitHub Actions support), Redocly's lint and bundle with custom rules, and Spectral for style rather than compatibility. The mechanics that matter are the same: the base must be the released spec, not main; multi-file documents must be bundled identically on both sides; and the job needs a deliberate override path.
Design the override path
Not every flagged change should block forever — sometimes you must ship a breaking change with a version bump and a migration window. What you do not want is engineers disabling the check wholesale. Use explicit, reviewable suppression:
- Breaking changes fail the build and print the affected operations with the exact rule (
response-property-removed: data.legacyId). - An approved breaking change is recorded in a changelog entry in the same PR and requires an owner approval; the gate can read a label or a
breaking: acceptedmarker attached to the change record. - The release process then bumps the API version (or date-based version) and the deprecation timeline starts.
This converts "breaking change" from a hidden fact into a recorded decision, which is the entire goal.
Deprecations before removals
The diff gate catches removals at the moment of removal, which is too late for consumers. Pair it with a deprecation policy expressed in the spec:
get:
deprecated: true
description: Use GET /v2/projects instead. Sunset 2027-01-15.
responses:
'200':
headers:
Deprecation:
schema: { type: string }
example: '@1768425600'
Sunset:
schema: { type: string }
example: 'Thu, 15 Jan 2027 00:00:00 GMT'RFC 8594 Deprecation and Sunset headers let observability detect who still calls deprecated operations. A useful CI/reporting job inverts the usual check: list deprecated operations with live traffic and no sunset date, and list operations past sunset still receiving calls.
Where teams get the comparison wrong
- Diffing source layout instead of bundled output. A refactor that moves a schema between files looks like massive removal/addition when files are compared directly; bundling first makes the diff semantic.
- Comparing against main instead of the release. Main already contains unreleased changes; the baseline must be what customers currently consume, tagged per version.
- Ignoring examples and content types. Removing an example is not breaking, but removing a supported media type is; the ruleset must cover
contentkeys, security requirements, and parameterinlocations. - Forgetting webhooks and SSE event contracts. Removing a webhook event type or an SSE event name is as breaking as removing a response field; treat the event catalog as part of the diff.
- Generated SDKs without the gate. Client generation hides nothing; a removed field breaks the union type at compile time, which is the same signal arriving later. Run the diff first, where the message is actionable.
Style linting is the other half
Compatibility diffing answers "does this break clients"; style linting answers "does this match how we design APIs." A Spectral or Redocly ruleset enforces naming conventions (snake_case properties, verb-first operationIds), required descriptions and examples, consistent pagination envelopes, the problem+json error schema, and tag hygiene. Run both: style rules keep new operations from inventing a fourth pagination style; the diff gate protects existing consumers. Together they make API review a ten-minute exception review instead of an hour of re-deriving the contract from implementation code.
Making the spec trustworthy enough to gate on
A CI gate is only as good as the document's relationship to reality. Two closing practices:
- Verify the service against the spec. Replay recorded traffic or run contract tests against staging and validate responses; a spec that omits fields the API actually returns produces false confidence.
- Recover drift from code when needed. For services where the spec was neglected, an AST-based code scan can regenerate candidate operations and schemas for review, closing gaps the diff tool cannot see.
In Powerduck the spec is the working document — designed, debugged, mocked, and tested in one place — so the compatibility diff is a natural step before publishing a revision, with webhooks and SSE events included in the change report alongside HTTP operations. The hosted side keeps versioned revisions, which gives the CI job a stable "last released" baseline.
What to read next: Swagger 2.0 to OpenAPI 3.x migration is often the first time a team establishes a release baseline, and generate a TypeScript client from OpenAPI shows the compile-time second gate on the consumer side.