apidiff
apidiff compares two versions of an OpenAPI 3.0/3.1 spec, classifies every difference as breaking, potentially breaking, safe or informational (taking request vs response direction into account), and renders the result as a console report, a Keep a Changelog entry, JSON or GitHub annotations. It is built for CI: fail a pull request that accidentally breaks API consumers, and publish a human-readable API changelog from the same run.
Features
- Loads YAML/JSON specs from files, URLs or git refs (
git:main:openapi.yaml, read withgit show). Resolves internal and relative-file$refs, including cyclic schemas. - Matches operations by method and path template;
/pets/{id}->/pets/{petId}is the same endpoint. - Compares schemas structurally rather than by name:
allOfis flattened,oneOf/anyOfmembers are matched by similarity, 3.0nullableequals a 3.1type: [x, "null"], discriminators,additionalProperties, enums, formats and constraints are all understood. - Direction-aware classification with 210 catalogued change kinds (see docs/change-catalog.md).
- Reports: grouped console report with JSON pointers, Markdown changelog (Added / Changed / Deprecated / Removed), JSON, GitHub workflow annotations.
--fail-on breaking|potentially-breakingexit codes,.apidiff.tomlpolicy with expiring allow rules,x-apidiff-ignorevendor extension, semver bump advice.apidiff summaryfor quick spec statistics andapidiff catalogto list change kinds.
Install
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/apidiff --help
Requires Python 3.12+. Dependencies: click, rich, pyyaml.
Quickstart
# two files
apidiff diff examples/v1.yaml examples/v2.yaml
# a PR against main, straight from git (run inside the repository)
apidiff diff --base git:main:openapi.yaml --head openapi.yaml --fail-on breaking
# changelog entry for the docs
apidiff diff examples/v1.yaml examples/v2.yaml -f markdown -o CHANGELOG-api.md --fail-on none
# spec statistics
apidiff summary examples/v2.yaml
Exit codes: 0 nothing at or above the --fail-on threshold (after policy), 1 threshold reached, 2 bad input (unreadable spec, bad policy file, usage error).
Usage
apidiff diff [BASE HEAD] [--base SPEC] [--head SPEC] [options]
-f, --format [text|markdown|json|github] default: text
-o, --output FILE write the report to a file
--fail-on [breaking|potentially-breaking|none]
default: policy `fail_on`, else breaking
--min-severity [breaking|potentially-breaking|safe|info]
hide lower severities in the text report
--config FILE policy file (default: ./.apidiff.toml if present)
--repo DIR repository used for git:REF:PATH (default: cwd)
--today YYYY-MM-DD override "today" for allow-rule expiry
--release-date YYYY-MM-DD date in the Markdown heading
apidiff summary SPEC [-f text|json] [--repo DIR]
apidiff catalog [-f text|json|markdown]
A spec is a file path, an http(s):// URL or git:REF:PATH, where PATH is relative to the repository root. Relative $refs inside a git or URL spec are resolved against the same ref or URL.
Console report
apidiff diff examples/v1.yaml examples/v2.yaml --config examples/.apidiff.toml --min-severity breaking (exit code 1):
apidiff examples/v1.yaml (1.4.0) -> examples/v2.yaml (2.0.0)
BREAKING (18)
GET /customers/{customerId}
response-became-nullable Response value became nullable (property `description`)
/paths/~1customers~1{customerId}/get/responses/200/content/application~1json/schema/properties/default_payment/properties/description
response-property-removed Property removed from response: `legacy_ref`
/paths/~1customers~1{customerId}/get/responses/200/content/application~1json/schema/properties/default_payment/properties/legacy_ref
operation-security-requirement-removed Security requirement removed: ApiKeyAuth
/paths/~1customers~1{customerId}/get/security
GET /payments
request-maximum-tightened Request maximum tightened: maximum: 100 -> 50
/paths/~1payments/get/parameters/1/schema/maximum
response-became-nullable Response value became nullable (property `description`)
/paths/~1payments/get/responses/200/content/application~1json/schema/properties/data/items/properties/description
response-property-removed Property removed from response: `legacy_ref`
/paths/~1payments/get/responses/200/content/application~1json/schema/properties/data/items/properties/legacy_ref
response-media-type-removed Response media type removed: `text/csv`
/paths/~1payments/get/responses/200/content/text~1csv
POST /payments
parameter-added-required Required parameter added: `Idempotency-Key` (header)
/paths/~1payments/post/parameters/0
request-enum-value-removed Enum value removed from request: `JPY` (property `currency`)
/paths/~1payments/post/requestBody/content/application~1json/schema/properties/currency/enum
request-property-became-required Request property became required: `customer_id`
/paths/~1payments/post/requestBody/content/application~1json/schema/properties/customer_id
request-max-length-tightened Request max length tightened: maxLength: 255 -> 140 (property `description`)
/paths/~1payments/post/requestBody/content/application~1json/schema/properties/description/maxLength
request-media-type-removed Request media type removed: `application/x-www-form-urlencoded`
/paths/~1payments/post/requestBody/content/application~1x-www-form-urlencoded
response-success-status-removed Success response removed: `201`
/paths/~1payments/post/responses/201
POST /payments/{id}/cancel
operation-removed Operation removed [allowed: Cancel was replaced by refunds; agreed with partners in PAY-212]
/paths/~1payments~1{id}~1cancel/post
GET /payments/{paymentId}
response-became-nullable Response value became nullable (property `description`)
/paths/~1payments~1{paymentId}/get/responses/200/content/application~1json/schema/properties/description
response-property-removed Property removed from response: `legacy_ref`
/paths/~1payments~1{paymentId}/get/responses/200/content/application~1json/schema/properties/legacy_ref
POST /payments/{paymentId}/capture
response-became-nullable Response value became nullable (property `description`)
/paths/~1payments~1{paymentId}~1capture/post/responses/200/content/application~1json/schema/properties/description
response-property-removed Property removed from response: `legacy_ref`
/paths/~1payments~1{paymentId}~1capture/post/responses/200/content/application~1json/schema/properties/legacy_ref
warning: allow rule 'response-property-removed' for '*/legacy_ref' expired on 2026-01-01; matching changes are enforced again
Summary: 18 breaking, 15 potentially-breaking, 27 safe, 7 info (1 allowed by policy)
Semver: MAJOR bump (1.4.0 -> 2.0.0), 18 breaking changes
Each line is change-id message, followed by the JSON pointer into the head spec (the base spec for removals). Without --min-severity the potentially-breaking, safe and info groups follow. The same comparison on all severities finds 18 breaking, 15 potentially breaking, 27 safe and 7 informational changes.
Markdown changelog
apidiff diff examples/v1.yaml examples/v2.yaml --config examples/.apidiff.toml -f markdown --release-date 2026-10-16:
## [2.0.0] - 2026-10-16
> Suggested version bump: **major** (1.4.0 -> 2.0.0), 18 breaking changes.
### Added
- **Potentially breaking** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Discriminator mapping added to response: `wallet` (property `method`)
- **Potentially breaking** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: oneof alternative added to response: `Wallet` (property `method`)
- **Potentially breaking** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Enum value added to response: `refunded` (property `status`)
- `GET /balance`, `GET /customers/{customerId}`, `GET /payments` and 3 more: Alternative security requirement added: OAuth2
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Property added to response: `receipt_url`
- `GET /payments`: Enum value added to request: `refunded`
- `POST /payments`: Success response added: `200`
- OAuth scope added: `customers:read`
- Server added: `https://api.example.com/v2`
- Server added: `https://sandbox.example.com/v2`
- `POST /refunds`: Operation added
### Changed
- **BREAKING** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Response value became nullable (property `description`)
- **BREAKING** `GET /payments`: Request maximum tightened: maximum: 100 -> 50
- **BREAKING** `POST /payments`: Required parameter added: `Idempotency-Key` (header)
- **BREAKING** `POST /payments`: Request property became required: `customer_id`
- **BREAKING** `POST /payments`: Request max length tightened: maxLength: 255 -> 140 (property `description`)
- **Potentially breaking** `GET /payments`: Default value changed: 20 -> 10
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Response max length tightened: maxLength: 255 -> 140 (property `description`)
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Type narrowed in response: number -> integer (property `fee`)
- `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Enum restriction added to response value: `visa`, `mastercard`, `amex` (property `brand`)
- `GET /customers/{customerId}`: Format added to response value: none -> e164 (property `phone`)
- `POST /payments`: Type widened in request: integer -> number (property `amount`)
- `/payments/{paymentId}`: Path parameter renamed: `/payments/{id}` -> `/payments/{paymentId}`
- `/payments/{paymentId}/capture`: Path parameter renamed: `/payments/{id}/capture` -> `/payments/{paymentId}/capture`
### Deprecated
- `GET /balance`: Operation deprecated
### Removed
- **BREAKING** `GET /customers/{customerId}`, `GET /payments`, `GET /payments/{paymentId}` and 1 more: Property removed from response: `legacy_ref`
- **BREAKING** `GET /customers/{customerId}`: Security requirement removed: ApiKeyAuth
- **BREAKING** `GET /payments`: Response media type removed: `text/csv`
- **BREAKING** `POST /payments`: Enum value removed from request: `JPY` (property `currency`)
- **BREAKING** `POST /payments`: Request media type removed: `application/x-www-form-urlencoded`
- **BREAKING** `POST /payments`: Success response removed: `201`
- **BREAKING** `POST /payments/{id}/cancel`: Operation removed (allowed: Cancel was replaced by refunds; agreed with partners in PAY-212)
- **Potentially breaking** Server removed: `https://api.example.com/v1`
- **Potentially breaking** Server removed: `https://sandbox.example.com/v1`
- `GET /customers/{customerId}`: Error response removed: `404`
A shared schema that changes (here Payment, used by four endpoints) is listed once with the affected endpoints.
JSON
-f json emits {base, head, summary, semver, failed, warnings, changes[]}; each change has id, severity, category, message, endpoint, location, old, new and allowed.
GitHub annotations
-f github prints workflow commands, ::error for breaking, ::warning for potentially breaking and ::notice for safe changes (policy-allowed changes become notices):
::warning file=api/v1.yaml,title=server-removed::Server removed: `https://api.example.com/v1` at /servers
::error file=api/v1.yaml,title=response-property-removed::GET /customers/{customerId}: Property removed from response: `legacy_ref` at /paths/~1customers~1{customerId}/get/...
A GitHub Actions job:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: pip install ./apidiff
- run: apidiff diff --base git:origin/main:openapi.yaml --head openapi.yaml -f github
Spec summary
apidiff summary examples/v2.yaml:
Payments API 2.0.0 (OpenAPI 3.1.0)
Paths 6
Operations 7 (GET 4, POST 3)
Tags 2
Schemas 8
Security schemes 2
Servers 2
Responses 10
Deprecated operations 1
Missing operationId 0
No error responses 4
Undocumented responses 0
Deprecated
GET /balance
Without error responses
POST /payments/{paymentId}/capture
GET /customers/{customerId}
GET /balance
POST /refunds
"Undocumented responses" counts responses with no description, or no content schema where a body is expected (everything except 1xx, 3xx, 204, 205).
Configuration
.apidiff.toml
[settings]
fail_on = "breaking" # default for --fail-on
[[allow]]
id = "operation-removed" # change id, glob allowed ("response-*")
path = "POST /payments/*/cancel" # glob matched against the path template, "METHOD path" or the JSON pointer
reason = "Cancel was replaced by refunds; agreed with partners in PAY-212"
expires = 2027-03-31 # inclusive; afterwards the change is enforced again and a warning is printed
Allowed changes stay in every report (marked [allowed: reason]) but do not count toward the exit code. Semver advice still reflects the true change.
x-apidiff-ignore
Place on an operation, path item, parameter, response, media type or any schema:
x-apidiff-ignore: true # skip this whole subtree
x-apidiff-ignore: [response-enum-value-added] # ignore these change ids (globs allowed) in the subtree
Architecture
| module | role |
|---|---|
loader.py |
file/git/URL loading, YAML/JSON parsing, $ref resolution into a shared (cyclic) graph |
catalog.py |
change kinds: id, severity, changelog category, rationale |
differ.py |
document level: paths, operations, parameters, bodies, responses, ignore handling |
schema_diff.py, schema_util.py |
direction-aware structural schema comparison, allOf flattening, type normalisation, oneOf member matching |
security_diff.py |
security schemes and per-operation requirements |
policy.py, advice.py |
.apidiff.toml rules with expiry; semver advice |
report.py, summary.py, cli.py |
renderers, summary statistics, click CLI |
See docs/design.md for the algorithms and docs/change-catalog.md for the catalogue.
Tests
.venv/bin/pytest
187 tests cover $ref and cycles, path matching, every schema change kind (table driven, both directions), parameters, bodies, responses, security, policy allow/expiry, git-ref loading from a temporary repository, URL loading, report formats and CLI exit codes.
Limitations
- OpenAPI 3.0/3.1 only (Swagger 2.0 is rejected). Webhooks, callbacks, links and
patternProperties/prefixItemsare not compared. - Changes are located by JSON pointer, not by source line, so GitHub annotations point at the file rather than a line.
- A shared schema that changes is reported once per endpoint that uses it in the text/JSON reports.
- Severity is conservative by design; use policy rules for deliberate exceptions.
- Request/response direction is inferred from where a schema is used; schemas reached through both directions are checked in each.