---
title: Specdiff
description: Breaking-change detection for JSON Schema and OpenAPI documents.
url: https://pr-1-7abef52380a8.thally.app/specdiff-overview
---

# Specdiff

Breaking-change detection for JSON Schema and OpenAPI documents.

Specdiff compares a *before* and *after* document and classifies every change as
`breaking`, `warning`, or `info`, with a JSON-pointer path, a stable rule code,
and a human-readable message.

Teams run Specdiff in CI (`specdiff old.yaml new.yaml --fail-on breaking`) and
coding agents call it over the Model Context Protocol (`specdiff_compare`)
before opening a pull request.

## Why Specdiff

- **Direction-aware.** The same schema edit means different things on the request
  and the response side. A new required field in a request body is `breaking`;
  the same field in a response is `info`. A new enum value in a response is a
  `warning` because clients may not handle it. See
  [direction-dependent severity](#request-and-response-direction).
- **Stable rule codes.** Every change carries a code from the
  [45-rule catalogue](/specdiff-rules) (`property-removed`,
  `constraint-tightened`, `security-requirement-added`, ...). Use
  `explainRule(code)` for the description and remediation, or `ignoreRules` /
  `overrides` in `DiffOptions` to tune them.
- **Precise paths.** Changes point at the exact location as an RFC 6901 JSON
  pointer, such as
  `#/paths/~1pets/get/responses/200/content/application~1json/schema/items/properties/tag`.
- **Zero runtime dependencies** in `@specdiff/core`. Local `$ref` resolution
  (`#/definitions`, `#/$defs`, `#/components/...`) with cycle protection is
  built in.
- **Deterministic output.** `DiffResult.changes` is always sorted by severity,
  then path, then code, so reports diff cleanly and snapshot well.
- **Three surfaces, one engine.** Library
  ([`@specdiff/core`](/specdiff-core-api)), CLI
  ([`@specdiff/cli`](/specdiff-cli)), and MCP server
  ([`@specdiff/mcp`](/specdiff-mcp)) share the same core and produce the same
  `DiffResult`.

## Packages

| Package | Description | Runtime dependencies |
| --- | --- | --- |
| [`@specdiff/core`](/specdiff-core-api) | The differ, rule catalogue, formatters, and pointer helpers. | none |
| [`@specdiff/cli`](/specdiff-cli) | The `specdiff` command. | `@specdiff/core`, `yaml` |
| [`@specdiff/mcp`](/specdiff-mcp) | The `specdiff-mcp` stdio MCP server. | `@specdiff/core`, `yaml`, `@modelcontextprotocol/sdk`, `zod` |

All packages are ESM, TypeScript-first, and require Node.js 22 or later.

## Install

#### Library

    ```bash
    npm install @specdiff/core
    ```

#### CLI

    ```bash
    npm install -D @specdiff/cli
    npx specdiff --help
    ```

#### MCP server

    Add to `.mcp.json` or `claude_desktop_config.json`:

    ```json
    {
      "mcpServers": {
        "specdiff": {
          "command": "npx",
          "args": ["-y", "@specdiff/mcp"]
        }
      }
    }
    ```

    The server reads files relative to the directory it is launched from and
    refuses paths that escape it.

## Quick example

```ts
import { readFileSync } from "node:fs";
import { diffDocuments, exceedsThreshold, formatMarkdown } from "@specdiff/core";

const before = JSON.parse(readFileSync("user-v1.json", "utf8"));
const after = JSON.parse(readFileSync("user-v2.json", "utf8"));

const result = diffDocuments(before, after, {
  ignoreRules: ["description-changed"],
  overrides: { "default-changed": "breaking" },
});

console.log(formatMarkdown(result));
console.log(exceedsThreshold(result, "warning")); // true if warning or breaking changes exist
```

## Configuration options

The `DiffOptions` object is accepted by every diff entry point:

| Option | Type | Description |
| --- | --- | --- |
| `ignoreRules` | `RuleCode[]` | Drop changes from these rules entirely. |
| `overrides` | `Partial<Record<RuleCode, Severity>>` | Force severity for a rule. Applied after the direction table. |
| `ignorePaths` | `string[]` | JSON pointer prefixes to ignore. A leading `#` is optional. |
| `direction` | `"request" \| "response" \| "neutral"` | Direction for plain JSON Schema diffs. Ignored for OpenAPI, whose direction is derived from usage context. Default: `"neutral"`. |

## Request and response direction

Validation-centric thinking ("stricter is breaking") is correct only for data
flowing *into* a service. For data flowing *out*, consumers are broken by
*losing* guarantees. Specdiff carries a direction through the schema walk:

- **OpenAPI:** parameters and request bodies are diffed as `request`; response
  bodies as `response`. A shared `components/schemas` entry is compared at each
  place it is used, with that use's direction.
- **JSON Schema:** `neutral` by default (mirrors the request column); pass
  `direction: "request"` or `"response"` to `diffJsonSchema` or `--direction` to
  the CLI.

| Rule | `request` | `response` | `neutral` |
| --- | --- | --- | --- |
| `required-added` | breaking | info | breaking |
| `required-property-added` | breaking | info | breaking |
| `required-removed` | info | breaking | info |
| `enum-value-added` | info | warning | info |
| `enum-value-removed` | breaking | info | breaking |
| `constraint-tightened` | breaking | info | breaking |
| `constraint-relaxed` | info | warning | info |
| `additional-properties-restricted` | breaking | info | breaking |
| `nullable-added` | info | breaking | info |
| `nullable-removed` | breaking | info | breaking |
| `composition-variant-added` | info | warning | info |
| `composition-variant-removed` | breaking | info | breaking |

`property-removed`, `type-changed`, and `const-changed` are breaking in every
direction. `DiffOptions.overrides` is applied after the direction table and wins.

## CI recipe

```yaml
# .github/workflows/api-compat.yml
name: API compatibility
on: pull_request
jobs:
  specdiff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Extract the base branch spec
        run: git show origin/${{ github.base_ref }}:openapi.yaml > /tmp/openapi-base.yaml
      - name: Fail on breaking changes
        run: npx -y @specdiff/cli /tmp/openapi-base.yaml openapi.yaml --fail-on breaking --format markdown | tee -a "$GITHUB_STEP_SUMMARY"
```

## FAQ

#### Which JSON Schema drafts are supported?

  The differ compares keywords rather than validating against a meta-schema, so
  draft-04 through 2020-12 documents work. It understands `definitions` and
  `$defs`, `items` arrays and `prefixItems`, `nullable` (OpenAPI 3.0), and
  `"null"` in `type` arrays. Draft-04 boolean
  `exclusiveMinimum`/`exclusiveMaximum` are ignored; numeric forms are compared.

#### What happens to remote $refs?

  Only local fragment references (`#/...`) are followed. A remote
  (`https://...`, `other.json#/...`) or missing reference is reported once as
  `unresolved-ref` (warning) and nothing behind it is compared. Bundle external
  references into a single document first if you need them analysed.

#### Can I change a rule's severity or silence it?

  Yes. Pass `overrides: { "default-changed": "breaking" }` or
  `ignoreRules: ["description-changed"]` in `DiffOptions`, or use
  `--ignore-rule` on the CLI and `ignoreRules` in `specdiff_compare`. Overrides
  are applied after the direction table. `ignorePaths` / `--ignore-path` silence
  everything under a JSON-pointer prefix.

#### Why does one change to a shared component appear several times?

  Because `components/schemas` entries are compared where they are used, with
  that use's direction. Three operations returning `Pet` produce three reports
  when `Pet` changes, each with its own path and severity. Components that no
  operation references are not compared.

#### Does Specdiff compare info, tags, examples, or vendor extensions?

  No. It focuses on the contract: paths, operations, parameters, request bodies,
  responses, media types, security, and servers, plus the schemas inside them.
  `description` changes on schemas are reported as `info`.