Skip to main content

Guide

deepmerge vs lodash merge vs defu 2026

Compare deepmerge, lodash merge, and defu for deep object merging in JavaScript. Array merging strategies, defaults vs override semantics, TypeScript.

·PkgPulse Team·
0

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

Featuredeepmergelodash mergedefu
SemanticsOverride (right wins)Override (right wins)Defaults (left wins)
Mutation❌ Immutable✅ Mutates target❌ Immutable
Array strategyConcatenate (default)Merge by indexKeep existing
Custom arraysarrayMerge optioncreateDefu
ESM
CJS
TypeScript
Bundle size~3KBPart of lodash~2KB
Circular refs
Multiple sourcesdeepmerge.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.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.