Flatten JSON in JavaScript: Recursive, Lodash, and the flat Package Compared

Last updated:

To flatten a JSON object is to turn a nested structure into a 1-level object where each key encodes the original path with a separator — usually a dot. So {user: {name: "Alice"}} becomes {"user.name": "Alice"}. This shape is what CSV writers, query-string encoders, and log aggregators expect. JavaScript gives you three solid ways to do it: a hand-rolled recursive function in about ten lines, the flat npm package, or lodash composition. Use the hand-rolled version for simple one-shot scripts; use the flat package for production code that needs to round-trip (flatten then unflatten back to the original).

Want to see what a flattened version of your JSON looks like before writing code? Paste it into Jsonic's JSON Formatter first to inspect the structure and key depth.

Inspect JSON structure

Hand-rolled recursive flatten in ~10 lines

The simplest implementation walks the object depth-first, building each key by appending the current property to the parent path. If the current value is a plain object, recurse; otherwise emit a leaf.

function flatten(obj, prefix = '', out = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const path = prefix ? prefix + '.' + key : key
    if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
      flatten(value, path, out)
    } else {
      out[path] = value
    }
  }
  return out
}

flatten({ user: { name: 'Alice', address: { city: 'NYC' } }, active: true })
// { 'user.name': 'Alice', 'user.address.city': 'NYC', 'active': true }

The three guards in the typeof check matter. value !== null is required because typeof null === 'object' in JavaScript — without it you would recurse into null and call Object.keys(null), throwing a TypeError. !Array.isArray(value) keeps arrays as leaf values (swap to recursion with index keys if you want indexed flattening — see Section 4).!(value instanceof Date) prevents dates from collapsing to empty objects because Object.keys(new Date()) returns [].

Pass the separator and an optional maxDepth as parameters if you need configurability — but at that point you are reinventing the flat package, which already handles both.

Using the flat package (npm install flat)

flat is the de-facto JavaScript flatten library. It is ~3kB, has no runtime dependencies, and exports two functions: flatten and unflatten. Both accept an options bag for separator, depth, and array handling.

npm install flat

// CommonJS
const { flatten, unflatten } = require('flat')

// ES module
import { flatten, unflatten } from 'flat'

const data = {
  user: { name: 'Alice', tags: ['admin', 'beta'] },
  meta: { created: '2026-01-01' }
}

flatten(data)
// {
//   'user.name': 'Alice',
//   'user.tags.0': 'admin',
//   'user.tags.1': 'beta',
//   'meta.created': '2026-01-01'
// }

flatten(data, { delimiter: '/', safe: true, maxDepth: 2 })
// {
//   'user/name': 'Alice',
//   'user/tags': ['admin', 'beta'],   // safe: true keeps arrays intact
//   'meta/created': '2026-01-01'
// }

unflatten({ 'user.name': 'Alice', 'user.tags.0': 'admin', 'user.tags.1': 'beta' })
// { user: { name: 'Alice', tags: ['admin', 'beta'] } }

The most useful options:

  • delimiter — separator string. Default "."; common alternatives are "/" or "_".
  • safe — when true, arrays are kept as-is rather than indexed. Round-trip-safe for array structure.
  • maxDepth — stop recursing past this depth. Useful when you only want the top N levels flattened.
  • object (on unflatten) — when true, never build arrays for numeric keys; always produce plain objects.
  • overwrite (on unflatten) — when true, later keys overwrite earlier ones at the same path instead of throwing.

The package is round-trip safe for most JSON-shaped data: unflatten(flatten(x)) deep-equals x as long as x has no circular references and no keys that contain the delimiter character.

Lodash patterns: _.toPairs vs custom

Lodash is the first place many developers look — and it does not have what you need._.flatten flattens arrays of arrays ([[1,2],[3]] [1,2,3]), not objects. _.flattenDeep and _.flattenDepth are the same — array-only.

const _ = require('lodash')

_.flatten([[1, 2], [3, [4]]])    // [1, 2, 3, [4]]
_.flattenDeep([[1, [2, [3]]]])   // [1, 2, 3]

// _.flatten does NOT do this:
_.flatten({ a: { b: 1 } })       // TypeError or unexpected output

The closest lodash composition uses _.toPairs to expand top-level entries — but it only goes one level deep, so you still need to write recursion yourself. At that point you have all the code from Section 1 plus a lodash import.

