Catch Breaking API Changes in CI
Your team ships a minor API change on Friday afternoon. The diff looks tiny — just renaming a field and making a new parameter required. Tests pass. You merge.
Monday morning: three mobile apps are broken. The renamed field returns null for 40% of users. The new required parameter wasn't sent by any client built before last week. Your partner integrations are returning 400 errors. And the webhook consumers? They're silently dropping payloads because the schema changed without warning.
This is the breaking API change problem: it's not whether your server works — it's whether every client that depends on you still works. And traditional testing doesn't catch it, because your server returns perfectly valid responses. The breakage is on the client side.
In this tutorial, you'll learn how to detect breaking API changes with API Contract Guardian, a CLI tool that diffs OpenAPI specs, classifies every change by severity, and gates your CI pipeline so incompatible changes never reach production silently.
What You'll Set Up
By the end of this tutorial, you'll have:
- API Contract Guardian installed and ready on your machine
- Two OpenAPI spec files — baseline and a PR with breaking changes
- Diff detection running with colored, classified output
- JSON output for CI/CD pipeline integration
- A GitHub Actions workflow that blocks PRs with breaking changes
1. Install API Contract Guardian
API Contract Guardian is a Python CLI — install it in seconds:
Or install directly from GitHub:
Verify it's working:
You should see the available commands: diff, check, and init.
2. Create Sample OpenAPI Specs
Let's simulate a real API versioning scenario. Create two spec files — one for your current production API, and one for a PR that introduces changes.
openapi-base.yaml (production)
openapi: "3.1.0"
info:
title: User Service API
version: "2.4.0"
paths:
/users:
get:
summary: List users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
responses:
"200":
description: Paginated user list
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
$ref: "#/components/schemas/User"
/users/{id}:
get:
summary: Get user by ID
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
required:
- id
- name
- email
properties:
id:
type: integer
name:
type: string
email:
type: string
format: email
avatar_url:
type: string
format: uri
role:
type: string
enum: [admin, member, guest]
Now save this as openapi-pr.yaml — the same API, but with several breaking changes sneaked in:
openapi: "3.1.0"
info:
title: User Service API
version: "2.5.0"
paths:
/users:
get:
summary: List users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: consent # ← NEW REQUIRED param
in: query
required: true
schema:
type: string
responses:
"200":
description: Paginated user list
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
$ref: "#/components/schemas/User"
/users/{id}:
get:
summary: Get user by ID
parameters:
- name: id
in: path
required: true
schema:
type: string # ← CHANGED from integer to string!
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
required:
- id
- name
# email REMOVED from required! # ← Was required, now optional
- consent # ← NEW required field
properties:
id:
type: string # ← CHANGED from integer
name:
type: string
email:
type: string
format: email
# avatar_url DELETED entirely # ← Removed field
role:
type: string
enum: [admin, member, guest, superadmin] # ← New enum value (non-breaking)
id changed from integer to string — any client parsing it as an int will crash. (2) avatar_url was deleted — any client referencing it gets null. (3) consent is now required in both the GET /users query and the User schema — any client not sending it gets a 400. These are exactly the kinds of changes that pass server-side tests but break every existing consumer.
3. Diff the Specs
Compare the two specs directly:
API Contract Guardian will output a classified report showing every change, grouped by severity:
══════════════════════════════════════════════════════════════════════
API Contract Guardian — Breaking Change Report
══════════════════════════════════════════════════════════════════════
🔴 BREAKING (4)
────────────────────────────────────────────────────────────────────
User.id type changed integer → string
User.avatar_url field removed was: string ($uri)
GET /users new required consent (query param)
User new required consent (schema field)
🟠 WARNING (1)
────────────────────────────────────────────────────────────────────
User.email required removed (breaking if clients depend on it)
🔵 INFO (2)
────────────────────────────────────────────────────────────────────
User.role enum added +superadmin
info.version version bump 2.4.0 → 2.5.0
API Contract Guardian classifies each change automatically:
- 🔴 Breaking — Changes that will break existing clients: removed fields, type changes, new required parameters. These should block deployment.
- 🟠 Warning — Changes that may break clients depending on usage: removed required constraints, relaxed validations. Worth reviewing.
- 🔵 Info — Non-breaking changes: new optional fields, added enum values, version bumps. Safe to ship.
4. Check a Single Endpoint
Sometimes you just want to check one endpoint, not the whole spec:
This isolates the diff to a specific path — useful for large APIs where you want to focus on the change you just made.
5. JSON Output for CI
For pipeline integration, use JSON output:
This produces structured JSON:
{
"base": "openapi-base.yaml",
"target": "openapi-pr.yaml",
"breaking": [
{
"location": "components.schemas.User.properties.id",
"change": "type_changed",
"from": "integer",
"to": "string",
"impact": "Clients parsing id as integer will crash or produce wrong values"
},
{
"location": "components.schemas.User.properties.avatar_url",
"change": "field_removed",
"impact": "Clients referencing avatar_url will receive null or KeyError"
},
{
"location": "paths./users.get.parameters.consent",
"change": "new_required_param",
"impact": "Requests without consent param will receive 400 Bad Request"
},
{
"location": "components.schemas.User.required",
"change": "new_required_field",
"field": "consent",
"impact": "Responses without consent field will fail schema validation"
}
],
"warnings": [
{
"location": "components.schemas.User.required",
"change": "required_removed",
"field": "email",
"impact": "Clients expecting email to always be present may break"
}
],
"infos": [
{ "location": "components.schemas.User.properties.role", "change": "enum_value_added", "value": "superadmin" },
{ "location": "info.version", "change": "version_bump", "from": "2.4.0", "to": "2.5.0" }
],
"exit_code": 1
}
The exit_code field tells you the result:
- 0 → No breaking changes found (safe to merge)
- 1 → Breaking changes detected (should block deploy)
6. CI/CD Integration (GitHub Actions)
Here's the real payoff — gating your pipeline so that breaking API changes never ship silently. Add this GitHub Actions workflow to your repo:
# .github/workflows/api-contract-check.yml
name: API Contract Check
on:
pull_request:
paths:
- 'openapi/**'
- 'docs/api/**'
jobs:
check-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install API Contract Guardian
run: pip install api-contract-guardian
- name: Diff OpenAPI specs
run: |
api-contract-guardian diff \
openapi/openapi-base.yaml \
openapi/openapi-pr.yaml \
--output silent || echo "::warning::Breaking API changes detected!"
--output silent in CI. The exit code is all you need — non-zero means breaking changes were found. Capture the full JSON report as a CI artifact for debugging.
For a more detailed pipeline that surfaces the actual breaking changes in the PR conversation:
- name: Generate contract report
id: contract
run: |
api-contract-guardian diff \
openapi/openapi-base.yaml \
openapi/openapi-pr.yaml \
--output json > contract-report.json
echo "breaking=$(jq -r '.exit_code' contract-report.json)" >> $GITHUB_OUTPUT
cat contract-report.json
- name: Comment PR with breaking changes
if: steps.contract.outputs.breaking == '1'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('contract-report.json'));
const breaking = report.breaking.map(b =>
`- 🔴 \`${b.location}\`: ${b.change} — ${b.impact}`
).join('\n');
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: `## ⚠️ Breaking API Changes Detected\n\n${breaking}\n\nFix these or add backward-compatible paths before merging.`
});
Now every PR that touches your OpenAPI spec gets automatically checked. If breaking changes are found, the PR gets a comment detailing every breaking change and the CI check fails — no more silent compatibility breaks.
7. Initialize a Contract Project
For teams that manage multiple APIs, use a config file:
This creates an .api-contract.yaml file where you can define baseline specs, paths, and rules:
baseline: openapi/openapi.yaml
specs:
- openapi/openapi.yaml
- openapi/admin-api.yaml
- openapi/partner-api.yaml
rules:
type_changed: breaking
field_removed: breaking
new_required_param: breaking
enum_value_added: info
new_optional_field: info
ignore:
- "info.version"
- "x-internal"
Then run with just:
What Counts as Breaking?
API Contract Guardian uses a comprehensive classification system. Here's the full reference:
| Change Type | Severity | Example |
|---|---|---|
| Field removed | 🔴 Breaking | avatar_url deleted from User schema |
| Type changed | 🔴 Breaking | id changed from integer to string |
| New required param | 🔴 Breaking | consent added as required query param |
| New required field | 🔴 Breaking | consent added to User required list |
| Required removed | 🟠 Warning | email no longer required |
| Enum value removed | 🔴 Breaking | guest removed from role enum |
| Enum value added | 🔵 Info | superadmin added to role enum |
| New optional field | 🔵 Info | nickname added (not required) |
| Format changed | 🟠 Warning | email format changed to email |
| Endpoint removed | 🔴 Breaking | DELETE /users/{id} removed |
| New endpoint | 🔵 Info | GET /users/search added |
Real-World API Breakage Horror Stories
Still think manual review is enough? Here's what happens when breaking changes slip through:
- The $2M mobile outage: A B2B SaaS company changed
user.idfromintegertostring(UUID migration). The server worked perfectly. Their iOS app crashed on launch for 200K users because the JSON parser expected an integer. The hotfix took 48 hours because App Store review takes time. API Contract Guardian would have caught this in CI in 2 seconds. - The silent data loss: A fintech API removed the
memofield from transaction responses. Three partner integrations were usingmemofor reconciliation. They didn't notice the field was gone — they just started losing memo data silently. It took a month before an auditor flagged the discrepancy. The cleanup cost more than the feature. - The cascade failure: A team made
regiona required parameter on an existing endpoint. Every internal service that called that endpoint withoutregionstarted getting 400 errors. The cascading failures took down auth, billing, and notifications simultaneously. The postmortem conclusion: "We should have caught this in CI."
Next Steps
API Contract Guardian is one of 11 tools in the DevForge suite, all designed to catch problems before they reach production:
- ConfigDrift — Catch environment config drift before it breaks production
- json2sql — Convert JSON data to INSERT statements with smart type inference
- DeployDiff — See the full cost and blast radius of every infra change
- DeadCode — Detect and remove unused exports, dead routes, and orphaned CSS
- SchemaForge — Bidirectional ORM schema converter (Drizzle, Prisma, SQL DDL)
Stay in the Loop
PyPI publishing is coming soon. Get notified when we ship.