JSON Deep Merge: Why Shallow Merge Fails and How to Fix It
Quick answer
💡Object.assign and the spread operator perform shallow merges, meaning nested objects from the second source replace — rather than combine with — nested objects in the first. To deeply merge two JSON objects so that nested properties are combined at every level, use a recursive function that checks whether both values for a key are plain objects and recurses into them, or use lodash.merge({}, defaultConfig, userConfig) which does this for you.
Error symptoms
- ✕
Nested configuration keys disappear after merging two config objects - ✕
User settings overwrite all default settings instead of just the changed ones - ✕
Object.assign output is missing nested properties from the base object - ✕
Spread operator {...defaults, ...overrides} drops unmodified nested keys - ✕
lodash.merge mutates the first argument unexpectedly - ✕
Merging user-supplied JSON corrupts Object.prototype (__proto__ pollution)
Common causes
- •Using Object.assign or spread operator for objects with nested properties
- •Assuming shallow merge works at all depths because it works at the top level
- •Calling lodash.merge(defaultConfig, userConfig) instead of merge({}, defaultConfig, userConfig)
- •Not distinguishing between plain objects and arrays when writing a recursive merge
- •Merging untrusted JSON that contains a __proto__ key without sanitization
- •Forgetting that JSON.parse(JSON.stringify()) does not handle undefined values in merge results
When it happens
- •When merging user-provided settings on top of application defaults at startup
- •When combining partial update payloads from a PATCH endpoint with the stored document
- •When building layered configuration from environment, file, and CLI argument sources
- •When unit testing with partial fixture objects merged into a full base fixture
- •When aggregating multiple API response fragments into a single normalized object
Examples and fixes
When two configuration objects have nested properties, Object.assign replaces the entire nested object from the source rather than merging its individual properties.
Shallow merge drops nested config keys
❌ Wrong
const defaultConfig = {
server: { host: 'localhost', port: 3000, timeout: 5000 },
database: { host: 'db.local', port: 5432, poolSize: 10 },
logging: { level: 'info', format: 'json' }
};
const userConfig = {
server: { port: 8080 },
logging: { level: 'debug' }
};
const mergedConfig = Object.assign({}, defaultConfig, userConfig);
console.log(mergedConfig.server.host);
// undefined — server.host was overwritten by { port: 8080 }
console.log(mergedConfig.server.timeout);
// undefined — server.timeout is gone
console.log(mergedConfig.logging.format);
// undefined — logging.format is gone✅ Fixed
const defaultConfig = {
server: { host: 'localhost', port: 3000, timeout: 5000 },
database: { host: 'db.local', port: 5432, poolSize: 10 },
logging: { level: 'info', format: 'json' }
};
const userConfig = {
server: { port: 8080 },
logging: { level: 'debug' }
};
function deepMerge(target, source) {
const output = Object.assign({}, target);
for (const key of Object.keys(source)) {
if (isPlainObject(source[key]) && isPlainObject(target[key])) {
output[key] = deepMerge(target[key], source[key]);
} else {
output[key] = source[key];
}
}
return output;
}
function isPlainObject(val) {
return val !== null && typeof val === 'object' && !Array.isArray(val);
}
const mergedConfig = deepMerge(defaultConfig, userConfig);
console.log(mergedConfig.server.host); // 'localhost'
console.log(mergedConfig.server.port); // 8080
console.log(mergedConfig.server.timeout); // 5000
console.log(mergedConfig.logging.format); // 'json'Object.assign performs a one-level-deep copy. When both the target and source have a key whose value is an object, Object.assign replaces the entire target sub-object with the source sub-object rather than merging their properties. The fix is a recursive function that checks whether both values for a key are plain objects before deciding to recurse. The isPlainObject helper explicitly excludes arrays and null, both of which have typeof 'object', to prevent them from being treated as recursion candidates. Each recursive call produces a new object rather than mutating the target, keeping the function pure.
When merging user-supplied JSON, a malicious payload with a __proto__ key can modify Object.prototype and affect all objects in the process. A safe merge function rejects dangerous keys.
Detecting and blocking prototype pollution in deep merge
❌ Wrong
const maliciousPayload = JSON.parse(
'{"__proto__": {"isAdmin": true}, "username": "guest"}'
);
const sessionData = { userId: 99, role: 'viewer' };
// Unsafe recursive merge
function unsafeMerge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
unsafeMerge(sessionData, maliciousPayload);
const freshObject = {};
console.log(freshObject.isAdmin); // true — Object.prototype was polluted✅ Fixed
const maliciousPayload = JSON.parse(
'{"__proto__": {"isAdmin": true}, "username": "guest"}'
);
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
const output = Object.assign({}, target);
for (const key of Object.keys(source)) {
if (DANGEROUS_KEYS.has(key)) continue;
const srcVal = source[key];
const tgtVal = target[key];
if (isPlainObject(srcVal) && isPlainObject(tgtVal)) {
output[key] = safeMerge(tgtVal, srcVal);
} else {
output[key] = srcVal;
}
}
return output;
}
function isPlainObject(val) {
return val !== null && typeof val === 'object' && !Array.isArray(val);
}
const sessionData = { userId: 99, role: 'viewer' };
const merged = safeMerge(sessionData, maliciousPayload);
const freshObject = {};
console.log(freshObject.isAdmin); // undefined — prototype is clean
console.log(merged.username); // 'guest'Prototype pollution occurs when a merge function processes __proto__ as a regular object key. Because __proto__ is a special property that sets the prototype of an object, assigning to it with bracket notation modifies Object.prototype and affects every subsequently created object in the same process. The fix uses Object.keys() instead of for...in to iterate only own enumerable properties, and additionally filters out a set of known dangerous keys before processing. Using Object.assign({}, target) at the start of each merge level also prevents direct prototype mutation on the original target object.
Why shallow merge drops nested config
The term shallow merge refers to the fact that Object.assign and the spread operator only copy properties at the top level of an object. When both the target and the source have a property with the same name whose value is itself an object, the source's version of that nested object completely replaces the target's version. None of the target's nested properties survive — not even ones that the source did not set.
This behavior is often surprising because the top-level merge looks correct. If you merge { color: 'red', size: 'large' } onto { color: 'blue', weight: 10 }, you get the right result: { color: 'red', weight: 10, size: 'large' }. The merge works because all values are primitives — strings and numbers — which are copied by value. The problem emerges as soon as any value is an object or array, because object references are copied, not the objects themselves.
Consider two configuration objects, each with a server property that is itself an object with host, port, and timeout. When you shallow merge the user config onto the defaults, the user config's server object reference replaces the defaults' server object reference entirely. Even if the user config only specified a port, the result's server property points to the user config's server object — an object that only has port. The host and timeout from defaults are not present in that object and are therefore inaccessible in the merged result.
This is not a bug in Object.assign or the spread operator — it is their defined behavior, and it is correct for use cases where you want the source to completely replace any conflicting top-level values. The mismatch between expected and actual behavior arises when developers reach for these tools expecting recursive combination rather than top-level replacement. Recognizing when your data is nested and when a recursive merge is actually needed is the core diagnostic skill for this class of problem.
Testing merge depth with console inspection
The most reliable way to diagnose whether your merge is behaving shallowly is to inspect the nested properties of the merged result directly after the merge operation. Do not inspect only the top-level keys — they will look correct in both a shallow and a deep merge. Instead, access a nested key that exists only in the target but not in the source, and check whether it appears in the merged output.
For configuration objects, a good test is to log a nested property that you expect to be preserved from the default: mergedConfig.server.host or mergedConfig.database.poolSize. If that value is undefined in the result despite being present in the defaults, the merge is shallow and the nested defaults are being dropped.
Node.js's util.inspect with depth: null produces a complete recursive view of an object with no truncation, which is helpful for comparing deeply nested structures. In a browser console, console.dir(obj) expands the object interactively. For automated tests, using a deep equality assertion such as toEqual in Jest or deepStrictEqual in Node's assert module will fail if a nested property is missing, immediately surfacing the problem.
When using lodash.merge, a common diagnostic confusion is that lodash.merge mutates its first argument. If you call lodash.merge(defaultConfig, userConfig) and then later call lodash.merge(defaultConfig, anotherConfig), the defaultConfig you are starting from has already been mutated by the first call. Logging defaultConfig after the first merge will show a value that is no longer the original defaults, which can make subsequent merges produce unexpected results. Always pass an empty object as the first argument — lodash.merge({}, defaultConfig, userConfig) — so the defaults remain pristine.
Writing a safe recursive merge function
A correct recursive deep merge function follows a straightforward algorithm: for each key in the source, if both the source value and the target value for that key are plain objects, recurse into them; otherwise, use the source value directly. This algorithm correctly handles any depth of nesting and produces a new object at every level, so neither the original target nor the original source is mutated.
The most important implementation detail is the isPlainObject check. Both null and arrays have typeof 'object' in JavaScript, so a naive check for typeof value === 'object' will incorrectly try to recurse into arrays and will throw when trying to recurse into null. A robust check is: val !== null and typeof val === 'object' and !Array.isArray(val). Some implementations also check that the object's prototype is Object.prototype to exclude class instances, which prevents accidentally recursing into Date, Map, or custom objects that happen to have enumerable own properties.
For arrays, the most common default behavior in a deep merge is for the source array to replace the target array entirely, the same way a primitive value would. This is the behavior of lodash.merge for arrays. If your use case requires merging arrays rather than replacing them — for example, concatenating a default middleware list with a user-supplied middleware list — you need to handle arrays explicitly in your merge function, applying the appropriate strategy before falling through to the default recursive or replace logic.
When using npm packages, lodash.merge is the most widely adopted option and has been battle-tested across millions of projects. For TypeScript projects, deepmerge and ts-deepmerge provide type-safe alternatives with customizable merge behavior. If your project already imports lodash for other utilities, using lodash.merge adds no additional bundle weight. For browser applications concerned about bundle size, a small hand-written recursive merge of ten to fifteen lines is a reasonable alternative to importing a library.
Array replacement versus concatenation strategies
Arrays are the most contentious part of deep merge because there is no single obviously correct behavior. The right strategy depends entirely on what the array represents in your data model. Configuration arrays where each element is a standalone value — for example, a list of allowed hostnames — usually call for replacement: the user's list replaces the default list entirely. Arrays where each element is a record with a unique identifier — for example, a list of middleware plugins with names — might call for a union strategy that merges by identifier.
Replacement is the simplest strategy and the default in most deep merge libraries including lodash.merge. When the source has an array for a key, the target's array for that key is completely discarded. This is predictable and easy to reason about, and it means users have full control over the merged result for any array-valued key.
Concatenation combines both arrays into one. This makes sense for additive configurations, such as a list of plugins where both the defaults and the user additions should be active simultaneously. The merged result is [...targetArray, ...sourceArray]. One risk is duplicate values if both arrays contain the same element, which can be addressed by deduplication using a Set for primitive arrays or a discriminator function for object arrays.
Union merge deduplicates after concatenation. For primitive arrays, this is straightforward: new Set([...targetArray, ...sourceArray]). For object arrays, you need a key function that identifies which property serves as the unique identifier, then merge objects with the same key and keep objects with unique keys. This is the most complex strategy and usually needs to be implemented per-array rather than as a universal behavior, because different arrays in the same document may need different discriminator keys.
The cleanest approach for complex documents is to write a merge function that accepts an options object where callers specify the strategy for each array path using a path descriptor or a per-path callback. This avoids trying to infer the correct strategy from the data itself.
Prototype pollution via __proto__ merge
Prototype pollution is a security vulnerability that occurs when a merge function processes the __proto__ key as a regular object property. In JavaScript, every object has a prototype chain, and Object.prototype is the root of that chain for all plain objects. If an attacker can get your merge function to assign properties to Object.prototype — by passing a JSON payload containing a __proto__ key — those properties become visible on every plain object in the process, even objects created before the merge happened.
This is not a theoretical concern. Multiple production npm packages have received security disclosures for prototype pollution vulnerabilities in their merge implementations. The attack is particularly effective in server-side Node.js applications that accept JSON from untrusted clients and merge it with application state, because a successful pollution affects all subsequent request handling in the same process.
The root cause is using for...in or direct bracket notation assignment without filtering dangerous keys. The for...in loop iterates inherited properties in addition to own properties, which means __proto__ can appear in the iteration. Even with Object.keys(), which only returns own enumerable properties, JSON.parse on a string containing __proto__ does create an own property with that name on the parsed object — the JSON spec does not prohibit it, and the parser creates it literally.
The fix has two components. First, use Object.keys() rather than for...in to iterate source properties. Second, maintain an explicit deny-list of dangerous keys including __proto__, constructor, and prototype, and skip those keys before any merge logic. Additionally, starting each merge level with Object.assign({}, target) rather than mutating the target in place prevents the recursive call from ever touching the prototype directly through assignment. For untrusted user input, validate the JSON against a schema before merging it — schema validation can reject documents containing unexpected keys at the top level or in nested positions.
Deep merge in configuration management
Configuration management is the most common real-world application of deep merge, and it has well-established patterns that are worth following. The typical pattern is a cascade of sources in increasing priority order: built-in defaults, then a configuration file, then environment variables, then command-line arguments. Each higher-priority source is deep-merged onto the accumulated result from lower-priority sources, so that each source only needs to specify the values it intends to override.
Always treat the base defaults as immutable. Pass an empty object as the first argument to lodash.merge or your recursive merge function, so the output is a fresh object and the defaults object is never modified. This matters in long-running server processes where multiple requests might trigger configuration reloading: if the defaults object accumulates changes from previous merges, the baseline shifts in unpredictable ways.
When serializing and restoring configuration — for example, caching a merged config object to Redis — use JSON.stringify and JSON.parse, but be aware that any undefined values in the merged result will be stripped. If your merge algorithm ever sets a key to undefined to signal deletion of a default, that signal will not survive serialization. Use a sentinel value like null, or explicitly delete the key before serializing, to ensure the intent is preserved across the serialize-parse cycle.
Document the merge semantics for your configuration system so that contributors understand what they can control. If your merge function replaces arrays, document that a user must specify the entire array to override even a single element. If it concatenates, document that users cannot remove default array elements by omitting them. These behaviors are not obvious from the API surface alone, and misunderstandings lead to configuration mistakes that are difficult to debug because the code works correctly — it just does not do what the user intended.
Quick fix checklist
- ✓Replace Object.assign or spread with a recursive deep merge function
- ✓Use isPlainObject that excludes null and arrays from recursion
- ✓Pass an empty object as the first argument to lodash.merge to avoid mutating defaults
- ✓Decide on array merge strategy (replace, concat, union) before implementing
- ✓Filter out __proto__, constructor, and prototype keys when merging untrusted input
- ✓Use Object.keys() rather than for...in to avoid inherited property iteration
- ✓Validate user-supplied JSON against a schema before merging it with application state
- ✓Test merged output by accessing nested keys that exist only in the target defaults
Related guides
Frequently asked questions
What is the difference between shallow merge and deep merge?
A shallow merge copies top-level properties from the source to the target. When both objects have a property whose value is itself an object, the source's nested object replaces the target's entirely. A deep merge recurses into nested objects and merges their properties individually at every level, preserving keys from the target that the source does not override.
Does lodash.merge mutate the first argument?
Yes, lodash.merge mutates the first argument and returns it. If you pass your default configuration object as the first argument, it will be modified in place. To avoid this, always pass an empty object as the first argument: lodash.merge({}, defaultConfig, userConfig). This produces a fresh merged object while leaving both sources unchanged.
Why does the spread operator {...a, ...b} not do a deep merge?
The spread operator creates a new object and copies own enumerable properties from each source in order. For any key that appears in both a and b, b's value wins. Since the copy is a reference copy for object and array values, nested objects from b completely replace nested objects from a rather than being merged with them. This is by design — spread is a shallow operation.
How do I merge arrays without duplicates during a deep merge?
For primitive arrays, convert both to Sets and spread them: [...new Set([...arrayA, ...arrayB])]. For object arrays with a unique identifier field such as id or name, iterate through the source array and either update the matching target element or append new elements. Build a lookup map from the target array by identifier for efficient matching without nested loops.
Can I use JSON.parse(JSON.stringify(obj)) to deep clone before merging?
Yes, for plain JSON objects this produces a deep clone suitable for use as a mutation-safe base before merging. The limitation is that undefined values, functions, Symbols, Date objects, Maps, and Sets do not survive the round trip. If your configuration objects contain any of these types, use structuredClone() instead, which handles Date, Map, and Set correctly.
What is prototype pollution and how does deep merge cause it?
Prototype pollution is a vulnerability where merging an object containing a __proto__ key modifies Object.prototype. Since all plain objects inherit from Object.prototype, properties set on it appear on every object created afterward. A deep merge that assigns source[key] to target[key] without filtering __proto__ will write to the prototype when the key is literally __proto__, affecting the entire process.
Should I use a library or write my own deep merge?
For production code, lodash.merge is well-tested, widely used, and handles most edge cases correctly. Writing your own is reasonable if you want to avoid the dependency or need custom array merge behavior that lodash does not support. If you write your own, ensure you handle the null and Array exclusions in isPlainObject, avoid for...in loops, and filter dangerous prototype keys when processing untrusted input.
How do I merge objects in TypeScript while preserving types?
Use the ts-deepmerge package, which is typed to infer the merged result type from its inputs. For lodash.merge, the @types/lodash package provides type definitions, though the inferred return type defaults to the first argument type. You can also write a typed utility with generic parameters that intersect the input types: function deepMerge(a: A, b: B): A & B. The actual implementation still needs runtime type checking.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-05.