// Almost-but-not-really lodash flatten
function flattenWithLodash(obj, prefix = '') {
  return _.fromPairs(
    _.toPairs(obj).flatMap(([k, v]) => {
      const path = prefix ? prefix + '.' + k : k
      return _.isPlainObject(v)
        ? _.toPairs(flattenWithLodash(v, path))
        : [[path, v]]
    })
  )
}

The verdict: lodash is rarely the right tool for object flattening. If you already import lodash for other reasons, the recursion above is fine; if not, install flat or use the 10-line function from Section 1.

Handling arrays: indexed keys vs preserving

The choice of how to flatten arrays is the biggest semantic decision. Three modes are common, each with a clear use case.

ModeInputOutputUse case
Indexed (default){tags: ["a", "b"]}{"tags.0": "a", "tags.1": "b"}General — most expressive, round-trips back to arrays
Preserve (safe: true){tags: ["a", "b"]}{"tags": ["a", "b"]}Downstream code expects native arrays
Stringified leaf{tags: ["a", "b"]}{"tags": '["a","b"]'}CSV export where every cell must be a string
// Indexed mode — hand-rolled
function flattenIndexed(obj, prefix = '', out = {}) {
  if (Array.isArray(obj)) {
    obj.forEach((v, i) => flattenIndexed(v, prefix ? prefix + '.' + i : String(i), out))
  } else if (obj !== null && typeof obj === 'object' && !(obj instanceof Date)) {
    for (const [k, v] of Object.entries(obj)) {
      flattenIndexed(v, prefix ? prefix + '.' + k : k, out)
    }
  } else {
    out[prefix] = obj
  }
  return out
}

flattenIndexed({ items: [{ name: 'a' }, { name: 'b' }] })
// { 'items.0.name': 'a', 'items.1.name': 'b' }

Indexed mode is what you want for round-tripping. The flat package's unflatten rebuilds arrays from contiguous numeric path segments starting at 0 — pass {object: true} to suppress this and always get objects.

Edge cases: null, undefined, dates, circular references

The four values below break a naive implementation. Handle them explicitly.

function flattenSafe(obj, prefix = '', out = {}, seen = new WeakSet()) {
  // Circular guard
  if (typeof obj === 'object' && obj !== null) {
    if (seen.has(obj)) {
      out[prefix] = '[Circular]'
      return out
    }
    seen.add(obj)
  }

  // null  -> emit as leaf (typeof null === 'object' would recurse)
  if (obj === null) { out[prefix] = null; return out }

  // Date  -> emit ISO string (Object.keys(new Date()) === [])
  if (obj instanceof Date) { out[prefix] = obj.toISOString(); return out }

  // Array / object -> recurse
  if (Array.isArray(obj)) {
    if (obj.length === 0) { out[prefix] = []; return out }   // preserve empty arrays
    obj.forEach((v, i) => flattenSafe(v, prefix ? prefix + '.' + i : String(i), out, seen))
    return out
  }
  if (typeof obj === 'object') {
    const keys = Object.keys(obj)
    if (keys.length === 0) { out[prefix] = {}; return out }  // preserve empty objects
    for (const k of keys) flattenSafe(obj[k], prefix ? prefix + '.' + k : k, out, seen)
    return out
  }

  // Primitives (string, number, boolean, undefined, bigint, symbol)
  out[prefix] = obj
  return out
}
  • null — emit as a leaf with the value null. The typeof null === 'object' quirk is the most common bug.
  • undefined — choose: emit as a leaf (lossy on JSON-stringify), or skip the key entirely. The flat package emits it as-is.
  • Date — convert to ISO string. Object.keys(new Date()) is empty, so without this guard dates collapse to {}.
  • Map, Set, RegExp, Buffer — same problem as Date. Either emit them as opaque leaves or convert to a JSON-friendly representation explicitly.
  • Circular references — track visited objects in a WeakSet and substitute a sentinel when you see a repeat. Without this the recursion stack overflows.
  • Empty objects / empty arrays — choose: preserve them as leaves (above) or drop them. The default flat behavior is to preserve.

Unflatten: reversing the operation

Unflattening takes a 1-level object back to a nested one by splitting each key on the separator and writing the value at the resulting path.

