deepmerge vs lodash merge vs defu: Deep Merge in JavaScript (2026)
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)
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on deepmerge v4.x, lodash v4.x, and defu v6.x.