Skip to main content

deepmerge vs lodash merge vs defu: Deep Merge in JavaScript (2026)

·PkgPulse Team

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)

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.