Dev Tools

How to Apply a JSON Patch in JavaScript, Python, and Go

What "Applying" a JSON Patch Actually Means

A JSON Patch (RFC 6902) is a JSON array of operations that transform a source document into a target document. "Applying" the patch means executing each operation in order against an in-memory copy of the document. The rules are strict: operations run sequentially, and if any operation fails, the whole patch is rejected and the original document must remain untouched. This atomic, all-or-nothing behavior is the single most misunderstood part of working with JSON Patch, and it is the reason you should always apply patches through a tested library rather than hand-rolling a loop.

This guide shows the exact, copy-paste way to apply a patch in three ecosystems — JavaScript, Python, and Go — and then covers the three things that trip people up: the test operation, atomic rollback on failure, and validating paths before you trust client input. If you just want to see what a patch between two documents looks like, generate one first with the JSON Diff tool and paste the result into the examples below.

JavaScript: fast-json-patch

The de-facto library in the Node and browser world is fast-json-patch. It applies patches, validates them, and can also generate a patch by observing mutations to an object.

import { applyPatch, deepClone } from 'fast-json-patch';

const document = { user: { name: 'Ada', roles: ['reader'] } };

const patch = [
  { op: 'add', path: '/user/roles/-', value: 'editor' },
  { op: 'replace', path: '/user/name', value: 'Ada Lovelace' }
];

// validateOperation: true throws on a malformed or failing op
const result = applyPatch(deepClone(document), patch, true).newDocument;
// result.user.name === 'Ada Lovelace'
// result.user.roles === ['reader', 'editor']

Three details matter here. First, applyPatch mutates the document you pass in, so clone it (deepClone) if you need to keep the original — critical when you must roll back on failure. Second, the /user/roles/- path is the JSON Pointer "end of array" token: add with - appends, while add with a numeric index inserts before that index rather than overwriting. Third, passing true as the third argument turns on validation so a bad operation throws instead of silently corrupting your data.

Python: the jsonpatch library

In Python, the jsonpatch package mirrors the RFC closely and integrates with jsonpointer for path resolution.

import jsonpatch

document = {"user": {"name": "Ada", "roles": ["reader"]}}

patch = jsonpatch.JsonPatch([
    {"op": "add", "path": "/user/roles/-", "value": "editor"},
    {"op": "replace", "path": "/user/name", "value": "Ada Lovelace"},
])

# apply() returns a NEW document and never mutates the original
result = patch.apply(document)
# result == {"user": {"name": "Ada Lovelace", "roles": ["reader", "editor"]}}

Unlike fast-json-patch, the Python library's apply() returns a new object and leaves the input alone by default, which makes atomic behavior easier: if apply() raises a JsonPatchConflict or JsonPointerException, you simply keep the original document. Pass in_place=True only when you have already cloned the source and want to avoid the allocation.

Go: evanphx/json-patch

Go services typically use github.com/evanphx/json-patch/v5, which operates on raw []byte JSON rather than decoded structs.

import jsonpatch "github.com/evanphx/json-patch/v5"

doc := []byte(`{"user":{"name":"Ada","roles":["reader"]}}`)
raw := []byte(`[
  {"op":"add","path":"/user/roles/-","value":"editor"},
  {"op":"replace","path":"/user/name","value":"Ada Lovelace"}
]`)

patch, err := jsonpatch.DecodePatch(raw)
if err != nil { /* malformed patch */ }

updated, err := patch.Apply(doc)
if err != nil { /* operation failed — keep `doc` unchanged */ }

Because the library works on bytes, errors surface in two distinct places: DecodePatch fails if the patch itself is malformed JSON, and Apply fails if an operation cannot be satisfied (for example, replace on a path that does not exist). Keep these separate in your error handling — a 400 for a malformed patch is a different response than a 409 for a conflicting one.

The test Operation: Optimistic Concurrency

The test operation is what turns JSON Patch from a blind writer into a safe one. It asserts that a value at a path equals an expected value, and if it does not, the whole patch fails before any change is written. This gives you optimistic concurrency control without a separate version column.

[
  { "op": "test",    "path": "/user/name", "value": "Ada" },
  { "op": "replace", "path": "/user/name", "value": "Ada Lovelace" }
]

If another request already changed /user/name, the test fails, the replace never runs, and the client learns the document moved underneath them. Put test operations at the start of the patch so you fail fast and never partially apply. All three libraries above honor test as part of the standard apply path.

