TL;DR
deepmerge is the most flexible deep merge library — configurable array merge strategy, well-tested, and available in both CommonJS and ESM. lodash merge mutates the target object in place and is the most used (because lodash is already in most projects), but it has opinionated array behavior. defu (from the Unjs ecosystem) focuses on defaults semantics — it only fills in missing values, never overrides existing ones, and is used internally by Nuxt, Nitro, and H3. For configuration merging where existing values win: defu. For general deep merge with full control: deepmerge. For projects already using lodash: lodash merge.
Key Takeaways
- deepmerge: ~15M weekly downloads — configurable, no mutation, customizable array merging
- lodash merge: part of lodash (~40M/wk) — mutates target, arrays merged by index, widely familiar
- defu: ~12M weekly downloads — defaults-only semantics (existing values always win), Nuxt ecosystem
- deepmerge and defu do NOT mutate — lodash merge DOES mutate the first argument
- Array merging is where these libraries differ most: concatenate vs index-merge vs skip
- For TypeScript config merging with strong types, deepmerge-ts is worth considering
The Core Difference: Override vs Defaults
Override semantics (deepmerge, lodash merge):
merge({ a: 1 }, { a: 2 }) → { a: 2 } // right wins
Defaults semantics (defu):
defu({ a: 1 }, { a: 2 }) → { a: 1 } // existing value wins
Array merge (deepmerge default):
merge({ arr: [1, 2] }, { arr: [3, 4] }) → { arr: [1, 2, 3, 4] } // concatenate
Array merge (lodash merge):
_.merge({ arr: [1, 2] }, { arr: [3] }) → { arr: [3, 2] } // merge by index
Array merge (defu):
defu({ arr: [1, 2] }, { arr: [3, 4] }) → { arr: [1, 2] } // existing wins
deepmerge
deepmerge — configurable deep merge for JavaScript:
Basic usage
import deepmerge from "deepmerge"
const a = {
name: "react",
config: {
port: 3000,
features: ["ssr", "hydration"],
},
tags: ["ui", "frontend"],
}
const b = {
config: {
port: 4000, // Overrides a.config.port
debug: true, // Added
},
tags: ["component"], // Concatenated with a.tags
version: "18.3.1", // Added
}
const merged = deepmerge(a, b)
// {
// name: "react",
// config: { port: 4000, features: ["ssr", "hydration"], debug: true },
// tags: ["ui", "frontend", "component"],
// version: "18.3.1",
// }
// Note: a and b are NOT mutated — deepmerge always returns a new object
console.log(a.config.port) // Still 3000
Custom array merge strategy
import deepmerge from "deepmerge"
// Default: concatenate arrays
const concat = deepmerge(
{ items: [1, 2] },
{ items: [3, 4] }
)
// { items: [1, 2, 3, 4] }
// Override: replace arrays entirely (right wins)
const overwriteMerge = (dest: unknown[], src: unknown[]) => src
const replaced = deepmerge(
{ items: [1, 2] },
{ items: [3, 4] },
{ arrayMerge: overwriteMerge }
)
// { items: [3, 4] }
// Custom: union (deduplicate)
const unionMerge = (dest: unknown[], src: unknown[]) =>
[...new Set([...dest, ...src])]
const unioned = deepmerge(
{ tags: ["ui", "react"] },
{ tags: ["react", "typescript"] },
{ arrayMerge: unionMerge }
)
// { tags: ["ui", "react", "typescript"] }
// Combine multiple objects:
const merged = deepmerge.all([config1, config2, config3])
Merging configurations
import deepmerge from "deepmerge"
const defaultConfig = {
server: {
host: "localhost",
port: 3000,
cors: {
enabled: true,
origins: ["http://localhost:3000"],
},
},
database: {
pool: { min: 2, max: 10 },
timeout: 30000,
},
features: {
auth: true,
analytics: false,
},
}
const userConfig = {
server: {
port: 4000,
cors: {
origins: ["https://pkgpulse.com"], // Concatenated with defaults
},
},
features: {
analytics: true, // Override
},
}
const config = deepmerge(defaultConfig, userConfig, {
arrayMerge: (_dest, src) => src, // Replace arrays, don't concat
})
// {
// server: { host: "localhost", port: 4000, cors: { enabled: true, origins: ["https://pkgpulse.com"] } },
// database: { pool: { min: 2, max: 10 }, timeout: 30000 },
// features: { auth: true, analytics: true },
// }
TypeScript with deepmerge-ts
// deepmerge-ts: TypeScript-first, preserves types better than deepmerge
import { deepmerge, deepmergeCustom } from "deepmerge-ts"
interface Config {
port: number
features: string[]
}
const base: Partial<Config> = { port: 3000, features: ["ssr"] }
const override: Partial<Config> = { port: 4000, features: ["hydration"] }
// Type-safe merge — return type is inferred:
const result = deepmerge(base, override)
// result.port is number (TypeScript knows this)
// Custom merging with types:
const customMerge = deepmergeCustom({
mergeArrays: (arrays) => [...new Set(arrays.flat())],
})
const merged = customMerge(base, override)
// { port: 4000, features: ["ssr", "hydration"] }
lodash merge
lodash merge — deep merge built into lodash:
Basic usage
import _ from "lodash"
// Or: import merge from "lodash/merge" // Tree-shakable
const target = {
name: "react",
config: {
port: 3000,
features: ["ssr"],
},
}
const source = {
config: {
port: 4000,
debug: true,
},
version: "18.3.1",
}
// WARNING: merge MUTATES target!
_.merge(target, source)
console.log(target)
// { name: "react", config: { port: 4000, features: ["ssr"], debug: true }, version: "18.3.1" }
// target is mutated — config.port is now 4000
Array merge by index (lodash quirk)
import _ from "lodash"
// lodash merge merges arrays by index, not concatenation:
const a = { roles: ["admin", "editor", "viewer"] }
const b = { roles: ["superuser"] }
_.merge(a, b)
console.log(a.roles)
// ["superuser", "editor", "viewer"] ← NOT what you usually want!
// lodash merged by index: [0] = "superuser", [1] unchanged, [2] unchanged
// To avoid this, clone first:
const safe = _.merge({}, a, b) // Don't mutate a
// Or use cloneDeep + merge:
const result = _.merge(_.cloneDeep(defaultConfig), userConfig)
Immutable merge pattern
import _ from "lodash"
// Since _.merge mutates, use cloneDeep for immutable merge:
function mergeConfigs<T extends object>(...configs: Partial<T>[]): T {
return _.merge({}, ...configs) as T
}
const final = mergeConfigs(
defaultConfig,
envConfig,
userConfig
)
// defaultConfig, envConfig, userConfig are NOT mutated
When lodash merge is fine
import _ from "lodash"
// Building a new object from scratch — mutation doesn't matter:
function buildConfig(base: object, overrides: object) {
return _.merge({}, base, overrides)
}
// Normalizing API responses:
const normalized = _.merge({}, apiResponse, {
createdAt: new Date(apiResponse.createdAt),
tags: apiResponse.tags?.map((t: string) => t.toLowerCase()),
})
defu
defu — fill in defaults, never override:
Basic usage
import { defu } from "defu"
// defu fills in MISSING values — existing values are NEVER overridden:
const userConfig = {
port: 4000,
features: {
auth: true,
},
}
const defaults = {
port: 3000, // Ignored — userConfig.port already set
host: "localhost", // Added — userConfig.host is missing
features: {
auth: false, // Ignored — userConfig.features.auth already set
analytics: false, // Added — userConfig.features.analytics missing
},
timeout: 5000, // Added
}
const config = defu(userConfig, defaults)
// {
// port: 4000, ← from userConfig (not overridden)
// host: "localhost", ← from defaults (was missing)
// features: { auth: true, analytics: false },
// timeout: 5000,
// }
Multiple defaults layers
import { defu } from "defu"
// Layer priority: first argument wins, subsequent are fallbacks
const result = defu(
userConfig, // Highest priority
envConfig, // Second priority (fills what user didn't set)
defaultConfig, // Lowest priority (fills what neither set)
)
// This is exactly how Nuxt resolves its configuration:
// User's nuxt.config.ts → module defaults → Nuxt defaults
defu vs deepmerge for config
import { defu } from "defu"
import deepmerge from "deepmerge"
const userConfig = { port: 4000 }
const defaultConfig = { port: 3000, debug: false, host: "localhost" }
// defu: fill missing, keep existing
defu(userConfig, defaultConfig)
// { port: 4000, debug: false, host: "localhost" }
// → port 4000 kept (user set it)
// deepmerge: right side wins
deepmerge(defaultConfig, userConfig)
// { port: 4000, debug: false, host: "localhost" }
// → same result here, but semantics differ for nested objects
// Where they differ — nested objects:
const user = { db: { host: "myserver" } }
const defaults = { db: { host: "localhost", port: 5432 } }
defu(user, defaults)
// { db: { host: "myserver", port: 5432 } }
// → host kept, port filled
deepmerge(defaults, user)
// { db: { host: "myserver", port: 5432 } }
// → same result — but defu reads LEFT to RIGHT, deepmerge reads RIGHT as winner
Custom merger (defu)
import { createDefu } from "defu"
// Custom array handling:
const mergeWithArrayOverride = createDefu((obj, key, value) => {
if (Array.isArray(obj[key]) && Array.isArray(value)) {
// Replace array instead of keeping existing:
obj[key] = value
return true // Handled
}
})
const result = mergeWithArrayOverride(
{ tags: ["user-tag"] },
{ tags: ["default-tag-1", "default-tag-2"] }
)
// { tags: ["user-tag"] } ← user's array kept as-is (defu semantics)
Nuxt/UnJS ecosystem usage
// defu powers configuration in Nuxt, Nitro, H3, and other UnJS tools:
import { defu } from "defu"
// Module author pattern — users extend your defaults:
export function defineMyModule(userOptions = {}) {
const options = defu(userOptions, {
enabled: true,
debug: false,
endpoint: "/api",
retries: 3,
})
return options
}
// User gets:
const module = defineMyModule({ debug: true, retries: 5 })
// { enabled: true, debug: true, endpoint: "/api", retries: 5 }
// User's values win; defaults fill in the rest
Feature Comparison
| Feature | deepmerge | lodash merge | defu |
|---|---|---|---|
| Semantics | Override (right wins) | Override (right wins) | Defaults (left wins) |
| Mutation | ❌ Immutable | ✅ Mutates target | ❌ Immutable |
| Array strategy | Concatenate (default) | Merge by index | Keep existing |
| Custom arrays | ✅ arrayMerge option | ❌ | ✅ createDefu |
| ESM | ✅ | ✅ | ✅ |
| CJS | ✅ | ✅ | ✅ |
| TypeScript | ✅ | ✅ | ✅ |
| Bundle size | ~3KB | Part of lodash | ~2KB |
| Circular refs | ❌ | ❌ | ❌ |
| Multiple sources | ✅ deepmerge.all() | ✅ | ✅ Variadic |
When to Use Each
Choose deepmerge if:
- General-purpose deep merge where you need full control over array strategy
- You need immutable merging (no mutation of source objects)
- Merging plugin or extension configs where concatenation is the right array behavior
- Not already using lodash — no point adding it just for merge
Choose lodash merge if:
- Your project already uses lodash — no additional dependency
- You're building up a new object with
_.merge({}, ...)pattern - Team is familiar with lodash API
- Array-by-index merging works for your use case (or you handle arrays yourself)
Choose defu if:
- You're building a library or framework module with user-overridable defaults
- Configuration filling: user values should ALWAYS win over defaults
- Working in the Nuxt/Nitro/UnJS ecosystem (it's already a dep)
- You want defaults semantics without custom comparator logic
Use Object.assign or spread if:
// Shallow merge — fine for flat objects:
const config = { ...defaults, ...userConfig }
const merged = Object.assign({}, defaults, userConfig)
// Only fails when nested objects need deep merging:
const bad = { ...{ db: { host: "localhost", port: 5432 } }, ...{ db: { host: "myserver" } } }
// { db: { host: "myserver" } } ← port is LOST (shallow spread)
Mutation Safety and Immutability Patterns
The mutation behavior of _.merge is one of the most common sources of subtle bugs in JavaScript codebases, particularly when merge results are passed to multiple downstream consumers. If function A merges a configuration object and passes it to function B and function C, and then function B calls _.merge() to layer on additional options, it inadvertently mutates the configuration that function C will later read. This class of bug is notoriously difficult to find because the mutation happens at a distance — the reading code does not know the object was modified between creation and consumption. deepmerge and defu avoid this entirely because they always create new objects. The tradeoff is memory allocation: each merge creates new objects at every nesting level, which adds garbage collection pressure in tight loops. For configuration merging that happens once at application startup, this is irrelevant. For merge operations inside hot request paths, the allocation overhead can be measurable. The _.merge({}, ...) pattern — cloning the target with an empty object — is a common workaround for lodash's mutation issue, though it only prevents mutation of the first argument, not intermediate nested objects.
TypeScript Type Inference Challenges
Deep merge functions present a particularly difficult challenge for TypeScript's type system. When you merge two objects of different types, TypeScript must compute the resulting type, which is the intersection of both types with nested overrides applied. Vanilla Object.assign returns a union type that TypeScript handles well. _.merge returns the type of the first argument mutated in place, which TypeScript infers as the target type — this loses information about fields added from the source. deepmerge returns T & U (a simplified model), which is more accurate but can produce complex union types for deeply nested merges. deepmerge-ts is the library that takes type inference most seriously — it recursively resolves the merged type at the TypeScript level, producing accurate return types even for deeply nested merges with different array strategies. For configuration typing patterns where strict end-to-end type safety matters, deepmerge-ts or manually typed configuration builder functions are the most reliable options.
Handling Circular References and Special Object Types
None of the three libraries handle circular references — they will stack overflow if a circular reference exists in the objects being merged. For application data that might contain circular references (certain ORM entity graphs, for example), you must either break the circular reference before merging or use a library that explicitly handles it (such as lodash's _.cloneDeep, which has circular reference detection). Beyond circular references, the handling of special JavaScript objects — Date, Map, Set, RegExp, ArrayBuffer — varies by library. deepmerge treats these as plain objects and merges their enumerable properties, which is rarely what you want for a Date (you want the date to be cloned, not spread). deepmerge-ts's custom merger option allows you to handle these types explicitly: check if target is an instance of Date and return a new Date with the source's time value. lodash's _.merge similarly treats these as plain objects. defu, focused on shallow-to-deep defaults, handles these relatively well for its use case (filling in missing fields) but is not designed for merging complex object graphs.
Practical Configuration Patterns in Framework Development
The defu library's use case becomes especially clear when you examine how Nuxt and similar meta-frameworks structure their configuration. A Nuxt module can define a set of default options that the end user extends, a layer can add its own defaults, and the user's nuxt.config.ts sits at the top of the priority stack. Each layer in this stack should be able to provide defaults without overriding what higher-priority layers explicitly set — exactly the semantics defu provides. This pattern appears in many plugin and framework ecosystems: Vite plugins, ESLint configuration presets, Tailwind CSS configuration merging, and PostCSS plugin chains all need a way to let downstream consumers override defaults without higher-priority consumers losing their intentional settings. deepmerge with a custom array strategy can implement the same pattern, but defu's left-wins semantics map more intuitively to the "explicit user config beats defaults" mental model that configuration system users expect.
Performance Considerations for High-Frequency Merge Operations
For most applications, deep merge performance is irrelevant — configuration merging happens at startup, not in request-handling hot paths. But for applications that merge objects on every request (per-user configuration overlays, per-tenant settings assembly, A/B test variant merging), the allocation cost of deep merge adds up. deepmerge creates new objects at every nesting level, which generates garbage that the V8 engine must collect. For high-frequency use, consider structuring merges to use shallow Object.assign where possible, deep merge only for the nested configuration keys that actually differ between variants, and memoizing merge results keyed on the input versions. defu's performance profile is similar to deepmerge since it also creates new objects. lodash merge's mutation of the first argument means less garbage from nested objects, but the mutable result must be cloned before caching to prevent later mutations from corrupting cached values. For this use case, pre-computing all variant combinations at startup and storing the merged results is more effective than optimizing the merge implementation.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on deepmerge v4.x, lodash v4.x, and defu v6.x.
Compare utility and data processing packages on PkgPulse →
See also: Lodash vs Underscore and Lodash vs Ramda, acorn vs @babel/parser vs espree.