Lodash _.defaultsDeep() method

Beginner
⏱️ 8 min read
📚 Updated: May 2026
🎯 3 Code examples
🚀 3 Try-it labs
Lodash

What you’ll learn

  • How _.defaultsDeep(object, ...sources) recursively fills only the undefined leaves of nested plain objects and arrays.
  • Why a defined value on the destination always wins—same precedence rule as _.defaults, just applied at every depth.
  • How arrays are recursed by index (not replaced wholesale).
  • Try each example in the editor (?tryit=1, 2, 3) with Lodash from a CDN.

Prerequisites

Skim _.defaults() first—defaultsDeep is the recursive twin with the same “destination wins” precedence rule.

  • Nested object trees: mental model of walking obj.a.b.c step by step, since defaultsDeep recurses through plain objects.
  • undefined vs null: only undefined leaves are filled—null counts as already set.
  • Mutation awareness: the destination is written in place; pass {} first when you don’t want callers to share the result.

Overview

_.defaultsDeep walks each source left to right and recurses into matching plain-object and array branches. At every leaf it writes the source value only when the destination’s value at that path is undefined. Everything else is left untouched.

Recursive, leaf by leaf

A defined nested object no longer blocks defaults below it—each undefined leaf inside can still be filled independently.

Existing values are sacred

Same precedence rule as _.defaults: destination wins, then the first source to define a leaf wins.

Arrays = recursed by index

Arrays aren’t opaque values: each index is compared and filled the same way as object keys.

Syntax

javascript
_.defaultsDeep(object, [...sources])
  • object: destination; mutated in place.
  • sources: zero or more source objects walked left to right; recursed into plain objects and arrays.
  • Write rule: a leaf is written only when the destination’s value at that path is undefined.
  • Returns: the (now-mutated) destination object.
1

Nested config: keep what’s defined, fill what’s missing

User-supplied theme and font.size survive; font.family is filled from the defaults because the user didn’t specify one.

javascript
import defaultsDeep from "lodash/defaultsDeep";

const userConfig = {
  theme: "dark",
  font: { size: 16 }
};

const defaults = {
  theme: "light",
  font: { size: 14, family: "Arial" }
};

defaultsDeep(userConfig, defaults);
// userConfig -> {
//   theme: "dark",                          // user wins
//   font:  { size: 16, family: "Arial" }    // size wins; family filled
// }
Try it Yourself
2

defaultsDeep vs defaults on nested data

A defined nested object stops _.defaults in its tracks; _.defaultsDeep walks inside and fills the still-undefined leaves.

javascript
import defaults     from "lodash/defaults";
import defaultsDeep from "lodash/defaultsDeep";

const baseShape = () => ({ user: { name: "Guest" } });
const fillFrom  = { user: { name: "Admin", role: "viewer" } };

defaults(baseShape(),     fillFrom);
// -> { user: { name: "Guest" } }
//                ^ user already defined as an object, so SHALLOW
//                  defaults bails out and the whole branch is kept.

defaultsDeep(baseShape(), fillFrom);
// -> { user: { name: "Guest", role: "viewer" } }
//                ^^^^^^^^^^^^   ^^^^^^^^^^^^^^
//                kept           filled (was undefined)
Try it Yourself
3

Arrays are recursed by index (not replaced)

This catches many readers off guard. Arrays are treated like objects keyed by their numeric indexes: existing positions are protected and only sparse/longer source positions get filled in.

javascript
import defaultsDeep from "lodash/defaultsDeep";

const target = { tags: ["a", "b"] };
const source = { tags: ["x", "y", "z"] };

defaultsDeep(target, source);
// target -> { tags: ["a", "b", "z"] }
//                    ^^^   ^^^   ^^^
//                    kept  kept  index 2 was undefined -> filled

// To replace an array wholesale, treat it as a leaf you set yourself:
const target2 = { tags: undefined };
defaultsDeep(target2, source);
// target2 -> { tags: ["x", "y", "z"] }  (entire array filled)
Try it Yourself

📋 _.defaultsDeep vs _.defaults vs _.merge

Topic_.defaultsDeep_.defaults_.merge
DepthRecursive (plain objects + arrays)ShallowRecursive (plain objects + arrays)
Precedence on conflictDestination wins; first source fillsDestination wins; first source fillsSource wins (last source overwrites)
Replaces null?No—only undefinedNo—only undefinedYes
ArraysWalked by indexReplaced wholesaleWalked by index
Typical useLayered nested config (user + defaults)Flat option fallbacksCombining nested data where source should win

Pick _.defaultsDeep when you want “keep what the user gave me, fill the rest” recursively. Switch to _.merge the moment defaults should override.

Pitfalls to avoid

Arrays

Array merge is positional

Old docs sometimes claim arrays are “treated as values.” They aren’t—each index is compared, so source values can sneak in past the end of a shorter destination array. Replace the array yourself if you need wholesale substitution.

null

null blocks the default

Only undefined counts as missing. A leaf set to null remains null. Sanitize upstream or pre-process with _.assignInWith.

Mutation

Mutates and shares references

The destination is written in place, and any new nested objects/arrays it gains are references from the source. Mutating them later mutates the source too. Pass {} first or deep-clone afterwards if you need isolation.

Security

Prototype-pollution surface

Like every deep merge, walking attacker-controlled JSON can write __proto__ or constructor.prototype keys. Strip or validate the source shape before merging untrusted input.

❓ FAQ

_.defaults is shallow: a defined nested object on the destination blocks every default below it. _.defaultsDeep recurses into plain objects and arrays so each missing LEAF can be filled independently, while still leaving any defined value alone.
_.merge overwrites: source values win for non-undefined leaves. _.defaultsDeep is the no-overwrite cousin: a leaf is written only when the destination's current value is undefined. Same recursion shape, opposite precedence on conflicts.
Yes—positionally, by index. _.defaultsDeep({ arr: [1, 2, 3] }, { arr: [4, 5, 6, 7] }) returns { arr: [1, 2, 3, 7] }: indexes 0, 1, 2 are defined and protected; only index 3 is filled. This trips many readers expecting arrays to be replaced wholesale.
No. Only literal undefined counts as missing. A leaf set to null on the destination is considered already defined and is not replaced. Sanitize null upstream if you also want it filled.
Yes. The first argument is written in place and returned. Pass {} as the destination when you need a fresh object: const cfg = _.defaultsDeep({}, userCfg, defaults).
Be careful. Like any deep merge it can walk into __proto__ or constructor.prototype keys and pollute the prototype chain. Validate input shape or strip dangerous keys before merging external data.

Summary

Did you know?

_.defaultsDeep recurses into arrays by index—not as opaque values. defaultsDeep({ tags: ['a','b'] }, { tags: ['x','y','z'] }) yields { tags: ['a','b','z'] }: indexes 0 and 1 are defined on the destination, so only index 2 is filled.

About the author

Mari Selvan M P
Mari Selvan M P 🔗

Developer, cloud engineer, and technical writer

  • Experience 12 years building web and cloud systems
  • Focus Full Stack Development, AWS, and Developer Education

I write practical tutorials so students and working developers can learn by doing—from databases and APIs to deployment on AWS.

6 people found this page helpful