Why Merging JSON Needs Three Strategies
Combining two JSON objects seems trivial until a config file goes stale, a feature flag overwrites itself to {}, or a PATCH request to a REST API silently drops every nested key. The reason is that merge is not one operation — it is at least three, and the right choice depends on what the two objects represent.
A shallow merge copies top-level keys from the source over the target. This is what Object.assign(target, src) and the spread operator{...target, ...src} do in JavaScript. It is fast and correct for flat objects, but if both sides contain a nested object under the same key, the source wins outright and anything the target had under that key is lost.
A deep mergewalks every nested object and merges children recursively. Nested keys from both sides are preserved. Arrays, by default, are replaced wholesale (you almost never want "merge item 0 of A with item 0 of B"). This is what you want for layered configs — a base config plus a per-environment override — and for most feature-flag systems.
JSON Merge Patch (RFC 7396) is a deep merge with one extra rule: anullvalue in the patch deletes the matching key from the target. This lets you express "remove this field" in a PATCH payload without a separate schema. Kubernetes and GitHub use merge-patch semantics for many of their REST endpoints.
Example: Shallow vs Deep
Target and source:
// target
{
"name": "promptspace",
"settings": { "theme": "dark", "lang": "en" },
"tags": ["ai", "prompts"]
}
// source
{
"settings": { "theme": "light" },
"tags": ["new"]
}Shallow merge (lossy — settings.lang disappears):
{
"name": "promptspace",
"settings": { "theme": "light" },
"tags": ["new"]
}Deep merge (settings merge, tags replace):
{
"name": "promptspace",
"settings": { "theme": "light", "lang": "en" },
"tags": ["new"]
}JSON Merge Patch (RFC 7396) in One Rule
Merge-patch is a deep merge with a kill-switch for individual keys. If a value in the patch is null, the key is removed from the target. Everything else behaves like deep merge. The spec is 15 pages long but boils down to:
// Target
{ "a": 1, "b": { "x": 10, "y": 20 }, "c": 3 }
// Patch
{ "b": { "x": null }, "c": null, "d": 4 }
// Result
{ "a": 1, "b": { "y": 20 }, "d": 4 }c was deleted. b.x was deleted. b.y stayed because the patch did not mention it. d was added.
JavaScript Code Equivalents
Shallow and deep merges are a handful of lines each. The common library choice islodash.merge for deep, which handles nested objects, arrays and primitives correctly.
// Shallow — native, no library
const shallow = { ...target, ...source };
// or
const shallow2 = Object.assign({}, target, source);
// Deep — hand-rolled, 10 lines
function deepMerge(target, source) {
const out = Array.isArray(target) ? [...target] : { ...target };
for (const key of Object.keys(source)) {
const sv = source[key];
const tv = out[key];
if (sv && typeof sv === 'object' && !Array.isArray(sv) &&
tv && typeof tv === 'object' && !Array.isArray(tv)) {
out[key] = deepMerge(tv, sv);
} else {
out[key] = sv;
}
}
return out;
}
// Deep — with lodash
import merge from 'lodash.merge';
const deep = merge({}, target, source);Python Equivalents
Python has no built-in deep merge. The one-liner {**a, **b} is shallow. For deep, hand-roll a recursive function or use deepmerge ormergedeep from PyPI.
# Shallow
shallow = {**target, **source}
# Deep — hand-rolled
def deep_merge(target, source):
out = dict(target)
for k, v in source.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = v
return out
# Deep with mergedeep
from mergedeep import merge
result = merge({}, target, source) # mutates first arg, returns itArray Merge Strategies
Arrays are where every merge library disagrees. The common strategies:
- Replace (default for most libs and for JSON Merge Patch) — the source array wins outright. Simple, predictable.
- Concat — append source to target. Good for append-only lists. Watch for duplicates.
- Merge by index — pair up items by position. Rarely what you want.
- Merge by key — pair items by a shared field like
id, then merge each pair as objects. The right choice for lists of records.
Our tool defaults to replace for both deep merge and merge patch to match RFC 7396. To concatenate, do it in code before pasting:target.items = [...target.items, ...source.items].
Common Pitfalls
- Mutating the input.
Object.assign(target, source)mutatestarget. Always start withObject.assign({}, target, source)or spread into a new object. - Mixing Dates, Maps, or class instances. Deep merge usually treats everything with
typeof 'object'as mergeable, which breaks on Date, Map, Set, and custom classes. Stick to plain JSON (strings, numbers, booleans, arrays, plain objects) when merging. - Prototype pollution. Never deep-merge arbitrary user input into an object that uses prototype properties. A malicious
__proto__key can poison every object in the runtime. Lodashmergepatched this in v4.17.21 — upgrade if you are on an older release. - Null in a deep merge (non-patch). Pure deep merge treats
nullas a value and overwrites. If you need delete-on-null, switch to Merge Patch mode.
When to Use Which Strategy
- Flat config, env overrides, DOM props → shallow merge. Fastest, zero surprises.
- Layered configs, feature flag defaults + overrides → deep merge.
- REST PATCH endpoints, Kubernetes manifests, GitHub API payloads → Merge Patch (RFC 7396). Deletion-via-null is a feature here.
- Partial state updates in Redux / Zustand → shallow for top-level slices, deep for nested stores. Most state libraries do shallow by design.
Related JSON Tools
- JSON Formatter — the parent tool with interactive input and real-time validation.
- JSON Diff Online — compare two JSON objects and see every change before merging.
- JSON Schema Validator — validate the merged result against a schema.
- JSON Flattener — flatten nested keys to dot-paths, which can simplify merging deeply-nested configs.