# How to diff two semantic models from successive spec versions

> Extract a model from each spec version, diff the two model files, and read the result beside what oasdiff and openapi-diff report about the same release.

Source: https://voxgig.com/howto/diff-two-semantic-models-across-versions

- Audience: platform-team
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Run @voxgig/apidef over version N and version N+1 of the document, and write each model as sorted JSON that carries the extractor version and the spec hash. Diff the two files entity by entity. The model diff reports what your generated SDK, CLI, and MCP server will gain or lose. A spec diff from oasdiff or openapi-diff reports what changes on the wire, which is a different list. Stop at reporting; gating is a separate decision.

---
## You will need

Node 22 or later, two versions of one OpenAPI 3 document, and `@voxgig/apidef` installed from a
lockfile. Verified 2026-10-21 against Node 22.22.0 and @voxgig/apidef 8.25.3. The two documents
here are `specs/orders-v1.json` and `specs/orders-v2.json`, a minor release that adds a field, a
response code, two endpoints, and two description edits. The spec-level reports were recorded
once from oasdiff and OpenAPITools openapi-diff and committed under `recordings/`. The checked
commands read those files rather than running Go or Java.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A model JSON diff from @voxgig/apidef](https://github.com/voxgig/apidef) | You generate SDKs, CLIs, or MCP servers from the extracted model and want the generated names that move | A model file per version, an extractor version to pin, and blindness to anything below the model, such as a status code | You generate nothing from a model, or the question is what callers see on the wire |
| [oasdiff changelog](https://www.oasdiff.com/docs/getting-started) | You want every wire-level change a consumer can feel, each with a stable id and a severity | A Go binary or Docker image in the pipeline, and one entry per endpoint, so a shared schema edit arrives several times | You need the SDK's view, which arrives here as one property addition per endpoint |
| [OpenAPITools openapi-diff](https://github.com/OpenAPITools/openapi-diff) | A Java shop that wants a compatible-or-incompatible verdict and a report people can read | A Java runtime or Docker, a report that names endpoints rather than properties, and a README that promises OpenAPI 3.0 | Your document is 3.1, or you need the changed property named rather than the schema flagged |

Voxgig maintains apidef. This page compares it with oasdiff and OpenAPITools openapi-diff.

The three tools answer three questions. oasdiff lists seven entries for this release, one per
endpoint a change reaches, so a field added to a shared schema arrives four times. openapi-diff
folds the release into one verdict, backward compatible, and names endpoints rather than
properties. The model diff names the field once and the new entity once, and the status code is
below its floor. Expect to keep two of the three.

## Extract both versions into sorted model files

`extract.mjs` runs apidef with its file-writing step switched off, reads the in-memory model, and
keeps the parts a generator consumes. Those are each entity's id field, its fields with their
type, required, format, and read-only flags, and its operations as lists of method and path with
their arguments. An argument added, or made required, changes a generated method's signature.
apidef's own [model reference](https://github.com/voxgig/apidef/blob/main/docs/reference/model.md)
documents the raw shape those come from. Descriptions stay out, because they reach generated
code as comments only.

```js title="extract.mjs"
/**
 * The model file: entities with their id field, fields, operations, and the operations'
 * arguments, the document it came from with its hash, and the extractor that produced it.
 * Descriptions are left out on purpose. They reach the generated surfaces as comments, and a
 * comment edit is not a surface change.
 */
export async function extractModel(specPath) {
  const kit = await rawModel(specPath)
  const entities = {}
  for (const [name, entity] of Object.entries(kit.entity)) {
    const operations = {}
    const args = {}
    for (const [op, value] of Object.entries(entity.op ?? {})) {
      if (!value) continue
      operations[op] = value.points.map(endpointOf).sort()
      for (const point of value.points) Object.assign(args, argsOf(point))
    }
    entities[name] = { id: entity.id?.field ?? null, fields: fieldsOf(entity), operations, args }
  }
  const bytes = readFileSync(specPath)
  return sorted({
    extractor: EXTRACTOR,
    spec: {
      file: basename(specPath),
      sha256: createHash('sha256').update(bytes).digest('hex'),
      title: kit.info.title,
      version: kit.info.version,
    },
    entities,
  })
}
```

Run it once per version.

```bash
node extract.mjs specs/orders-v1.json models/orders-v1.model.json
node extract.mjs specs/orders-v2.json models/orders-v2.model.json
```

```text output
orders-v1.json -> models/orders-v1.model.json: 2 entities, 7 operations, 9 fields, extracted by @voxgig/apidef 8.25.3
orders-v2.json -> models/orders-v2.model.json: 3 entities, 9 operations, 15 fields, extracted by @voxgig/apidef 8.25.3
```

Two parts of the file exist for the diff rather than for a generator. `extractor` records the
package and version that produced the model, and `spec.sha256` is the hash of the document bytes,
so a model can be traced to the exact file it came from. `spec.version` is the document's own
`info.version`, which the specification keeps apart from the OpenAPI version and from the
implementation's.[^1]

```json title="models/orders-v2.model.json"
  "extractor": {
    "name": "@voxgig/apidef",
    "version": "8.25.3"
  },
  "spec": {
    "file": "orders-v2.json",
    "sha256": "b6eb084570de76a1376ce02a5093642068e6c91cfd0151d3631ad243d530463e",
    "title": "Orders API",
    "version": "1.1.0"
  }
}
```

Every object's keys are sorted before the file is written, so two extractions of one document are
byte-identical.[^2] A text diff of two model files is then a diff of the API rather than of key
order.

## Diff the two models and read what moved

`diff.mjs` walks the union of entity names, then operations, then fields, and reports every
difference as one line. Before it compares anything it reads the two extractor stamps, and it
refuses when they differ.

```js title="diff.mjs"
export function diffModels(base, head, { allowExtractorDrift = false } = {}) {
  const a = base.extractor
  const b = head.extractor
  if ((a.name !== b.name || a.version !== b.version) && !allowExtractorDrift) {
    throw new ExtractorMismatch(
      `the models were extracted by ${a.name} ${a.version} and ${b.name} ${b.version}. ` +
        'Re-extract both with one version, or pass --allow-extractor-drift to see the churn.',
    )
  }
  const changes = []
  for (const entity of keysOf(base.entities, head.entities)) {
    const x = base.entities[entity]
    const y = head.entities[entity]
    if (!x) { changes.push({ kind: 'entity added', entity, detail: describe(y) }); continue }
    if (!y) { changes.push({ kind: 'entity removed', entity, detail: describe(x) }); continue }
```

```bash
node diff.mjs models/orders-v1.model.json models/orders-v2.model.json
```

```text output
base  Orders API 1.0.0  (orders-v1.json, @voxgig/apidef 8.25.3)
head  Orders API 1.1.0  (orders-v2.json, @voxgig/apidef 8.25.3)

+ field added       order.shipped_at       string date-time, optional, read-only
+ entity added      shipment               operations list, load; fields carrier, id, order_id, shipped_at, tracking_code
2 model changes
```

Two lines. The release added `shipped_at` to the order and introduced a shipment entity with a
list and a load operation. An SDK generated from the second model gains exactly that: one field
on one class, and one class with two methods. The 404 response and the description edits are
absent by construction, since none of them reaches the model. The command exits 0 whatever it
finds. Whether a change may ship is a release decision, and
[detecting breaking changes between two OpenAPI versions in CI](/howto/detect-breaking-changes-between-openapi-versions-in-ci)
is where that decision has a tool.

## Set the spec diffs beside it

`reconcile.mjs` reads the two committed recordings and a live model diff, then lines the three up
by endpoint, using the model's operation lists to find which entity owns each path.

```bash
node reconcile.mjs
```

```text output
GET /orders  (order.list)
  oasdiff changelog  response-optional-property-added
  openapi-diff       200 changed
  model diff         + field order.shipped_at
POST /orders  (order.create)
  oasdiff changelog  response-optional-property-added
  openapi-diff       request changed, 201 changed
  model diff         + field order.shipped_at
GET /orders/{orderId}  (order.load)
  oasdiff changelog  response-non-success-status-added, response-optional-property-added
  openapi-diff       404 added, 200 changed
  model diff         + field order.shipped_at
PATCH /orders/{orderId}  (order.update)
  oasdiff changelog  response-optional-property-added
  openapi-diff       request changed, 200 changed
  model diff         + field order.shipped_at
GET /orders/{orderId}/shipments  (shipment.list)
  oasdiff changelog  endpoint-added
  openapi-diff       new
  model diff         + entity shipment
GET /shipments/{shipmentId}  (shipment.load)
  oasdiff changelog  endpoint-added
  openapi-diff       new
  model diff         + entity shipment

oasdiff changelog: 7 entries on 6 endpoints
openapi-diff: 6 endpoints, 2 new
model diff: 2 changes on 2 entities
oasdiff diff: 14 description edits, 2 distinct; the changelog carries 0 of them and so does the model
```

Read it by column. oasdiff reports one entry per endpoint and change kind, so one new property on
a shared schema becomes four entries, and the 404 on `GET /orders/{orderId}` gets an entry of its
own. openapi-diff also reports per endpoint and adds a status code and a verdict.[^3] The model
diff has two things to say and repeats them under every endpoint the entity owns, because that is
its grain. The last line is the two description edits, one to `info` and one to the order's
`status`. The full `oasdiff diff` output carries the second thirteen times, once per place the
schema appears. The `changelog` reports neither, and the model never carried them. Neither kind of
diff is wrong. They measure different things, and a release process that wants both answers runs
both.

## Check it worked

Seven tests with the [Node test runner](https://nodejs.org/api/test.html) pin the behavior. The
one that matters most takes the first document, adds one read-only property to the order schema,
extracts, and asserts that the diff is exactly one line.

```js title="diff.test.mjs"
test('one additive change to the document is exactly one change in the model', async () => {
  const plusOne = await extractModel(
    edited('specs/orders-v1.json', (doc) => {
      doc.components.schemas.Order.properties.shipped_at = { type: 'string', format: 'date-time', readOnly: true }
    }),
  )
  assert.deepEqual(diffModels(v1, plusOne).map(brief), ['field added order shipped_at'])
})
```

```bash
node --test diff.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

`# pass 7` is the line to look for. The other six pin determinism, the empty self-diff, the
two-line release, the refusal on mismatched stamps, the shape a rename takes, and an argument
added, then made required.

## When it goes wrong

The diff is full of churn for an API that did not move. The two model files came from two
releases of the extractor, and the newer one records something the older one did not.
`pitfall.mjs` compares a model of the first document written by an earlier release of the same
major version, committed under `recordings/`, with a fresh extraction.

```bash
node pitfall.mjs
```

```text output
same document:  yes, sha256 5d4ae413a862f561
extracted by:   @voxgig/apidef 8.0.0, then @voxgig/apidef 8.25.3

with the stamp checked: refused. the models were extracted by @voxgig/apidef 8.0.0 and @voxgig/apidef 8.25.3. Re-extract both with one version, or pass --allow-extractor-drift to see the churn.

with --allow-extractor-drift: 2 changes on a document that did not change
~ field changed     customer.email         string, required -> string email, required
~ field changed     order.created_at       string, optional -> string date-time, optional, read-only
```

The older release recorded neither the `format` nor the `readOnly` flag of a property, so two
fields changed in the model while the document's hash stayed the same. The stamp exists so the
diff refuses this comparison by default. Fix it by re-extracting both versions with the
extractor pinned in `package.json`, and treat `--allow-extractor-drift` as a way to inspect the
extractor, never the API.

A renamed collection appears as a removal and an addition. The diff keys on entity names and has
no signal that `client` is `customer` under a new path, so the test pins that pair rather than
pretending to detect a rename. Record the rename as a decision beside the model, which is what
[reconciling an extracted entity model with a domain model](/howto/reconcile-an-extracted-model-with-a-domain-model)
sets up, and read the two lines together.

The model diff is empty for a pull request that changed the document. The edit sat below the
model: a description, an example, or a non-success status code. That is the job of the spec diff, so
run `oasdiff changelog` beside the model diff rather than widening the model until it is the
document again.

## When not to do this

Do not turn this diff into a release gate. It reports what a generated surface gains or loses and
says nothing about whether a caller breaks. The oasdiff
[breaking changes](https://www.oasdiff.com/docs/breaking-changes) command has a published rule set
for that question and exits non-zero on a match, which is the shape a gate needs.

Do not use apidef's model diff when nothing is generated from the model. If your SDKs are
hand-written, or come from a generator that reads the document directly, the model file is an
artifact only this check reads. The spec diff already answers your question.

Do not diff models extracted by two versions of the extractor and read the result as API change.
The stamp is in the file so the diff can refuse.

Do not read a quiet model diff as a quiet release. A new 404, a changed description, or a
tightened example reaches every client without moving the model.

## Related how-tos

- [Reconcile an extracted entity model with a domain model](/howto/reconcile-an-extracted-model-with-a-domain-model)

- [Detect breaking changes between two OpenAPI versions in CI](/howto/detect-breaking-changes-between-openapi-versions-in-ci)

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

## Last verified

Verified 2026-10-21 against Node 22.22.0 and @voxgig/apidef 8.25.3. Every output block is what the
command preceding it printed, run in the page's code directory after `npm ci`. The reports under
`recordings/` were produced once, locally, by oasdiff v1.33.0 built with `go install` and by
`openapi-diff-cli` 2.1.7 run with `java -jar`, against the two committed documents, and the checked
commands read those files. The older model under `recordings/` was written by the same
`extract.mjs` with @voxgig/apidef 8.0.0 installed in place of the pinned version.

[^1]: An OpenAPI document carries two version numbers in its first lines, and the specification
keeps them apart with a parenthesis. [OpenAPI 3.0.3](https://spec.openapis.org/oas/v3.0.3) defines
`info.version` as the version of the OpenAPI document, which is distinct from the OpenAPI
Specification version or the API implementation version. The model file here adds a fourth, the
extractor's, and the diff reads that one before it reads anything else.

[^2]: Sorting keys before writing JSON has a standard of its own.
[RFC 8785](https://www.rfc-editor.org/rfc/rfc8785.html) defines a canonical form for JSON, named
JCS, because hashing and signing need data expressed in an invariant format. It sorts object
properties recursively, comparing names as arrays of UTF-16 code units treated as unsigned
integers, independent of locale settings. JavaScript's default string sort compares the same units
in the same order, so `Object.keys(value).sort()` follows the rule in the RFC by the design of the
engine rather than by citation.

[^3]: The openapi-diff [README](https://github.com/OpenAPITools/openapi-diff) opens by comparing
two OpenAPI specifications in the 3.x line and lists support for OpenAPI 3.0 among its features.
A 3.1 document sits inside the first sentence and outside the second. It asks for Java 8, and
its thanks credit an earlier project, swagger-diff, as a source of inspiration for the tool. The
jar on Maven Central is
[`openapi-diff-cli`](https://central.sonatype.com/artifact/org.openapitools.openapidiff/openapi-diff-cli),
a name the README does not use.