function unflatten(flat, separator = '.') {
  const result = {}
  for (const [path, value] of Object.entries(flat)) {
    const parts = path.split(separator)
    let cursor = result
    for (let i = 0; i < parts.length - 1; i++) {
      const key = parts[i]
      const nextKey = parts[i + 1]
      const nextIsIndex = /^\d+$/.test(nextKey)
      if (cursor[key] == null) cursor[key] = nextIsIndex ? [] : {}
      cursor = cursor[key]
    }
    cursor[parts[parts.length - 1]] = value
  }
  return result
}

unflatten({ 'user.name': 'Alice', 'user.tags.0': 'admin', 'user.tags.1': 'beta' })
// { user: { name: 'Alice', tags: ['admin', 'beta'] } }

The non-trivial part is the numeric-key ambiguity. Is "items.0.name" an array with one element [{name: ...}] or an object {"0": {name: ...}}? The implementation above peeks at the next path segment and uses an array if it is numeric, an object otherwise. The flat package does the same and lets you override with {object: true} to always build objects — useful when your real data has string keys that happen to be numeric (e.g., year keys like {"2024": ..., "2025": ...}).

Other unflatten gotchas: keys containing the separator character (e.g., a key "a.b" in the source) cannot round-trip — they become path a → b after unflatten. Pick a separator that does not appear in your keys, or pre-escape the separator during flatten.

Performance: which approach for what size payload

For typical API responses (<100 keys, <5 levels deep), all three approaches flatten in well under a millisecond and the choice is purely about ergonomics. Differences show up only at scale.

ApproachBundle costSpeed (10k keys)When to use
Hand-rolled recursive0 (10 lines inline)~5–10 msScripts, one-off, no round-trip needed
flat package~3 kB~8–15 msProduction, needs unflatten, configurable
Lodash composition~24 kB (lodash) — free if already imported~15–25 msOnly if lodash is already in the bundle
JSON.stringify + regex0~20–40 msAlmost never — fragile and lossy

For very large payloads (>100k keys) recursion can hit the call-stack limit on extremely deep nesting. Convert to an explicit stack with an array if you see a "Maximum call stack size exceeded" error on deep input. Below ~10k call-stack frames you will be fine on every modern engine.

If you flatten the same shape many times (e.g., a hot path in a logging pipeline), the bigger win is structural: cache the set of paths once, then reuse it to extract values for each new object. Both flat and the hand-rolled function walk the structure from scratch each call.

Key terms

Flatten
Transform a nested object into a 1-level object whose keys encode the original nesting path with a separator. E.g., {a: {b: 1}}{"a.b": 1}.
Unflatten
The inverse: rebuild the nested object by splitting each key on the separator and writing the value at the resulting path. Round-trip safety depends on separator choice and array-handling mode.
Dot notation
The convention of joining path segments with "." — e.g., user.address.city. Convention only; not part of any JSON spec. Choose a different separator if your keys can contain dots.
Separator (delimiter)
The string used to join path segments in a flattened key. Common: ".", "/", "_", "__". Pick one that does not appear in your source keys.
Circular reference
An object that — directly or transitively — refers back to itself. Naive recursion never terminates. Detect with a WeakSet of visited objects and emit a sentinel value.
Deep object
An object with multiple levels of nested children. The "depth" is the maximum path length from root to any leaf. Both flat and hand-rolled functions accept a maxDepth to stop early.

Frequently asked questions

What does it mean to flatten a JSON object?

Flattening turns a nested object into a single-level object whose keys encode the original path. The convention is dot notation: {user: {name: "Alice", address: {city: "NYC"}}} becomes {"user.name": "Alice", "user.address.city": "NYC"}. Flat shapes are easier to write to a CSV row, encode as a query string, send to a log aggregator (Datadog, Splunk), or compare with a diff tool that does not understand nesting. The dot separator is convention only — RFC 8259 (the JSON spec) places no restriction on key characters, so a key that already contains a dot in the source object will collide with a synthesized path. If your source keys can contain dots, pick a separator you know is absent (often "/" or a Unicode separator like "\u001f").

What's the best library to flatten JSON in JavaScript?

For production code that needs to round-trip (flatten then unflatten back to the original shape), use the flat npm package — it is roughly 3kB, has both flatten and unflatten with configurable separator, max depth, and array handling, and has been stable since 2014. For a one-off script or when you cannot add a dependency, a hand-rolled recursive function in 10 lines does the job (see Section 1). Lodash is a poor fit because it does not ship a direct flatten-object function — _.flatten only flattens arrays of arrays. If lodash is already in your bundle you can compose _.toPairs and _.fromPairs with your own recursion, but you save nothing over the hand-rolled version. Pick flat if your code needs unflatten; pick the recursive function if it only needs the forward direction.

