π Ultra-fast, Schema-Aware JSON Patch Generation with Human-Readable Diffing
fast-json-schema-patch is a high-performance JSON patching library designed to create efficient, schema-driven patches. It intelligently understands your data structure, enabling optimized, semantic diffs, and also provides fast, human-friendly diffing tools for frontend applications. It outperforms many popular alternatives in both speed and memory usage.
π§ Schema-Driven Diffing Unlike generic JSON diff libraries, fast-json-schema-patch leverages schema-based diff plans to:
-
β‘ Optimize array diffing using the best strategy for each case (LCS, primary key matching, etc.).
-
π§© Generate semantic patches that align with your dataβs meaning, not just its shape.
-
π― Compare objects intelligently by focusing only on relevant fields.
π‘ Ideal for applications where the JSON structure is known and schema-driven diffs are important.
bun add fast-json-schema-patchThe core of the library is the JsonSchemaPatcher, which uses a diff plan to optimize patch generation.
import { JsonSchemaPatcher, buildPlan } from 'fast-json-schema-patch';
// 0. Describe your data with a JSON Schema. `id` is `required`, so the
// `users` array auto-detects the `primaryKey` diffing strategy (CORE Β§3.5).
const schema = {
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
status: { type: 'string' },
},
},
},
},
};
// 1. Build a plan from the schema β this needs to be done only once per schema.
const plan = buildPlan({ schema });
// 2. Instantiate the patcher with the plan
const patcher = new JsonSchemaPatcher({ plan });
// Original and modified documents
const original = {
users: [
{ id: 'user1', name: 'John Doe', status: 'active' },
{ id: 'user2', name: 'Jane Smith', status: 'inactive' },
],
};
const modified = {
users: [
{ id: 'user1', name: 'John Doe', status: 'online' }, // Changed
{ id: 'user3', name: 'Sam Ray', status: 'active' }, // Added
],
// user2 was removed
};
// 3. Generate the optimized patch
const patch = patcher.execute({ original, modified });
console.log(patch);
// Output (modifications first, then removals, then `/-` appends β see
// GEN Β§4.1.4 for the primaryKey strategy's normative emission order):
// [
// { op: "replace", path: "/users/0/status", value: "online", oldValue: "active" },
// { op: "remove", path: "/users/1", oldValue: { id: "user2", name: "Jane Smith", status: "inactive" } },
// { op: "add", path: "/users/-", value: { id: "user3", name: "Sam Ray", status: "active" } }
// ]This library extends the standard JSON Patch format by adding an oldValue field to remove and replace operations.
This addition makes UI rendering and state reconciliation easier but is not part of the strict RFC 6902 specification.
original and modified must be JSON values β the value space produced by JSON.parse (null, boolean, number, string, plain object, or array). Handling of non-JSON inputs is not fully defended against:
Date,RegExp,Map, and other class instances are compared as opaque leaves (viavalueOf()/===, so two differentDates will correctly diff instead of silently comparing equal), but they are otherwise echoed as-is intovalue/oldValueand will not round-trip throughJSON.stringify/JSON.parsethe way a plain object would.undefinedvalues andfunction-valued fields are not valid JSON; diffing them can produce operations that omitvalueentirely.- Circular references are not detected and will overflow the call stack.
If your data may contain any of the above, round-trip it through JSON.parse(JSON.stringify(doc)) (or an equivalent JSON-safe transform) before diffing.
You can apply patches back to a document β no separate library needed. applyPatch supports all six RFC 6902 operations (add, remove, replace, move, copy, test), so it can also apply patches produced by other RFC 6902 tools.
import { applyPatch, invertPatch } from 'fast-json-schema-patch';
// Reconstruct the modified document from the original + patch
const result = applyPatch(original, patch);
// Compute an undo patch (uses the original, pre-patch document)
const undo = invertPatch(original, patch);
applyPatch(result, undo); // deep-equals `original`Behavior guarantees:
- Immutable: the input document is never mutated. Untouched subtrees are shared by reference between input and output (copy-on-write), so applying a small patch to a large document is cheap.
β οΈ Because of that sharing, don't mutate the returned document in place β pass{ cloneResult: true }if you need a fully independent copy to mutate. - Atomic: if any operation fails, a
JsonPatchErroris thrown (with a machine-readablecode, the failingoperation, andoperationIndex) and your document is left untouched. - Validating: pass
{ validateOldValues: true }to check each operation'soldValueagainst the current document before applying β an implicittestfor everyremove/replace, useful when the document may have drifted. - Safe: pointer segments that would mutate the prototype chain (
__proto__,constructorβprototype) are rejected with anUNSAFE_KEYerror.
Migrating from fast-json-patch: applyPatch(doc, patch) here returns the new document directly (not an OperationResult[] with .newDocument) and never mutates the input (no mutateDocument flag). Use toRfc6902(patch) to strip this library's oldValue fields before handing patches to strict third-party tools.
βΉοΈ Arrays diffed with the
primaryKeystrategy are compared semantically: patches capture item modifications, removals, and additions, but not pure reorderings of otherwise-identical items. Applying such a patch reconstructs the modified document's content exactly; surviving items keep their original relative order and additions are appended at the end. EnableemitMoves(see theJsonSchemaPatcheroptions below) to upgrade this to exact order fidelity: relocated items are expressed asmoveops and the patched document matchesmodified's order exactly, not just its content.
If you're using Zod for runtime validation, you can easily generate JSON schemas for use with fast-json-schema-patch. Zod 4 introduced native JSON Schema conversion:
import * as z from "zod/v4";
import { JsonSchemaPatcher, buildPlan } from 'fast-json-schema-patch';
// Define your Zod schema
const userSchema = z.object({
users: z.array(z.object({
id: z.string(),
name: z.string(),
status: z.enum(['active', 'inactive', 'online'])
}))
});
// Convert Zod schema to JSON Schema
const jsonSchema = z.toJSONSchema(userSchema);
// Use the JSON schema to build a plan
const plan = buildPlan({ schema: jsonSchema });
const patcher = new JsonSchemaPatcher({ plan });This integration makes it seamless to leverage your existing Zod schemas for optimized JSON patching. For more details on Zod's JSON Schema conversion, see the official documentation.
When you need to present diffs to users, raw JSON patches can be hard to work with. StructuredDiff helps you transform those patches into structured, human-readable diffs that are fast, memory-efficient, and frontend-friendly.
It organizes changes into:
Parent diffs: Changes outside of specific arrays.
Child diffs: Changes within a target array, keyed by unique identifiers.
This makes it easy to build side-by-side diff views or activity feeds.
import { StructuredDiff } from 'fast-json-schema-patch';
// Assuming `original`, `modified`, `patch`, and `plan` from the previous example
// 1. Instantiate the aggregator with the plan
const structuredDiff = new StructuredDiff({plan});
// 2. Execute the aggregation
const aggregatedResult = structuredDiff.execute({
original,
modified,
pathPrefix: '/users',
});
// 3. Use the result to render a UI
console.log(aggregatedResult.parentDiff); // Shows changes outside the /users array
console.log(aggregatedResult.childDiffs['user2']); // Shows user2 was removed
console.log(aggregatedResult.childDiffs['user3']); // Shows user3 was addedCreates a plan for optimizing JSON patch generation based on a JSON schema.
buildPlan(options)
options: An object with the following properties:schema: A JSON Schema object that describes your data structure.primaryKeyMap(optional): A record mapping path prefixes to primary key field names.basePath(optional): The base path for the schema traversal.primaryKeyCandidates(optional): Override the ordered field-name candidate list used to auto-detect an array's primary key (default["id", "name", "port"]). The first candidate that is arequiredstring/numberproperty of the item schema is selected. Pass[]to disable auto-detection entirely for arrays without an explicitprimaryKeyMapentry, keeping them on thelcs/uniquestrategy instead of (sometimes wrongly) matching on a mutable field likename.onWarning(optional):(message: string) => void, called instead of writing toconsole.warnwhen traversal hits a$refit cannot resolve. Only local same-document references ($refstarting with#/) are ever resolved β anything else (a relative/absolute URL, or any other non-#/-rooted string) always triggers this callback and is treated as unresolvable, so the branch is skipped and traversal continues elsewhere. Omit to stay silent (the default).
- Returns: A
Planobject that can be used withJsonSchemaPatcherandStructuredDiff.
The main class for generating patches.
new JsonSchemaPatcher({ plan, includeOldValue?, emitMoves?, wholesaleReplaceFallback?, ignorePaths? })
plan: APlanobject created bybuildPlanthat describes your data structure and desired diffing strategies.includeOldValue(optional, defaulttrue): Whenfalse, omits the non-standardoldValuefield from everyremove/replaceop, producing smaller, strict RFC 6902-shaped patches.invertPatchstill works withoutoldValuepresent, since it recovers prior values from the original document you pass it.emitMoves(optional, defaultfalse): Whentrue, a relocated (unchanged) array element is expressed as a single RFC 6902moveinstead of a remove+add pair, across all three array strategies (lcs,unique,primaryKey). Also upgrades theuniqueandprimaryKeystrategies to reconstruct the modified array's order exactly (see the note on theprimaryKeystrategy below).wholesaleReplaceFallback(optional, defaultfalse): Whentrue, caps a heavily-rewritten array's patch size β if the estimated size of one array's granular ops would exceed the array's own serialized size, the differ emits a single whole-arrayreplaceinstead. Applies independently to every array, including nested ones; small/typical diffs are unaffected.ignorePaths(optional, default none): A list of object-member JSON Pointers whose subtrees are treated as equal β no ops are emitted at or beneath them, in any strategy. Use*for an array level, e.g.["/users/*/updatedAt", "/meta/revision"]ignores every user'supdatedAtand the top-levelmeta.revision. Two items differing only in ignored fields collapse to nothing (or a singlemoveunderemitMoves) rather than a remove+add. An invalid pointer (an array-index or-segment, a rootless pointer, or one that would ignore aprimaryKeyfield) throws aTypeErrorat construction.
patcher.execute({original, modified})
original: The original document to compare from.modified: The modified document to compare to.- Returns: A
DiffOperation[]β anadd/remove/replace/movesubset of the widerOperationtype thatapplyPatch/invertPatchaccept (moveonly appears whenemitMovesis enabled).DiffOperation[]is usable anywhere anOperation[]is expected.
Applies an RFC 6902 patch to a document and returns the resulting document.
applyPatch(document, patches, options?)
document: The document to apply the patch to (never mutated).patches: An array of JSON Patch operations (add,remove,replace,move,copy,test).options.validateOldValues(optional): Whentrue,remove/replaceoperations carrying anoldValueare validated against the current document value before being applied.- Returns: The patched document. Unchanged subtrees are shared by reference with the input.
- Throws:
JsonPatchErrorif any operation cannot be applied; the input document is left untouched.
Computes the inverse of a patch, for undo/rollback flows.
invertPatch(document, patches)
document: The original (pre-patch) document the patch was generated from.patches: The patch to invert.- Returns: An array of operations such that
applyPatch(applyPatch(document, patches), invertPatch(document, patches))deep-equalsdocument.
The main class for creating human-readable diffs.
new StructuredDiff({plan})
plan: APlanobject created bybuildPlanthat describes your data structure and desired diffing strategies.
structuredDiff.execute(config)
config: AStructuredDiffConfigobject with the following properties:pathPrefix: The path prefix of the array to aggregate (e.g.,/users).original: The original document.modified: The modified document.patches(optional): Pre-computed patch array fromJsonSchemaPatcher. If not provided, patches will be generated automatically.
- Returns: A
StructuredDiffResultobject containingparentDiffand a record ofchildDiffs.
Registers (or clears) a callback for the one internal warning StructuredDiff/DiffFormatter can hit: a JSON parse failure while building a path map for diff-line formatting (not expected in normal operation, but defended against rather than left to throw).
setWarningHandler(handler)
handler:(message: string) => void, orundefinedto go back to silent (the default β no handler is registered until one is set).
Run benchmarks on your own data:
# Run the benchmark suite
bun run compareA Go port lives in go/ as a separate module
(github.com/flightcontrolhq/fast-json-schema-patch/go, package schemapatch,
zero third-party dependencies). It implements the same spec-v1
(SPEC.md) behavior and is held byte-identical to this TypeScript
reference by the shared conformance vectors (spec/vectors)
plus a seeded differential-fuzz corpus (spec/fuzz).
go get github.com/flightcontrolhq/fast-json-schema-patch/goCompare diffs two typed Go values against a schema in one call (nil schema =
schemaless); CompareJSON is the same for raw JSON bytes:
patch, err := schemapatch.Compare(schema, original, modified) // structs, maps, slices
patch, err := schemapatch.CompareJSON(schemaBytes, originalBytes, modifiedBytes)See go/README.md for the quick start, the determinism contract
(bytes preserve source order; encoding/json canonicalizes maps), capability
options, and the subdirectory-module tagging note (go/vX.Y.Z).
A capability comparison against the closest alternative in each ecosystem: wI2L/jsondiff v0.7.1 (Go), fast-json-patch v3.1.1 (JS), jsondiffpatch v0.7.3 (JS), and rfc6902 v5.1.2 (JS). Versions are the ones pinned in this repo's package.json/go-bench/go.mod devDependencies, so every competitor claim below was checked against the exact source in node_modules/the Go module cache, not against upstream docs of an unknown version. No claim here is a benchmark result β see Benchmarking Your Use Case and the notebook for performance.
Capabilities only, no marketing language. "Yes" means the capability is a documented, exported part of the library's public API; "Partial" means it exists but with a caveat spelled out in the cell; "No" means it doesn't exist. Every claim about us cites a runnable proof (a test file or vector name) in an inline HTML comment; every competitor claim cites the exact source location checked.
| Capability | Ours (TS + Go) | wI2L/jsondiff v0.7.1 (Go) | fast-json-patch v3.1.1 (JS) | jsondiffpatch v0.7.3 (JS) | rfc6902 v5.1.2 (JS) |
|---|---|---|---|---|---|
| RFC 6902 output | Yes β diff emits a pure add/remove/replace(/move) subset of RFC 6902; the non-standard oldValue extension is stripped by toRfc6902() for strict consumers. |
Yes β that's the package's stated purpose (README.md intro). | Yes β compare()/generate() emit add/remove/replace ops (README.md:207-289). |
Partial β native output is its own "delta" format; RFC 6902 is available only via the separate jsonpatch formatter (README.md:34; lib/formatters/jsonpatch.js). |
Yes, but createPatch generation is "limited to remove, add, and replace operations" β no move/copy (README.md:85). |
| Schema-aware array strategies | Yes β buildPlan derives a per-array strategy (lcs / unique / primaryKey) from a JSON Schema. |
No β one generic recursive/LCS comparison; Compare/CompareJSON take no schema argument (compare.go). |
No β arrays are diffed positionally, treated like objects with numeric keys (module/duplex.mjs _generate). |
No β array diffing is objectHash-based LCS matching, not schema-driven (README.md "smart array diffing"). |
No β no schema concept anywhere in the public API (index.d.ts). |
| Typed-value entry API | Yes β TS JsonSchemaPatcher.execute({original, modified}) takes parsed JsonValues directly; Go Compare(schema, source, target any, ...) diffs typed structs/maps/slices. |
Yes β Compare(source, target interface{}, ...) (compare.go:11). |
Yes β compare(document1, document2) (README.md:261-264). |
Yes β diff(left, right) (README.md usage example). |
Yes β createPatch(input, output) (README.md). |
| Raw-JSON entry | Partial β Go CompareJSON(schema, original, modified []byte, ...) decodes raw bytes in one call; the TS engine has no equivalent (caller JSON.parses first). |
Yes β CompareJSON(source, target []byte, ...) (compare.go:21). |
No β no byte-input entry point distinct from compare() (README.md API list). |
No β no byte-input entry point distinct from diff() (README.md API list). |
No β no byte-input entry point distinct from createPatch() (README.md API list). |
| Diff | Yes. | Yes (core purpose). | Yes (core purpose). | Yes (core purpose). | Yes (core purpose). |
| Apply | Yes β applyPatch implements all six RFC 6902 ops, immutable and atomic. |
No β an apply method exists but its own doc comment says it "will NEVER be exported... is feature-wise out of scope of the project" (apply.go:19-22, citing wI2L/jsondiff#28). |
Yes β applyPatch/applyOperation, all six ops (README.md:89-155). |
Partial β applyJsonPatchRFC6902 exists, but its doc comment says it "is used for testing to ensure the JSON-Patch formatter output is correct" and supports "only add, remove, replace and move" (jsonpatch-apply.d.ts). |
Yes β applyPatch(object, patch) (README.md). |
Invert (incl. WITHOUT oldValue) |
Yes β invertPatch(originalDocument, patch) recovers prior values by walking the original document, so it inverts correctly even when the patch carries no oldValue field. |
Partial β Patch.Invert() exists, but requires the patch to have been generated with Invertible() (a test op preceding every remove/replace); a remove/replace without a preceding test returns ErrNonReversible (patch.go:34-58). |
No β no standalone invert function; compare(a, b, invertible=true) can only generate a patch with test ops baked in up front (README.md:207-289). |
No, for RFC 6902 β reverse()/unpatch() operate on jsondiffpatch's own delta format, which stores both values by construction; there is no RFC-6902-patch inversion function (README.md:83-85). |
No β no invert/reverse export (index.d.ts exports only applyPatch, createPatch, createTests). |
| Move factorization | Yes, scoped to one array β emitMoves expresses a relocated element as a single move within its own array; it does not factor arbitrary add/remove pairs elsewhere in the document into moves. |
Yes, document-wide β Factorize() turns any matching remove+add pair anywhere in the tree into move/copy (README.md "Operations factorization"). |
No β source comment: "if ever "move" operation is implemented here..." confirms it's never generated (module/duplex.mjs:124). | Partial β the jsonpatch formatter emits move for LCS-matched array reorders (requires an objectHash function), not general cross-path factorization (lib/formatters/jsonpatch.js:129). |
No β generation "limited to remove, add, and replace" (README.md:85). |
| Size rationalization (wholesale) | Yes β wholesaleReplaceFallback caps one array's patch at roughly its own serialized size by substituting a single whole-array replace when the granular op stream would be larger. |
Yes β Rationalize() replaces a set of child operations with a single parent replace when it marshals smaller (README.md "Operations rationalization"). |
No β no such option (README.md API list). | No β no such option (README.md API list). | No β no such option (README.md API list). |
| Ignores | Yes β ignorePaths (JSON Pointer + * wildcard syntax), threaded through every array strategy so ignored subtrees compare equal. |
Yes β Ignores() (variadic JSON Pointer list), marked experimental (README.md "Ignores"). |
No β no ignore/filter option (README.md API list). | Partial β propertyFilter is a (name, context) => boolean callback, not JSON-Pointer/wildcard syntax (README.md:229-231). |
No β no ignore option (README.md API list). |
| Deterministic cross-language output | Yes β the TS and Go engines are checked against the same 300+ conformance vectors plus a 576-record seeded differential-fuzz corpus, zero mismatches. | N/A β single-language implementation, no second engine to be deterministic against. | N/A β single-language implementation. | N/A β single-language implementation. | N/A β single-language implementation. |
| Conformance test suite | Yes β 300+ spec-linked, language-neutral vectors under spec/vectors/{diff,apply,plan,invert}, documented in spec/vectors/README.md, executed by both engines. |
Partial β has internal testdata/tests/jsonpatch/*.json fixtures used by its own _test.go files; not documented or published as a spec-linked suite for external implementers (no README under testdata/tests). |
No published cross-implementation vector suite β ordinary unit tests only. | No published cross-implementation vector suite β ordinary unit tests only. | No published cross-implementation vector suite β ordinary unit tests only. |
| Second-language engine | Yes β a full Go port (go/, package schemapatch), zero third-party dependencies, implementing the same spec-v1 contract. |
No β Go only. | No β JS only. | No β JS only. | No β JS only. |
| Structured/human-readable diff layer | Yes β StructuredDiff aggregates a patch into parent/child diffs keyed by primary key, for building diff UIs (TS only, no Go equivalent yet). |
No β public output is only the Patch/Operation type. |
No. | Partial β ships html/annotated/console formatters that render its delta for human viewing, but no queryable structured object for building a custom UI (lib/formatters/{html,annotated,console}.js). |
No. |
- Competitor versions are pinned exactly as declared in this repo:
fast-json-patch@3.1.1,jsondiffpatch@0.7.3,rfc6902@5.1.2inpackage.jsondevDependencies;github.com/wI2L/jsondiff@v0.7.1ingo-bench/go.mod. Every claim above was checked against that exact version's source (node_modules/<pkg>/ the Go module cache), not against upstreammain/latest docs. - Every "Ours" cell's HTML comment names a real test file or vector directory in this repository at the time of writing; run
bun test/cd go && go test ./...to reproduce. - "N/A" (not "No") is used only for the two cross-language rows, where the capability requires a second implementation to exist at all β a single-language library cannot meaningfully have or lack it.