A Go port of fast-json-schema-patch — schema-aware JSON patch
generation, application, and inversion. This is the Go engine: a separate
implementation of the same normative behavior described in SPEC.md
(spec-v1), verified against the shared conformance vectors under
spec/vectors and a differential-fuzz corpus that pins it
byte-for-byte to the TypeScript reference.
- Module path:
github.com/flightcontrolhq/fast-json-schema-patch/go - Package name:
schemapatch - Requires Go >= 1.22
- Zero third-party dependencies — standard library only.
go get github.com/flightcontrolhq/fast-json-schema-patch/goThe fastest way in is Compare — hand it two Go values (typed structs, maps,
slices, or already-decoded JSON) and a JSON Schema, and it returns the patch. It
marshals each argument with encoding/json under the hood, so it feels like the
typed-value diffing you may know from wI2L/jsondiff.
package main
import (
"encoding/json"
"fmt"
schemapatch "github.com/flightcontrolhq/fast-json-schema-patch/go"
)
// Deployment-like types with a keyed containers slice.
type Container struct {
Name string `json:"name"`
Image string `json:"image"`
}
type PodSpec struct {
Containers []Container `json:"containers"`
}
type Deployment struct {
Replicas int `json:"replicas"`
Template PodSpec `json:"template"`
}
func main() {
// Describe your data with a JSON Schema. Each container's `name` is
// `required`, so the containers slice auto-detects the `primaryKey` diffing
// strategy (CORE §3.5): elements are matched by key, not by position.
schema := json.RawMessage(`{
"type": "object",
"properties": {
"template": {
"type": "object",
"properties": {
"containers": {
"type": "array",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string"},
"image": {"type": "string"}
}
}
}
}
}
}
}`)
original := Deployment{
Replicas: 2,
Template: PodSpec{Containers: []Container{
{Name: "web", Image: "nginx:1.25"},
{Name: "sidecar", Image: "envoy:1.29"},
}},
}
modified := Deployment{
Replicas: 3,
Template: PodSpec{Containers: []Container{
// containers reordered; only web's image changed
{Name: "sidecar", Image: "envoy:1.29"},
{Name: "web", Image: "nginx:1.27"},
}},
}
patch, err := schemapatch.Compare(schema, original, modified)
if err != nil {
panic(err)
}
out, err := json.MarshalIndent(patch, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(out))
// The reorder is absorbed by the keyed match — only the changed fields emit:
// [
// {"op":"replace","path":"/replicas","value":3,"oldValue":2},
// {"op":"replace","path":"/template/containers/0/image","value":"nginx:1.27","oldValue":"nginx:1.25"}
// ]
}Pass nil for the schema to diff schemalessly (every array uses lcs). The
capability toggles from NewPatcher work here too:
schemapatch.Compare(schema, a, b, schemapatch.EmitMoves(true)).
CompareJSON is the same one-call flow when your documents are already
serialized. It decodes each through the ordered value model, so object member
order and numeric literal text are preserved end-to-end (CORE §1.2) — a guarantee
Compare cannot make because encoding/json canonicalizes on the way in (see
Determinism).
schema := []byte(`{ "type": "object", "properties": { ... } }`)
original := []byte(`{"users":[{"id":"user1","status":"active"}]}`)
modified := []byte(`{"users":[{"id":"user1","status":"online"}]}`)
patch, err := schemapatch.CompareJSON(schema, original, modified)
if err != nil {
panic(err)
}
out, err := schemapatch.EncodeOperations(patch) // compact JSON patch array
if err != nil {
panic(err)
}
fmt.Println(string(out))CompareJSON(nil, a, b) diffs schemalessly. This emits the same bytes as the
TypeScript quick start — the two engines are
byte-identical.
Both entry points are deterministic for fixed inputs, but they canonicalize differently:
CompareJSON(bytes) preserves source shape.Decodekeeps object member order and number literal text exactly as written (CORE §1.2). This is the documented deterministic route — use it when member order or a specific numeric literal must survive into the patch.Compare(values) canonicalizes viaencoding/json. Struct fields marshal in declaration order (deterministic, and yours to control).map[K]Vmembers marshal in sorted key order — deterministic across runs, but a map's original insertion order is not preserved (Go maps have none). Non-finite floats (NaN,±Inf) are rejected with an error, consistent with the value model's own rejection of numbers with no JSON representation (CORE §1.2).
Compare/CompareJSON build a Plan and run a Patcher for you. When you need
plan reuse across many diffs, non-default BuildPlanOptions (PrimaryKeyMap,
BasePath, custom candidates), or to hold documents in the ordered value model
directly, drive the three stages yourself:
schema, err := schemapatch.Decode([]byte(`{ "type": "object", "properties": { ... } }`))
if err != nil {
panic(err)
}
// Build the plan once, reuse it for every diff against this schema.
plan, err := schemapatch.BuildPlan(schema, schemapatch.BuildPlanOptions{})
if err != nil {
panic(err)
}
patcher, err := schemapatch.NewPatcher(plan) // add capability options here
if err != nil {
panic(err) // only fails for invalid IgnorePaths (GEN §10)
}
original, err := schemapatch.Decode([]byte(`{"users":[{"id":"user1","status":"active"}]}`))
if err != nil {
panic(err)
}
modified, err := schemapatch.Decode([]byte(`{"users":[{"id":"user1","status":"online"}]}`))
if err != nil {
panic(err)
}
patch := patcher.Execute(original, modified)
out, err := schemapatch.EncodeOperations(patch)
if err != nil {
panic(err)
}
fmt.Println(string(out))// Reconstruct the modified document from original + patch.
result, err := schemapatch.ApplyPatch(original, patch, schemapatch.ApplyOptions{})
if err != nil {
panic(err)
}
// Compute an undo patch (uses the original, pre-patch document).
undo, err := schemapatch.InvertPatch(original, patch)
if err != nil {
panic(err)
}
applied, err := schemapatch.ApplyPatch(result, undo, schemapatch.ApplyOptions{})
if err != nil {
panic(err)
}
// applied deep-equals original — check with schemapatch.DeepEqual(applied, original)ApplyPatch guarantees (CORE §5):
- Immutable — the input document is never mutated; untouched subtrees are
shared by reference (copy-on-write). Pass
ApplyOptions{CloneResult: true}if you need a fully independent copy to mutate afterwards. - Atomic — on the first failing op it returns a
*PatchError(withCodeandOpIndex) and the input is left untouched. Match specific failures witherrors.Is(err, schemapatch.ErrTestFailed)(one exported sentinel per code) or pull out the*PatchErrorwitherrors.As. - Validating —
ApplyOptions{ValidateOldValues: true}checks eachremove/replaceop'soldValueagainst the current document first (an implicittest), failing withOLD_VALUE_MISMATCHon drift. - Safe — pointer segments that would mutate the prototype chain
(
__proto__,constructor) are rejected withUNSAFE_KEY.
NewPatcher accepts functional options mirroring the TypeScript capabilities
(CONF §5), and Compare/CompareJSON forward their trailing opts straight
through to it. The defaults reproduce pre-capability output byte-for-byte:
| Option | Default | Effect |
|---|---|---|
IncludeOldValue(bool) |
true |
Attach the full prior value as oldValue on every remove/replace (CORE §4.4). Pass false to omit it. |
EmitMoves(bool) |
false |
Express relocations of otherwise-identical items as move ops so the applied document matches modified's order exactly, not just its content (GEN §8). |
WholesaleReplaceFallback(bool) |
false |
For a heavily-rewritten array, replace it wholesale when that is smaller than the element-wise edit script (GEN §9), capping patch size. The size comparison uses the spec's pinned estimate of fjsp's own op encoding (oldValue and all); if you re-encode ops into a different wire shape, a borderline cutover can differ from your encoding's true optimum by a few bytes. |
IgnorePaths(paths...) |
none | Object-member JSON Pointers whose subtrees are treated as equal — no ops at or beneath them, in any strategy; use * for an array level (GEN §10). An invalid pointer makes NewPatcher return an error. |
patcher, err := schemapatch.NewPatcher(plan,
schemapatch.IncludeOldValue(false),
schemapatch.EmitMoves(true),
schemapatch.IgnorePaths("/users/*/updatedAt"),
)
if err != nil {
panic(err)
}
patch := patcher.Execute(original, modified)BuildPlan takes plan-shaping options via BuildPlanOptions:
| Field | Effect |
|---|---|
PrimaryKeyMap |
map[docPath]keyField — force the primaryKey strategy on specific array paths, overriding auto-detection; paths the schema never reaches (including a nil/empty schema) still register, so the override works without schema coverage (CORE §3.4.3). The diff-time applicability gate still applies. |
PrimaryKeyCandidates |
Override the ordered auto-detection candidate list (default ["id","name","port"], CORE §3.5.3). An empty, non-nil slice disables auto-detection entirely — every object array falls back to lcs. A nil slice keeps the default. |
BasePath |
Restrict and relativize the plan to the subtree at or under this pointer, for diffing a sub-document (CORE §3.3.1). |
Documents are represented as an ordered JSON value model rooted at Value
(an alias for any):
- objects →
*Object, preserving member order with O(1) key lookup (NewObject,(*Object).Get,(*Object).Set); - arrays →
[]Value; - numbers →
Number, preserving the original literal text while comparing atfloat64(CORE §1.2); - strings / bools / null →
string/bool/nil.
Helpers: Decode([]byte) (Value, error) and Encode(Value) ([]byte, error)
round-trip preserving member order and number text; EncodeOperations /
DecodeOperations do the same for []Operation. For interop with ordinary Go
values there are FromAny(any) (Value, error) and ToAny(Value) any. Note ToAny
returns numbers as json.Number (preserving literal text) — wire-identical to
float64 after marshaling, but in-memory comparisons against float64 fixtures
will see a different dynamic type.
DeepEqual(a, b Value) bool compares under JSON semantics (CORE §1.4); Clone makes
a deep copy.
This engine implements spec-v1 (SPEC.md, final) and is
verified two ways from the go/ directory:
*_conformance_test.goreplay every vector underspec/vectors/{diff,apply,plan,invert}— the shared, language-neutral oracle (CONF).differential_test.goreplays a seeded differential-fuzz corpus (spec/fuzz) of ~520 records, asserting each Go patch is structurally equal to the TypeScript reference's patch and that applying it reproduces the reference's applied document — currently zero mismatches.
Both suites read ../spec and t.Skip when it is absent (the published-module
case, where only go/ ships).
cd go
go vet ./...
go test ./...
gofmt -l . # must print nothingCondensed capability comparison against wI2L/jsondiff v0.7.1,
the closest alternative in the Go ecosystem (version pinned in
go-bench/go.mod, checked against that exact source in the Go module
cache). See the root README for the full matrix including the
three JS competitors, and the notebook for
performance (this table is capabilities only).
| Capability | Ours (Go) | wI2L/jsondiff v0.7.1 |
|---|---|---|
| Schema-aware array strategies | Yes — BuildPlan derives a per-array strategy (lcs/unique/primaryKey) from a JSON Schema, including through recursive $ref cycles: a self-referential schema keeps keyed diffing at every nesting depth (CORE §3.3.7). |
No — one generic recursive/LCS comparison; Compare/CompareJSON take no schema argument. |
| Raw-JSON entry | Yes — CompareJSON(schema, original, modified []byte, ...). |
Yes — CompareJSON(source, target []byte, ...) (compare.go:21). |
| Typed-value entry | Yes — Compare(schema, source, target any, ...). |
Yes — Compare(source, target interface{}, ...) (compare.go:11). |
| Apply | Yes — ApplyPatch, all six RFC 6902 ops, immutable and atomic. |
No — an apply method exists but is deliberately unexported: "will NEVER be exported... is feature-wise out of scope of the project" (apply.go:19-22, citing wI2L/jsondiff#28). |
Invert (incl. WITHOUT oldValue) |
Yes — InvertPatch(doc, patch) recovers prior values from the original document even when the patch carries no oldValue. |
Partial — Patch.Invert() requires the patch to have been generated with Invertible() (a preceding test op); otherwise returns ErrNonReversible (patch.go:34-58). |
| Move factorization | Yes, scoped to one array — EmitMoves(true) expresses a relocated element as one move within its own array. |
Yes, document-wide — Factorize() turns any matching remove+add pair anywhere in the tree into move/copy. |
| Size rationalization (wholesale) | Yes — WholesaleReplaceFallback(true) caps an array's patch at roughly its own serialized size. |
Yes — Rationalize() replaces a set of child ops with one parent replace when it marshals smaller. |
| Ignores | Yes — IgnorePaths(...) (JSON Pointer + * wildcard). |
Yes — Ignores() (variadic JSON Pointer list), marked experimental. |
| Deterministic cross-language output | Yes — checked against the TypeScript engine via 300+ shared conformance vectors plus a 576-record differential-fuzz corpus, zero mismatches. | N/A — single-language implementation. |
| Conformance test suite | Yes — 300+ spec-linked vectors under spec/vectors/, documented and run by both engines. |
Partial — internal testdata/tests/jsonpatch/*.json fixtures for its own tests only; not published as a spec-linked suite for external implementers. |
This is a subdirectory module (it lives in go/, not the repo root), so its
Go module version tags MUST be prefixed with the subdirectory path:
go/v1.2.3
A bare v1.2.3 tag is not picked up by the Go toolchain for this module.
Consumers then depend on it as usual:
go get github.com/flightcontrolhq/fast-json-schema-patch/go@v1.2.3The Go engine is versioned independently of the npm package; keep its changelog
and tags separate from the TypeScript releases (see CONTRIBUTING.md).