| id | json-patch |
|---|---|
| title | JsonPatch |
JsonPatch is an untyped, composable patch for Json values. It represents a sequence of operations that transform one Json value into another — computed automatically via a diff algorithm or constructed manually. The two fundamental operations are JsonPatch.diff to compute a patch between two Json values, and JsonPatch#apply to apply it.
JsonPatch:
- is a pure value — applying it never mutates the input
- is composable via
++, sequencing two patches one after another - supports three failure-handling modes:
Strict,Lenient, andClobber - carries its own
Schemainstances for full serialization support - converts bidirectionally to/from
DynamicPatchfor use in generic patching pipelines
The JsonPatch type wraps a sequence of operations:
final case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp])In most systems, updating JSON data means transmitting the entire new value — even when only a single field changed. JsonPatch solves this by representing changes as a first-class value that can be:
- Transmitted efficiently — send only what changed, not the entire document
- Stored for audit logs — record every change for compliance, debugging, or undo
- Composed — merge multiple changes into a single atomic patch
- Serialized — persist patches to disk or a message queue and replay them later
Source JSON Target JSON
┌─────────────────────┐ ┌─────────────────────┐
│ { "name": "Alice", │ │ { "name": "Alice", │
│ "age": 25, │──diff──│ "age": 26, │
│ "city": "NYC" } │ │ "city": "NYC" } │
└─────────────────────┘ └─────────────────────┘
│
▼
JsonPatch {
ObjectEdit(
Modify("age", NumberDelta(1))
)
}
│
▼ apply
┌─────────────────────┐
│ { "name": "Alice", │
│ "age": 26, │
│ "city": "NYC" } │
└─────────────────────┘
The "hello world" for JsonPatch is diff-then-apply. We compute a patch from source to target, then verify that applying it to source reproduces target:
import zio.blocks.schema.json.{Json, JsonPatch}
val source = Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(25))
val target = Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(26))
val patch: JsonPatch = JsonPatch.diff(source, target)Applying the patch to source always yields Right(target):
patch.apply(source) == Right(target)There are three ways to create a JsonPatch: compute one automatically with JsonPatch.diff, construct one manually with JsonPatch.root or JsonPatch.apply, or start from the identity patch JsonPatch.empty.
Computes the minimal JsonPatch that transforms source into target. Uses a smart diff strategy per value type — see the Diffing Algorithm section for details:
object JsonPatch {
def diff(source: Json, target: Json): JsonPatch
}JsonPatch.diff is also available as the Json#diff extension method:
import zio.blocks.schema.json.{Json, JsonPatch}
// Via companion object
val p1 = JsonPatch.diff(Json.Number(10), Json.Number(15))
// Via extension method on Json
val p2 = Json.Number(10).diff(Json.Number(15))
// Nested object diff produces minimal ObjectEdit
val p3 = JsonPatch.diff(
Json.Object("a" -> Json.Number(1), "b" -> Json.Number(2)),
Json.Object("a" -> Json.Number(1), "b" -> Json.Number(9))
)
// p3 only touches "b", leaves "a" unchangedCreates a patch with a single operation applied at the root of the value:
object JsonPatch {
def root(operation: JsonPatch.Op): JsonPatch
}For example, we can replace the root entirely, increment a number, or add a field to a root object:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.chunk.Chunk
// Replace the entire value
val replaceAll = JsonPatch.root(Op.Set(Json.Null))
// Increment a number at the root
val increment = JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1))))
// Add a field to a root object
val addField = JsonPatch.root(Op.ObjectEdit(Chunk(ObjectOp.Add("active", Json.Boolean(true)))))Creates a patch with a single operation applied at the specified DynamicOptic path. Use this when targeting a nested location within the value:
object JsonPatch {
def apply(path: DynamicOptic, operation: JsonPatch.Op): JsonPatch
}Paths are built fluently on DynamicOptic.root using .field(name) to navigate object fields and .at(index) to navigate array elements. For instance, to increment age inside a user object:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.schema.DynamicOptic
val agePath = DynamicOptic.root.field("user").field("age")
val agePatch = JsonPatch(agePath, Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1))))
val nested = Json.Object("user" -> Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(25)))Applying the patch navigates to the nested age field and increments it:
agePatch.apply(nested)The empty patch. Applying it to any Json value returns that value unchanged. JsonPatch.empty is the identity element for ++:
object JsonPatch {
val empty: JsonPatch
}JsonPatch.empty is useful as a neutral starting point when building patches conditionally:
import zio.blocks.schema.json.{Json, JsonPatch}Both JsonPatch#isEmpty and applying JsonPatch.empty confirm the identity property:
JsonPatch.empty.isEmpty
JsonPatch.empty.apply(Json.Number(42))
(JsonPatch.empty ++ JsonPatch.empty).isEmptyConverts a generic DynamicPatch to a JsonPatch. Returns Left[SchemaError] for operations not representable in JSON:
- Temporal deltas (
InstantDelta,DurationDelta, etc.) — JSON has no native time type - Non-string map keys — JSON object keys must always be strings
All numeric delta types (IntDelta, LongDelta, DoubleDelta, etc.) are widened to NumberDelta(BigDecimal):
object JsonPatch {
def fromDynamicPatch(patch: DynamicPatch): Either[SchemaError, JsonPatch]
}The round-trip through DynamicPatch preserves numeric deltas:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.patch.DynamicPatch
import zio.blocks.schema.SchemaError
val original: JsonPatch = JsonPatch.diff(Json.Number(1), Json.Number(2))
val dynPatch: DynamicPatch = original.toDynamicPatch
val back: Either[SchemaError, JsonPatch] = JsonPatch.fromDynamicPatch(dynPatch)The roundtrip succeeds and the recovered patch equals the original:
back == Right(original)JsonPatch exposes operations for applying patches, composing them, and converting between formats. The three groups of operations are applying, composing, and converting.
The primary way to use a JsonPatch is to call JsonPatch#apply or the Json#patch extension method, both of which accept an optional PatchMode argument.
Applies this patch to a Json value. Returns Right with the patched value on success, or Left[SchemaError] on failure. The mode parameter controls failure handling — see PatchMode:
case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
def apply(value: Json, mode: PatchMode = PatchMode.Strict): Either[SchemaError, Json]
}apply is also available via the Json#patch extension method. Both forms produce the same result:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.schema.patch.PatchMode
import zio.blocks.chunk.Chunk
val applyJson = Json.Object("score" -> Json.Number(10))
val applyPatch = JsonPatch.root(Op.ObjectEdit(Chunk(
ObjectOp.Modify("score", JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(5)))))
)))The direct call and the extension method are equivalent:
applyPatch.apply(applyJson)
applyJson.patch(applyPatch)
applyJson.patch(applyPatch, PatchMode.Lenient)Returns true if this patch contains no operations:
case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
def isEmpty: Boolean
}A patch computed between two identical values also produces an empty patch:
import zio.blocks.schema.json.{Json, JsonPatch}JsonPatch.empty.isEmpty
JsonPatch.diff(Json.Number(1), Json.Number(1)).isEmpty++ is the principal operator for building complex patches from smaller, focused ones. The JsonPatch.empty value is the identity element for ++:
case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
def ++(that: JsonPatch): JsonPatch
}Concatenating two patches applies this first, then that. This allows building a single patch that updates multiple fields independently:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.schema.DynamicOptic
val renamePatch = JsonPatch(
DynamicOptic.root.field("name"),
Op.Set(Json.String("Bob"))
)
val incrAgePatch = JsonPatch(
DynamicOptic.root.field("age"),
Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1)))
)
val combinedPatch = renamePatch ++ incrAgePatch
val personJson = Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(25))The combined patch applies both operations in sequence:
combinedPatch.apply(personJson)toDynamicPatch converts a JsonPatch to a DynamicPatch. This is always safe — every JSON operation maps to a corresponding dynamic operation. NumberDelta widens to BigDecimalDelta:
case class JsonPatch(ops: Chunk[JsonPatch.JsonPatchOp]) {
def toDynamicPatch: DynamicPatch
}To convert in the opposite direction, use JsonPatch.fromDynamicPatch — see Creating Patches above:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.patch.DynamicPatch
val patch: JsonPatch = JsonPatch.diff(Json.Number(1), Json.Number(5))
val dyn: DynamicPatch = patch.toDynamicPatchPatchMode controls how JsonPatch#apply reacts when an operation's precondition is not met (e.g., a field is missing, or ObjectOp.Add targets a key that already exists):
| Mode | Behaviour |
|---|---|
PatchMode.Strict (default) |
Returns Left[SchemaError] on the first failure |
PatchMode.Lenient |
Silently skips failing operations; returns Right with partial result |
PatchMode.Clobber |
Overwrites on conflicts; forces through missing-field errors where possible |
ObjectOp.Add fails in Strict mode when the key already exists. In Lenient mode the conflicting add is silently skipped; in Clobber mode it overwrites the existing value:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.schema.patch.PatchMode
import zio.blocks.chunk.Chunk
val modeJson = Json.Object("a" -> Json.Number(1))
val modePatch = JsonPatch.root(Op.ObjectEdit(Chunk(ObjectOp.Add("a", Json.Number(99)))))The three modes produce different outcomes for the same conflicting patch:
modeJson.patch(modePatch, PatchMode.Strict)
modeJson.patch(modePatch, PatchMode.Lenient)
modeJson.patch(modePatch, PatchMode.Clobber)A JsonPatch is a sequence of JsonPatchOp values. Each JsonPatchOp pairs a DynamicOptic path with an Op:
final case class JsonPatchOp(path: DynamicOptic, operation: Op)The full Op hierarchy covers five cases, from full replacement to fine-grained array and object edits:
Op (sealed trait)
├── Op.Set — replace the target value entirely
├── Op.PrimitiveDelta — numeric increment or string edit
│ ├── PrimitiveOp.NumberDelta
│ └── PrimitiveOp.StringEdit
│ ├── StringOp.Insert
│ ├── StringOp.Delete
│ ├── StringOp.Append
│ └── StringOp.Modify
├── Op.ArrayEdit — insert / append / delete / modify array elements
│ ├── ArrayOp.Insert
│ ├── ArrayOp.Append
│ ├── ArrayOp.Delete
│ └── ArrayOp.Modify
├── Op.ObjectEdit — add / remove / modify object fields
│ ├── ObjectOp.Add
│ ├── ObjectOp.Remove
│ └── ObjectOp.Modify
└── Op.Nested — groups a sub-patch under a shared path prefix
Replaces the target value with a new Json value, regardless of the current value. Works on any Json type:
final case class Set(value: Json) extends OpOp.Set can replace across types — for example, replacing a number with a string, or resetting a nested field to null:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.schema.DynamicOptic
val setString = JsonPatch.root(Op.Set(Json.String("replaced")))
val setNull = JsonPatch(DynamicOptic.root.field("status"), Op.Set(Json.Null))
val withStatus = Json.Object("status" -> Json.String("active"), "id" -> Json.Number(1))Both patches replace their target regardless of its current type:
setString.apply(Json.Number(123))
setNull.apply(withStatus)Applies a primitive mutation to a scalar value — either a numeric increment (NumberDelta) or a sequence of string edits (StringEdit):
final case class PrimitiveDelta(op: PrimitiveOp) extends OpAdds delta to a Json.Number. Use a negative value to subtract. Fails if the target is not a Json.Number:
final case class NumberDelta(delta: BigDecimal) extends PrimitiveOpPositive deltas increment; negative deltas decrement:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
val inc = JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(5))))
val dec = JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(-3))))inc.apply(Json.Number(10))
dec.apply(Json.Number(10))Applies a sequence of StringOp operations to a Json.String. JsonPatch.diff generates StringEdit automatically when it is more compact than a full Set:
final case class StringEdit(ops: Chunk[StringOp]) extends PrimitiveOpThe StringOp cases:
| Case | Parameters | Effect |
|---|---|---|
StringOp.Insert(index, text) |
position, text | Inserts text before character index |
StringOp.Delete(index, length) |
position, count | Removes length characters starting at index |
StringOp.Append(text) |
text | Appends text to the end |
StringOp.Modify(index, length, text) |
position, count, text | Replaces length characters at index with text |
We can insert a prefix before the first character using StringOp.Insert:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.chunk.Chunk
val insertPatch = JsonPatch.root(
Op.PrimitiveDelta(PrimitiveOp.StringEdit(Chunk(StringOp.Insert(0, "Hello, "))))
)insertPatch.apply(Json.String("world")):::tip
For most use cases, let JsonPatch.diff generate StringEdit automatically. The diff algorithm uses an LCS (Longest Common Subsequence) comparison and only emits StringEdit when it produces fewer bytes than a plain Set.
:::
Applies a sequence of ArrayOp operations to a Json.Array. Operations are applied in order, and each one sees the result of the previous:
final case class ArrayEdit(ops: Chunk[ArrayOp]) extends OpThe ArrayOp cases:
| Case | Parameters | Effect |
|---|---|---|
ArrayOp.Insert(index, values) |
position, elements | Inserts values before index |
ArrayOp.Append(values) |
elements | Appends values to the end |
ArrayOp.Delete(index, count) |
position, count | Removes count elements starting at index |
ArrayOp.Modify(index, op) |
position, op | Applies op to the element at index |
Multiple ArrayOps in a single ArrayEdit can be combined — here we transform [1, 2, 3] into [0, 1, 2, 4] in one pass:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.chunk.Chunk
val arrayPatch = JsonPatch.root(Op.ArrayEdit(Chunk(
ArrayOp.Insert(0, Chunk(Json.Number(0))),
ArrayOp.Delete(3, 1),
ArrayOp.Append(Chunk(Json.Number(4)))
)))
val originalArr = Json.Array(Json.Number(1), Json.Number(2), Json.Number(3))arrayPatch.apply(originalArr):::note
Array indices in ArrayOp.Delete and ArrayOp.Modify refer to the state of the array after all preceding ops in the same ArrayEdit have been applied.
:::
Applies a sequence of ObjectOp operations to a Json.Object. Operations are applied in order:
final case class ObjectEdit(ops: Chunk[ObjectOp]) extends OpThe ObjectOp cases:
| Case | Parameters | Effect |
|---|---|---|
ObjectOp.Add(key, value) |
field name, value | Adds a new field; fails in Strict mode if key exists |
ObjectOp.Remove(key) |
field name | Removes an existing field |
ObjectOp.Modify(key, patch) |
field name, sub-patch | Applies patch recursively to the field value |
A single ObjectEdit can add, remove, and modify fields in one operation:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.chunk.Chunk
val originalObj = Json.Object(
"name" -> Json.String("Alice"),
"age" -> Json.Number(25),
"city" -> Json.String("NYC")
)
val objPatch = JsonPatch.root(Op.ObjectEdit(Chunk(
ObjectOp.Add("email", Json.String("alice@example.com")),
ObjectOp.Remove("city"),
ObjectOp.Modify("age", JsonPatch.root(Op.PrimitiveDelta(PrimitiveOp.NumberDelta(BigDecimal(1)))))
)))objPatch.apply(originalObj)Groups a sub-patch under a shared path prefix. JsonPatch.diff emits Nested automatically when multiple operations share a common navigation path — this avoids repeating the full path in each JsonPatchOp:
final case class Nested(patch: JsonPatch) extends OpYou rarely need to construct Nested manually; it is primarily an internal optimization used by the diff algorithm.
JsonPatch.diff (and its alias Json#diff) delegate to JsonDiffer.diff, which selects the most compact representation for each type of change:
| Value type | Change | Strategy |
|---|---|---|
| Any | No change | No operation emitted |
Json.Number |
Value changed | NumberDelta — stores the numeric difference |
Json.String |
Value changed | StringEdit via LCS if smaller; otherwise Set |
Json.Array |
Elements changed | ArrayEdit with LCS-aligned Insert/Delete/Append/Modify |
Json.Object |
Fields changed | ObjectEdit with recursive per-field diff |
| Any | Type changed | Set — full replacement |
:::tip
JsonPatch.diff followed by JsonPatch#apply is always a lossless roundtrip: for any source and target, JsonPatch.diff(source, target).apply(source) == Right(target).
:::
ZIO Blocks' JsonPatch is not an implementation of RFC 6902. The two share the same motivation but differ in design:
ZIO Blocks JsonPatch |
RFC 6902 JSON Patch | |
|---|---|---|
| Operations | Typed ADT (Op.Set, Op.ArrayEdit, …) |
String-tagged JSON objects ("op": "replace") |
| Paths | DynamicOptic (typed, composable) |
JSON Pointer strings ("/a/b/0") |
| Number changes | NumberDelta (stores diff) |
replace (stores full new value) |
| String changes | LCS-based StringEdit |
replace only |
| Array changes | LCS-aligned insert/delete | add, remove, replace at absolute indices |
| Serialization | Via ZIO Blocks Schema in any format |
Always JSON |
| Composition | ++ operator |
Array concatenation |
Use JsonPatch when working within ZIO Blocks. For interoperability with RFC 6902 tooling, convert the patch to JSON using the built-in Schema instances and reformat as needed.
JsonPatch's composability and first-class serializability unlock patterns beyond simple point-in-time updates.
Because JsonPatch is a pure value with a Schema, we can serialize every change and replay or audit it later:
import zio.blocks.schema.json.{Json, JsonPatch}
// Every mutation is a patch — store it instead of overwriting
val v0 = Json.Object("count" -> Json.Number(0))
val v1 = Json.Object("count" -> Json.Number(1))
val v2 = Json.Object("count" -> Json.Number(2))
val log: List[JsonPatch] = List(
JsonPatch.diff(v0, v1),
JsonPatch.diff(v1, v2)
)
// Replay: reconstruct any historical state
val replay = log.foldLeft(v0: Json)((state, patch) => patch.apply(state).getOrElse(state))
assert(replay == v2)We can build a single patch that updates multiple nested fields by combining focused per-field patches with ++:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.json.JsonPatch._
import zio.blocks.schema.DynamicOptic
def setField(field: String, value: Json): JsonPatch =
JsonPatch(DynamicOptic.root.field(field), Op.Set(value))
val fieldPatch =
setField("status", Json.String("active")) ++
setField("updatedAt", Json.String("2025-01-01"))
val doc = Json.Object("status" -> Json.String("draft"), "id" -> Json.Number(42))Applying the composed patch updates both fields in one step:
fieldPatch.apply(doc)JsonPatch integrates with Json, DynamicPatch, and the ZIO Blocks serialization system. Each integration point is covered below.
Json exposes two extension methods as entry points into JsonPatch:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.patch.PatchMode
val source = Json.Object("x" -> Json.Number(1))
val target = Json.Object("x" -> Json.Number(2))
val patch: JsonPatch = source.diff(target) // compute patch
val result = source.patch(patch) // apply (Strict)
val lenient = source.patch(patch, PatchMode.Lenient)See Json for the complete Json API.
JsonPatch and DynamicPatch are bidirectionally convertible. This is useful when patches originate from the typed Patch[S] system and need to be applied to raw JSON:
import zio.blocks.schema.json.{Json, JsonPatch}
import zio.blocks.schema.patch.DynamicPatch
import zio.blocks.schema.SchemaError
val jsonPatch: JsonPatch = JsonPatch.diff(Json.Number(1), Json.Number(3))
// JsonPatch → DynamicPatch (always succeeds)
val dynPatch: DynamicPatch = jsonPatch.toDynamicPatch
// DynamicPatch → JsonPatch (may fail for temporal ops or non-string keys)
val back: Either[SchemaError, JsonPatch] = JsonPatch.fromDynamicPatch(dynPatch)See Patching for the typed Patch[S] API.
JsonPatch ships with Schema instances for all nested operation types, enabling round-trip serialization via any ZIO Blocks codec:
import zio.blocks.schema.json.JsonPatch
import zio.blocks.schema.Schema
val schema: Schema[JsonPatch] = implicitly[Schema[JsonPatch]]See Codec & Format for how to derive and use codecs.
Runnable examples are in schema-examples/src/main/scala/jsonpatch/:
| File | Topic |
|---|---|
JsonPatchDiffAndApplyExample.scala |
JsonPatch.diff, Json#diff, Json#patch, roundtrip guarantee |
JsonPatchManualBuildExample.scala |
JsonPatch.root, path-based patches, JsonPatch.empty |
JsonPatchOperationsExample.scala |
All Op types — Set, NumberDelta, StringEdit, ArrayEdit, ObjectEdit |
JsonPatchCompositionExample.scala |
++, PatchMode, toDynamicPatch, fromDynamicPatch |
CompleteJsonPatchExample.scala |
Collaborative document editor with a full patch log, replay, and sync |
Run any example with:
sbt "schema-examples/runMain jsonpatch.CompleteJsonPatchExample"