How do I unflatten a flattened object?

Unflattening rebuilds the nested structure by splitting each key on the separator and writing the value at the resulting path. The flat package exports an unflatten function that handles this plus options.object (force object outputs even for numeric keys), options.overwrite, and options.safe (skip flattening arrays so they survive a round trip intact). Hand-rolled: split the key, walk the path, create intermediate objects, set the leaf. The non-trivial part is deciding whether numeric path segments like "items.0.name" mean "array index 0" or "object key \"0\"" — the flat package builds an array when every numeric segment is contiguous starting at 0, and falls back to an object otherwise. Always test the round-trip flatten → unflatten on representative data; ambiguity around numeric keys, null leaves, and empty objects is where bugs hide.

How are arrays handled when flattening?

Three options. Index notation: {tags: ["a","b"]} becomes {"tags.0": "a", "tags.1": "b"} — the most general representation and what the flat package does by default. Preserve mode: leave the array as a leaf value, giving {"tags": ["a","b"]} — simpler but only useful if downstream consumers handle arrays. JSON-stringified leaf: encode the array as a string ("tags": "[\"a\",\"b\"]") — common for CSV exports where every cell must be a scalar. The flat package supports the first two via options.safe = true (preserve) or default (index). For a hand-rolled function, check Array.isArray before recursing: recurse with index keys to get indexed flattening, or return the array unchanged to get preserved mode. Round-trip safety usually favors indexed mode plus the package’s unflatten with options.object = false so arrays come back as arrays.

Can I flatten an object with circular references?

Not naively — a circular reference makes the recursion infinite and you will hit a "Maximum call stack size exceeded" error. The fix is to track visited objects in a WeakSet during the walk: before recursing into a value, check if it is already in the set; if so, either skip it, replace with a sentinel like "[Circular]", or throw. The flat package does NOT detect cycles and will overflow the stack on circular input, so guard at the call site (e.g., with JSON.stringify and a replacer that tracks references) or use a library like flatted that is built for cyclic structures. In practice, most JSON-shaped data — anything that round-trips through JSON.stringify and JSON.parse — cannot be circular, so this is mainly a concern when flattening live in-memory objects (e.g., DOM nodes, ORM models, anything with parent backreferences).

How do I change the separator from "." to something else?

With the flat package: pass {delimiter: "/"} (or any string) to both flatten and unflatten. With a hand-rolled function: pass the separator as a parameter and concatenate it instead of the literal ".". Common alternatives are "/" (path-like, no collision with dotted hostnames or version numbers), "_" (safe for environment-variable names — useful when flattening for process.env-style configs), "__" (double underscore, the Node.js convention for env vars: AWS_S3__BUCKET), or "\u001f" (ASCII unit separator, guaranteed not to appear in normal text). The right choice depends on what consumes the flat output: pick something you know is absent from your source keys and is legal in the target format (CSV column header, env var name, URL query parameter).

Why does my flattened object lose null values?

Because typeof null === "object" in JavaScript — a famous historical quirk. If your recursive function only checks typeof value === "object" before recursing, it will treat null as an object to descend into, call Object.keys(null), and either throw or silently produce nothing depending on how you guard. The fix is to check value !== null && typeof value === "object" — or, more robustly, value !== null && typeof value === "object" && !Array.isArray(value) if you also want to preserve arrays. The flat package gets this right by default and emits null as a leaf value. Same trap with Date instances (typeof === "object" but Object.keys returns []) — explicitly check value instanceof Date and emit it as a leaf, otherwise dates collapse to empty objects.

How is this different from JSON.stringify with a replacer?

JSON.stringify with a replacer function transforms values during serialization but does NOT change the shape — the output is still nested JSON, just with some keys filtered or values rewritten. Flattening changes the shape: a 5-level-deep object becomes 1-level deep with composite keys. The two solve different problems. Use a replacer to remove sensitive fields, mask secrets, or pretty-print a date; use flatten when the downstream consumer literally cannot handle nesting (a CSV row, a URL query string, a metrics tag set, a key-value store like Redis hashes). You can chain them — flatten first, then JSON.stringify the flat object for transport — but a single replacer cannot do what flatten does because the replacer cannot synthesize new keys from a path.

Further reading and primary sources