Atomic Failure: Why Order and Rollback Matter

RFC 6902 requires that a failed patch leaves the document exactly as it was. Libraries that return a new document (Python, Go) give you this for free — you discard the result and keep the original. Libraries that mutate in place (the default fast path in fast-json-patch) do not, which is why the JavaScript example clones first. The anti-pattern to avoid is iterating over operations yourself and applying them one by one to the live object: the moment operation three fails, operations one and two have already corrupted your data and there is no clean way back.

  • Clone-then-apply: work on a copy, swap it in only on success.
  • Fail fast: validate the entire patch (and run test ops) before writing anything durable.
  • Map errors to status codes: malformed patch → 400, failed test or missing path → 409, unauthorized path → 403.

Validate Paths Before You Trust Them

A patch arriving from a client is untrusted input. A user editing their profile must not be able to send {"op":"replace","path":"/role","value":"admin"}. Maintain a whitelist of patchable JSON Pointer paths per resource type and reject any operation whose path (or from, for move/copy) falls outside it. This is covered in depth in the RFC 6902 guide, and it pairs naturally with schema validation — run the result of the patch through your JSON Schema validator before persisting it so a technically-valid patch can never produce a structurally-invalid document.

Debugging a Patch That Will Not Apply

When a patch fails and you are not sure why, the fastest way to diagnose it is to compare the source and target documents directly. Paste both into the JSON Diff tool to see exactly which fields differ, then confirm your patch's paths match that structure. The most common culprits are: a replace targeting a path that does not exist yet (use add instead), an array index that assumes add overwrites (it inserts), and unescaped / or ~ characters in object keys, which must be written as ~1 and ~0 respectively under JSON Pointer (RFC 6901). For a refresher on the underlying document model, see Understanding JSON Structure and Syntax.

A Complete Worked Example: Patching a User Profile

Theory is easier to trust when you can trace it end to end. Suppose your API stores this user document:

{
  "id": 42,
  "name": "Ada",
  "email": "[email protected]",
  "roles": ["reader"],
  "settings": { "theme": "light", "notifications": true }
}

The client wants to rename the user, promote them to editor, switch the theme to dark, and remove the notifications flag entirely — but only if the email has not changed since they last read the record. Expressed as a single JSON Patch, that intent looks like this:

[
  { "op": "test",    "path": "/email",        "value": "[email protected]" },
  { "op": "replace", "path": "/name",         "value": "Ada Lovelace" },
  { "op": "add",     "path": "/roles/-",      "value": "editor" },
  { "op": "replace", "path": "/settings/theme", "value": "dark" },
  { "op": "remove",  "path": "/settings/notifications" }
]

Walking through it: the test guards the whole operation — if someone changed the email in the meantime, nothing else runs. The replace on /name succeeds because the path already exists. The add with the - token appends "editor" to the roles array. The second replace reaches into the nested settings object. Finally, remove deletes the notifications key outright. Run this through any of the three libraries above and you get a deterministic result, or a clean failure with the original document preserved. Compare that to the alternative — five separate field updates, each its own request, each a chance for a partial write — and the appeal of one atomic patch becomes obvious.

JSON Patch vs Sending the Whole Object (PUT)

A fair question is why not just PUT the entire updated object and let the server overwrite it. For small, flat resources that is often simpler, and you should not reach for JSON Patch reflexively. But three situations make a patch clearly better. First, large documents with small changes: editing one field of a 50 KB record means sending 50 KB with a PUT versus a few bytes with a patch. Second, concurrent editors: a blind PUT silently overwrites whatever changed since the client last read the record, while a patch with a test operation refuses to clobber a stale field. Third, partial-update semantics: PUT replaces the whole resource, so any field the client omits is treated as deleted — a classic source of accidental data loss that patch sidesteps because it only touches the paths you name. If your update is "set these three fields and leave everything else alone," JSON Patch expresses that precisely; PUT cannot.

Summary

Applying a JSON Patch is straightforward once you respect the standard's guarantees: use a real library, work on a copy, lead with test operations for concurrency safety, validate client paths against a whitelist, and map each failure mode to the right HTTP status. JavaScript, Python, and Go all have mature, RFC-compliant libraries, so you should never hand-roll the apply loop. When something does not behave, diff the two documents and check your paths first — nine times out of ten the patch is correct and the path is one character off.

← Back to Blog