Flatten Nested JSON: JavaScript, Python, flat npm & pandas

Last updated:

Flattening nested JSON converts { "address": { "city": "NYC", "zip": "10001" } } to { "address.city": "NYC", "address.zip": "10001" } — dot-notation keys at one level, enabling simple key-value storage, CSV export, and form field mapping. The most common issue with custom flatten functions is array handling — { "tags": ["a", "b"] } can flatten to { "tags.0": "a", "tags.1": "b" } (index notation) or { "tags": ["a", "b"] } (preserve arrays). The flat npm library and Python's flatten_dict handle both strategies with a configuration option. This guide covers recursive JavaScript flatten implementation, the flat npm package, Python flatten_dict and pandas json_normalize(), circular reference detection, unflattening (reconstructing nested from flat), and common use cases: CSV export, Elasticsearch index mapping, and environment variable generation.

Recursive JavaScript Flatten Implementation

The core flatten algorithm recurses through every key of a nested object. If a value is a plain object (not null, not an array), recurse deeper with the current path as a prefix. If the value is a leaf (primitive, null, or array), write it to the result under the dot-joined key. The function accumulates results in a single output object passed by reference — avoiding repeated object merges which are O(n) per level.

// ── Basic recursive flatten ───────────────────────────────────────
function flatten(obj, prefix = "", result = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;

    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
      // Plain object — recurse deeper
      flatten(value, flatKey, result);
    } else {
      // Leaf: primitive, null, or array
      result[flatKey] = value;
    }
  }
  return result;
}

const nested = {
  address: { city: "NYC", zip: "10001" },
  user: { name: "Alice", age: 30 },
  tags: ["admin", "editor"],       // array treated as leaf (preserve)
  active: true,
  score: null,
};

console.log(flatten(nested));
// {
//   "address.city": "NYC",
//   "address.zip": "10001",
//   "user.name": "Alice",
//   "user.age": 30,
//   "tags": ["admin", "editor"],  // array preserved
//   "active": true,
//   "score": null,
// }

// ── Custom delimiter ──────────────────────────────────────────────
function flattenWithDelimiter(obj, delimiter = ".", prefix = "", result = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}${delimiter}${key}` : key;
    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
      flattenWithDelimiter(value, delimiter, flatKey, result);
    } else {
      result[flatKey] = value;
    }
  }
  return result;
}

// Use "__" for env vars (dots not allowed in most shells)
flattenWithDelimiter({ db: { host: "localhost", port: 5432 } }, "__");
// { "db__host": "localhost", "db__port": 5432 }

// ── Max depth limit ───────────────────────────────────────────────
function flattenMaxDepth(obj, maxDepth = Infinity, prefix = "", depth = 0, result = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;
    if (
      depth < maxDepth &&
      value !== null &&
      typeof value === "object" &&
      !Array.isArray(value)
    ) {
      flattenMaxDepth(value, maxDepth, flatKey, depth + 1, result);
    } else {
      result[flatKey] = value;  // stop at maxDepth — keep sub-object as-is
    }
  }
  return result;
}

flattenMaxDepth({ a: { b: { c: { d: 1 } } } }, 2);
// { "a.b": { c: { d: 1 } } }  — stops at depth 2, keeps deeper structure

// ── TypeScript typed version ──────────────────────────────────────
type FlatObject = Record<string, unknown>;

function flattenTyped(
  obj: Record<string, unknown>,
  prefix = "",
  result: FlatObject = {}
): FlatObject {
  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;
    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
      flattenTyped(value as Record<string, unknown>, flatKey, result);
    } else {
      result[flatKey] = value;
    }
  }
  return result;
}

Pass the result accumulator object as a parameter rather than returning a new object from each recursive call — merging objects with spread (...obj) at every level creates O(depth × keys) intermediate objects, which is a significant memory issue for deeply nested structures with many keys. For JSON from untrusted sources, always add a max-depth guard to prevent stack overflow from pathologically nested input. The TypeScript version uses Record<string, unknown> as the input type — avoid any, which defeats type checking downstream.

The flat npm Package: flatten() and unflatten()

The flat npm package provides production-ready flatten() and unflatten() functions that handle edge cases a basic recursive implementation misses: empty objects, prototype pollution prevention, custom delimiters, safe mode for arrays, and max depth. Install with npm install flat. It ships both CommonJS and ES module exports.

import { flatten, unflatten } from "flat";

// ── Basic usage ───────────────────────────────────────────────────
const nested = {
  user: { name: "Alice", address: { city: "NYC", zip: "10001" } },
  tags: ["admin", "editor"],
  score: 42,
};

const flat = flatten(nested);
// {
//   "user.name": "Alice",
//   "user.address.city": "NYC",
//   "user.address.zip": "10001",
//   "tags.0": "admin",           // arrays expanded by default
//   "tags.1": "editor",
//   "score": 42,
// }

// ── safe: true — preserve arrays as leaf values ───────────────────
const flatSafe = flatten(nested, { safe: true });
// {
//   "user.name": "Alice",
//   "user.address.city": "NYC",
//   "user.address.zip": "10001",
//   "tags": ["admin", "editor"], // array preserved
//   "score": 42,
// }

// ── Custom delimiter ──────────────────────────────────────────────
const flatUnderscore = flatten(nested, { delimiter: "_", safe: true });
// { "user_name": "Alice", "user_address_city": "NYC", ... }

// ── maxDepth: stop recursing at a given depth ─────────────────────
const flatShallow = flatten(
  { a: { b: { c: { d: 1 } } } },
  { maxDepth: 2 }
);
// { "a.b": { c: { d: 1 } } }

// ── transformKey: modify each key segment ─────────────────────────
const flatCamel = flatten(
  { user_info: { first_name: "Alice" } },
  {
    transformKey: (key) =>
      key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), // snake_case → camelCase
  }
);
// { "userInfo.firstName": "Alice" }

// ── unflatten: reconstruct nested structure ───────────────────────
const reconstructed = unflatten({
  "user.name": "Alice",
  "user.address.city": "NYC",
  "user.address.zip": "10001",
  "score": 42,
});
// { user: { name: "Alice", address: { city: "NYC", zip: "10001" } }, score: 42 }

// ── unflatten with custom delimiter ──────────────────────────────
const nested2 = unflatten(
  { "user_address_city": "NYC" },
  { delimiter: "_" }
);
// { user: { address: { city: "NYC" } } }

// ── unflatten array index keys ────────────────────────────────────
const withArrays = unflatten({
  "tags.0": "admin",
  "tags.1": "editor",
  "user.name": "Alice",
});
// { tags: ["admin", "editor"], user: { name: "Alice" } }

// ── CommonJS usage ────────────────────────────────────────────────
// const { flatten, unflatten } = require("flat");

The flat package prevents prototype pollution — it does not set properties like __proto__ or constructor on the result object, which a naive implementation using bracket notation would do if the input JSON contained those keys. This makes flat safe for use with untrusted JSON input. The transformKey option is particularly useful when flattening JSON from APIs that use snake_case keys into camelCase for JavaScript consumers. Use delimiter: "_" when the flat keys will be used as environment variable names — dots are not valid in most shell environments.

Handling Arrays: Index Notation vs Preserve

Arrays in nested JSON require a deliberate choice: expand them to numbered keys (tags.0, tags.1) or preserve them as array values. The wrong strategy for the downstream system causes type errors, lost data, or broken queries. Index notation makes every value a scalar — required for CSV columns, environment variables, and key-value stores. Preserve mode keeps arrays intact — required when the consumer handles arrays natively (Elasticsearch, databases, form validators).

// ── Strategy 1: Index notation — expand arrays to numbered keys ───
function flattenIndexArrays(obj, prefix = "", result = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;

    if (Array.isArray(value)) {
      // Expand array: recurse into each element with index as key
      value.forEach((item, i) => {
        const itemKey = `${flatKey}.${i}`;
        if (item !== null && typeof item === "object") {
          flattenIndexArrays(item, itemKey, result);
        } else {
          result[itemKey] = item;
        }
      });
    } else if (value !== null && typeof value === "object") {
      flattenIndexArrays(value, flatKey, result);
    } else {
      result[flatKey] = value;
    }
  }
  return result;
}

const input = {
  user: { name: "Alice" },
  tags: ["admin", "editor"],
  scores: [{ subject: "math", grade: 90 }, { subject: "science", grade: 85 }],
};

console.log(flattenIndexArrays(input));
// {
//   "user.name": "Alice",
//   "tags.0": "admin",
//   "tags.1": "editor",
//   "scores.0.subject": "math",
//   "scores.0.grade": 90,
//   "scores.1.subject": "science",
//   "scores.1.grade": 85,
// }

// ── Strategy 2: Preserve arrays — treat as leaf values ────────────
function flattenPreserveArrays(obj, prefix = "", result = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;
    // Arrays are leaves — do not recurse into them
    if (!Array.isArray(value) && value !== null && typeof value === "object") {
      flattenPreserveArrays(value, flatKey, result);
    } else {
      result[flatKey] = value;  // primitive, null, OR array
    }
  }
  return result;
}

console.log(flattenPreserveArrays(input));
// {
//   "user.name": "Alice",
//   "tags": ["admin", "editor"],          // array preserved
//   "scores": [{ ... }, { ... }],         // array preserved
// }

// ── flat npm: safe option controls the same choice ────────────────
import { flatten } from "flat";

// safe: false (default) — index notation
flatten(input);
// { "tags.0": "admin", "tags.1": "editor", ... }

// safe: true — preserve arrays
flatten(input, { safe: true });
// { "tags": ["admin", "editor"], ... }

// ── Choosing the right strategy ───────────────────────────────────
// Index notation (safe: false) → CSV export, env vars, key-value stores
// Preserve arrays (safe: true)  → Elasticsearch, MongoDB, form validators
// Arrays of objects with index → Elasticsearch nested field mapping

// ── Rejoin array items as a string (CSV-safe) ─────────────────────
function flattenArraysJoined(obj, prefix = "", sep = "|", result = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;
    if (Array.isArray(value)) {
      result[flatKey] = value.join(sep);  // "admin|editor"
    } else if (value !== null && typeof value === "object") {
      flattenArraysJoined(value, flatKey, sep, result);
    } else {
      result[flatKey] = value;
    }
  }
  return result;
}

flattenArraysJoined({ tags: ["admin", "editor"] });
// { "tags": "admin|editor" }  — single CSV cell, separator-joined

The join-as-string strategy ("admin|editor") is the most CSV-friendly for array values — it produces a single column without multiplying rows and preserves all values in one cell. Use a separator that does not appear in the values — pipe (|) is common, but comma is dangerous in CSVs. For arrays of objects (like scores above), index notation is the only way to represent all fields as scalars — each object expands into its own set of columns prefixed with the array index.

Python: flatten_dict and pandas json_normalize()

Python has two main approaches to flattening nested JSON: the flatten_dict library for pure-Python pipelines, and pandas json_normalize() for data analysis workflows. Both produce a flat dictionary from nested dicts. The zero-dependency recursive approach works for simple scripts but lacks the robustness of the libraries.

# ── pandas json_normalize() ───────────────────────────────────────
import pandas as pd

data = {
    "user": {"name": "Alice", "address": {"city": "NYC", "zip": "10001"}},
    "tags": ["admin", "editor"],
    "score": 42,
}

# Flatten a single dict — wrap in list, extract record 0
flat = pd.json_normalize(data, sep=".").to_dict(orient="records")[0]
# {
#   "user.name": "Alice",
#   "user.address.city": "NYC",
#   "user.address.zip": "10001",
#   "tags": ["admin", "editor"],  # arrays preserved as list
#   "score": 42,
# }

# Flatten a list of records — returns a DataFrame
records = [
    {"user": {"name": "Alice"}, "score": 90},
    {"user": {"name": "Bob"},   "score": 85},
]
df = pd.json_normalize(records, sep=".")
#   user.name  score
# 0     Alice     90
# 1       Bob     85

df.to_csv("output.csv", index=False)

# Flatten nested arrays of objects with record_path
orders = [
    {
        "id": 1,
        "items": [
            {"product": "Widget", "qty": 2},
            {"product": "Gadget", "qty": 1},
        ],
    }
]
df_items = pd.json_normalize(
    orders,
    record_path=["items"],   # expand this array of objects
    meta=["id"],             # include these top-level fields as columns
    sep=".",
)
#   product  qty  id
# 0  Widget    2   1
# 1  Gadget    1   1

# ── flatten_dict library ──────────────────────────────────────────
# pip install flatten_dict
from flatten_dict import flatten, unflatten

nested = {"a": {"b": {"c": 1}}, "d": 2}

# reducer controls how key segments are joined
flat_dot   = flatten(nested, reducer="dot")   # {"a.b.c": 1, "d": 2}
flat_tuple = flatten(nested, reducer="tuple") # {("a","b","c"): 1, ("d",): 2}

# Custom reducer function
flat_slash = flatten(nested, reducer=lambda k1, k2: f"{k1}/{k2}" if k1 else k2)
# {"a/b/c": 1, "d": 2}

# Unflatten with matching splitter
reconstructed = unflatten(flat_dot, splitter="dot")
# {"a": {"b": {"c": 1}}, "d": 2}

# ── Zero-dependency recursive flatten ────────────────────────────
def flatten_json(obj, prefix="", sep="."):
    items = {}
    for k, v in obj.items():
        flat_key = f"{prefix}{sep}{k}" if prefix else k
        if isinstance(v, dict):
            items.update(flatten_json(v, flat_key, sep))
        else:
            items[flat_key] = v  # list, scalar, or None kept as-is
    return items

flatten_json({"a": {"b": 1}, "c": 2})
# {"a.b": 1, "c": 2}

# ── Environment variable export (separator "__") ──────────────────
env_vars = flatten_json(
    {"database": {"host": "localhost", "port": 5432}, "debug": True},
    sep="__"
)
# {"database__host": "localhost", "database__port": 5432, "debug": True}

for key, value in env_vars.items():
    print(f'export {key.upper()}={value}')

pd.json_normalize() is the right choice when the flattened data is going into a DataFrame for analysis, CSV export, or machine learning pipelines — it handles nested dicts and arrays of objects natively, and the result is immediately usable with pandas operations. Use flatten_dict when working in pure Python without pandas — it handles edge cases cleanly and supports tuple keys which are useful for programmatic key access without string splitting. Use sep="__" (double underscore) when generating environment variables from config JSON — dots and slashes are not valid in most shell variable names.

Unflatten: Reconstructing Nested JSON from Dot Keys

Unflattening reverses the process: split each dot-notation key on the delimiter, traverse or create intermediate objects for each segment, and set the leaf value at the final key. The key challenge is handling numeric segments — tags.0 and tags.1 should reconstruct as an array, not an object with keys "0" and "1".

// ── Basic unflatten (objects only, no array reconstruction) ───────
function unflattened(flat, delimiter = ".") {
  const result = {};
  for (const [key, value] of Object.entries(flat)) {
    const parts = key.split(delimiter);
    let current = result;
    for (let i = 0; i < parts.length - 1; i++) {
      const part = parts[i];
      current[part] ??= {};        // create intermediate object if missing
      current = current[part];
    }
    current[parts[parts.length - 1]] = value;
  }
  return result;
}

unflattened({
  "address.city": "NYC",
  "address.zip": "10001",
  "score": 42,
});
// { address: { city: "NYC", zip: "10001" }, score: 42 }

// ── Unflatten with array reconstruction ──────────────────────────
// Detect numeric segments → build arrays instead of objects
function unflattenWithArrays(flat, delimiter = ".") {
  const result = {};

  for (const [key, value] of Object.entries(flat)) {
    const parts = key.split(delimiter);
    let current = result;

    for (let i = 0; i < parts.length - 1; i++) {
      const part = parts[i];
      const nextPart = parts[i + 1];
      const nextIsIndex = /^\d+$/.test(nextPart);

      // Initialize as array if next segment is a numeric index
      if (current[part] === undefined) {
        current[part] = nextIsIndex ? [] : {};
      }
      current = current[part];
    }

    const lastPart = parts[parts.length - 1];
    current[lastPart] = value;
  }

  return result;
}

unflattenWithArrays({
  "tags.0": "admin",
  "tags.1": "editor",
  "user.name": "Alice",
  "scores.0.subject": "math",
  "scores.0.grade": 90,
});
// {
//   tags: ["admin", "editor"],
//   user: { name: "Alice" },
//   scores: [{ subject: "math", grade: 90 }],
// }

// ── flat npm: unflatten() with options ───────────────────────────
import { unflatten } from "flat";

// Default: reconstructs arrays from numeric index keys
unflatten({ "tags.0": "admin", "tags.1": "editor" });
// { tags: ["admin", "editor"] }

// object: true — force numeric keys to become object keys ("0", "1")
unflatten({ "tags.0": "admin", "tags.1": "editor" }, { object: true });
// { tags: { "0": "admin", "1": "editor" } }

// ── Python unflatten ──────────────────────────────────────────────
def unflatten_json(flat, sep="."):
    result = {}
    for key, value in flat.items():
        parts = key.split(sep)
        current = result
        for part in parts[:-1]:
            current = current.setdefault(part, {})
        current[parts[-1]] = value
    return result

unflatten_json({"a.b.c": 1, "a.d": 2})
# {"a": {"b": {"c": 1}, "d": 2}}

# flatten_dict library
from flatten_dict import unflatten
unflatten({"a.b.c": 1}, splitter="dot")
# {"a": {"b": {"c": 1}}}

The ??= operator (nullish assignment, available in Node.js 15+ / modern browsers) is the cleanest way to initialize intermediate objects without overwriting existing values — it is equivalent to if (current[part] === undefined || current[part] === null) current[part] = {}. When unflattening, the input key order matters for array reconstruction: if tags.1 appears before tags.0 in the flat object, the reconstructed array may have holes. Sort the keys before unflattening if the source is a serialized JSON object where key order is not guaranteed.

Circular Reference Detection

A circular reference occurs when an object contains itself — directly or through a chain of references. Naive recursive flatten enters infinite recursion and crashes with a stack overflow. Detect circular references using a Set of visited objects: check before recursing, add before descending, remove after returning (to allow diamond references — the same object appearing in multiple branches without a cycle).

// ── Circular reference detection with Set ─────────────────────────
function flattenSafe(obj, prefix = "", result = {}, seen = new Set()) {
  if (seen.has(obj)) {
    throw new Error(`Circular reference detected at key: "${prefix}"`);
  }
  seen.add(obj);

  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;

    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
      flattenSafe(value, flatKey, result, seen);
    } else {
      result[flatKey] = value;
    }
  }

  // Remove after processing children — allows diamond references (not cycles)
  seen.delete(obj);
  return result;
}

// ── Safe usage ────────────────────────────────────────────────────
const obj = { a: { b: 1 }, c: { d: 2 } };
flattenSafe(obj);  // { "a.b": 1, "c.d": 2 } — no circular ref

// ── Circular reference — caught before stack overflow ─────────────
const circular = { name: "Alice" };
circular.self = circular;   // direct self-reference

try {
  flattenSafe(circular);
} catch (e) {
  console.error(e.message);
  // "Circular reference detected at key: "self""
}

// ── Diamond reference — allowed (same object, different paths) ────
const shared = { value: 42 };
const diamond = { a: shared, b: shared };   // shared object, not a cycle
flattenSafe(diamond);
// { "a.value": 42, "b.value": 42 }  — works correctly with seen.delete()

// ── Arrays with circular references ──────────────────────────────
function flattenFullSafe(obj, prefix = "", result = {}, seen = new Set()) {
  if (seen.has(obj)) {
    throw new Error(`Circular reference at: "${prefix}"`);
  }
  seen.add(obj);

  for (const [key, value] of Object.entries(obj)) {
    const flatKey = prefix ? `${prefix}.${key}` : key;

    if (Array.isArray(value)) {
      // Check array for circular references in its elements
      value.forEach((item, i) => {
        if (item !== null && typeof item === "object") {
          flattenFullSafe(item, `${flatKey}.${i}`, result, seen);
        } else {
          result[`${flatKey}.${i}`] = item;
        }
      });
    } else if (value !== null && typeof value === "object") {
      flattenFullSafe(value, flatKey, result, seen);
    } else {
      result[flatKey] = value;
    }
  }

  seen.delete(obj);
  return result;
}

// ── Python: circular reference detection ──────────────────────────
def flatten_safe(obj, prefix="", sep=".", seen=None):
    if seen is None:
        seen = set()
    obj_id = id(obj)
    if obj_id in seen:
        raise ValueError(f"Circular reference detected at: '{prefix}'")
    seen.add(obj_id)

    items = {}
    for k, v in obj.items():
        flat_key = f"{prefix}{sep}{k}" if prefix else k
        if isinstance(v, dict):
            items.update(flatten_safe(v, flat_key, sep, seen))
        else:
            items[flat_key] = v

    seen.discard(obj_id)  # allow diamond references
    return items

Python uses id(obj) as the Set key rather than the object itself — Python dicts are not hashable and cannot be added to a set directly. This works correctly because id() returns the memory address of the object, which is unique for live objects. Note that after seen.discard(obj_id), the ID could theoretically be reused by a new object — but this cannot happen within a single synchronous traversal since the original object is still alive. The diamond-vs-cycle distinction matters: seen.delete(obj) after processing is not optional — omitting it would cause diamond references (valid and common in real data) to throw false circular reference errors.

Use Cases: CSV Export, Elasticsearch, and Environment Variables

Flattening unlocks three major use cases where nested JSON does not fit natively: CSV export (tabular format requires scalar cells), Elasticsearch field mapping (explicit per-field type definitions), and environment variable generation (shell variables must be flat key-value pairs). Each use case has specific requirements for delimiter choice, array handling, and key naming.

// ── Use Case 1: CSV Export ────────────────────────────────────────
import { flatten } from "flat";

const records = [
  { user: { name: "Alice", age: 30 }, tags: ["admin"], active: true },
  { user: { name: "Bob",   age: 25 }, tags: ["editor"], active: false },
];

// Flatten all records
const flatRecords = records.map((r) => flatten(r, { safe: true }));
// safe: true — preserves tags array (will join it in CSV)

// Collect all headers across all records (union of keys)
const headers = [...new Set(flatRecords.flatMap((r) => Object.keys(r)))];
// ["user.name", "user.age", "tags", "active"]

// Build CSV rows
function toCsvCell(value) {
  if (Array.isArray(value)) return `"${value.join("|")}"`;  // join arrays
  if (typeof value === "string" && (value.includes(",") || value.includes('"'))) {
    return `"${value.replace(/"/g, '""')}"`;  // escape quotes
  }
  return String(value ?? "");
}

const csvLines = [
  headers.join(","),
  ...flatRecords.map((row) =>
    headers.map((h) => toCsvCell(row[h] ?? "")).join(",")
  ),
];
console.log(csvLines.join("\n"));
// user.name,user.age,tags,active
// Alice,30,"admin",true
// Bob,25,"editor",false

// Python CSV — pandas handles everything
import pandas as pd
df = pd.json_normalize(records, sep=".")
df.to_csv("users.csv", index=False)

// ── Use Case 2: Elasticsearch field mapping ───────────────────────
// Build explicit mappings from a sample document
function buildElasticsearchMapping(flatDoc) {
  const properties = {};
  for (const [key, value] of Object.entries(flatDoc)) {
    const type = typeof value === "number"
      ? (Number.isInteger(value) ? "integer" : "float")
      : typeof value === "boolean"
      ? "boolean"
      : "text";  // default — refine to "keyword" for exact-match fields

    // Convert dot-notation back to nested mapping structure
    const parts = key.split(".");
    let current = properties;
    for (let i = 0; i < parts.length - 1; i++) {
      current[parts[i]] ??= { properties: {} };
      current = current[parts[i]].properties;
    }
    current[parts[parts.length - 1]] = { type };
  }
  return { mappings: { properties } };
}

const sample = flatten({ user: { name: "Alice", age: 30 }, score: 9.5 });
console.log(JSON.stringify(buildElasticsearchMapping(sample), null, 2));
// {
//   "mappings": {
//     "properties": {
//       "user": { "properties": { "name": { "type": "text" }, "age": { "type": "integer" } } },
//       "score": { "type": "float" }
//     }
//   }
// }

// ── Use Case 3: Environment Variable Generation ───────────────────
import { flatten } from "flat";

const config = {
  database: { host: "localhost", port: 5432, name: "mydb" },
  redis:    { url: "redis://localhost:6379" },
  debug:    true,
};

const envVars = flatten(config, { delimiter: "__", safe: true });
// {
//   "database__host": "localhost",
//   "database__port": 5432,
//   "database__name": "mydb",
//   "redis__url": "redis://localhost:6379",
//   "debug": true,
// }

const envLines = Object.entries(envVars)
  .map(([k, v]) => `${k.toUpperCase()}=${v}`)
  .join("\n");

console.log(envLines);
// DATABASE__HOST=localhost
// DATABASE__PORT=5432
// DATABASE__NAME=mydb
// REDIS__URL=redis://localhost:6379
// DEBUG=true

For CSV export, use sep="_" instead of sep="." in pandas — dots in column names cause issues in SQL databases and some spreadsheet tools. For Elasticsearch, the Elasticsearch JSON flattened field type (available since ES 7.3) accepts the entire nested object and handles dot-notation internally — pre-flattening is only necessary when you need explicit per-field type mappings. For environment variables, uppercase the keys and use double underscore (__) as the delimiter — single underscore would conflict with variable names that legitimately contain underscores, and tools like python-dotenv and many 12-factor app frameworks recognize the double-underscore convention as a nesting separator. See also our guides on JSON transform, JSON to CSV, and JSON performance.

Key Terms

flatten
The process of converting a deeply nested JSON object into a single-level object where all nested keys are represented as dot-notation strings. A nested structure like { "a": { "b": { "c": 1 } } } becomes { "a.b.c": 1 }. Flattening eliminates all intermediate objects — the result contains only leaf values (primitives, nulls, and optionally arrays). The inverse operation is called unflattening. Flattening is lossless as long as the delimiter character does not appear in any key name; if keys may contain the delimiter, use a different separator or encode the keys.
dot-notation key
A string key that represents a path through a nested object hierarchy using dots as separators. The key address.city in a flat object corresponds to obj.address.city in the nested form. Dot notation is the default delimiter for most JSON flatten libraries because it mirrors JavaScript property access syntax. Alternative separators include underscore (_) for environment variables, slash (/) for URL-like paths, and double underscore (__) to distinguish from single underscores in names. Keys that contain literal dots must be escaped or a different delimiter must be chosen.
unflatten
The inverse of flatten — reconstructing a nested object from a flat object with dot-notation keys. Each key is split on the delimiter and the value is placed at the corresponding nested path, creating intermediate objects as needed. Numeric path segments (e.g., tags.0, tags.1) are typically reconstructed as array indices rather than object keys. Unflattening assumes the delimiter does not appear in original key names — if it does, reconstruction produces incorrect nesting. The flat npm package provides unflatten(); Python's flatten_dict provides a matching unflatten() function.
flat npm package
A zero-dependency Node.js package (npm install flat) providing flatten(obj, options) and unflatten(obj, options) functions. Key options: delimiter (default ".") — the separator between key segments; safe (default false) — when true, arrays are preserved as leaf values instead of being expanded with index notation; maxDepth — stop recursing at a given nesting depth; transformKey — a function to transform each key segment. The package prevents prototype pollution by rejecting dangerous keys like __proto__. Available as both CommonJS (require("flat")) and ES module (import { flatten } from "flat").
pandas json_normalize
A pandas function (pd.json_normalize(data, sep=".")) that converts nested JSON records (dicts or lists of dicts) into a flat pandas DataFrame. The sep parameter controls the delimiter (default "."). For a list of records, each dict in the list becomes a row. The record_path parameter expands nested arrays of objects into rows, and meta specifies top-level fields to include as columns alongside the expanded records. Arrays of primitives are preserved as list-typed columns unless further expanded. The result is a DataFrame that can be exported directly to CSV with df.to_csv().
circular reference
A data structure where an object references itself directly (obj.self = obj) or indirectly through a chain of properties, creating an infinite loop when traversed recursively. JSON.stringify throws TypeError: Converting circular structure to JSON when it encounters one. A flatten function without circular reference detection enters infinite recursion and throws a stack overflow. Detection uses a Set of visited object references (JavaScript) or id() values (Python) — if the current object is already in the Set, a cycle is detected and an error is thrown. Diamond references (the same object reachable via two paths but without a cycle) are valid and must be handled correctly by removing objects from the Set after processing their children.

FAQ

How do I flatten a nested JSON object in JavaScript?

Write a recursive function that iterates over Object.entries(obj). If a value is a plain object (not null, not an array), recurse with the current key as prefix. Otherwise write the leaf value under the dot-joined key: function flatten(obj, prefix = "", result = { }) { for (const [k, v] of Object.entries(obj)) { const key = prefix ? prefix + "." + k : k; if (v !== null && typeof v === "object" && !Array.isArray(v)) { flatten(v, key, result); } else { result[key] = v; } } return result; }. For production code, use the flat npm package (npm install flat) which handles edge cases, prototype pollution, custom delimiters, and array strategies with a safe option. The flat package is available as an ES module: import { flatten } from "flat".

How do I flatten JSON in Python?

Two main approaches: (1) pandasimport pandas as pd; flat = pd.json_normalize(data, sep=".").to_dict(orient="records")[0]. Best when you are already working with pandas or need to flatten a list of records into a DataFrame for CSV export. (2) flatten_dictpip install flatten_dict; from flatten_dict import flatten; flat = flatten(data, reducer="dot"). Best for pure-Python pipelines without pandas. Zero-dependency recursive option: def flatten_json(obj, prefix="", sep="."): items = { }; [items.update(flatten_json(v, f"{prefix}{{sep}}{k}" if prefix else k, sep) if isinstance(v, dict) else { f"{prefix}{{sep}}{k}": v }) for k, v in obj.items()]; return items. Use sep="__" when generating environment variable names.

How do I handle arrays when flattening JSON?

Choose one of two strategies based on the downstream consumer: index notation expands { "tags": ["a", "b"] } to { "tags.0": "a", "tags.1": "b" } — every leaf is a scalar, required for CSV columns and key-value stores. Preserve arrays keeps { "tags": ["a", "b"] } as-is — required when the consumer handles arrays natively (Elasticsearch, databases). In the flat npm package, flatten(obj) (default) uses index notation; flatten(obj, { safe: true }) preserves arrays. In pandas json_normalize(), arrays of primitives are preserved as list columns by default. A third option is joining array values as a string ("admin|editor") — useful for single CSV cells where all values fit in one column.

How do I unflatten a flat JSON object?

Split each key on the delimiter and set the value at the resulting nested path. JavaScript with the flat package: import { unflatten } from "flat"; const nested = unflatten({ "address.city": "NYC" }). Manual JavaScript: split the key, loop through all segments except the last to create intermediate objects (current[part] ??= { }), then set the value at the last segment. Python with flatten_dict: from flatten_dict import unflatten; nested = unflatten(flat, splitter="dot"). The flat package reconstructs arrays from numeric index keys (tags.0, tags.1 ["a", "b"]) by default. If keys may contain the delimiter character, unflatten produces incorrect nesting — use a different separator.

What is the flat npm package?

flat is a zero-dependency npm package (npm install flat) that provides flatten(obj, options) and unflatten(obj, options) for nested JavaScript objects. flatten({ a: { b: 1 } }) returns { "a.b": 1 }. unflatten({ "a.b": 1 }) returns { a: { b: 1 } }. Key options: delimiter (default "."), safe (preserve arrays when true), maxDepth (stop recursing at given depth), transformKey (transform each key segment). It prevents prototype pollution — keys like __proto__ are rejected. Available as both CommonJS (require("flat")) and ES module (import { flatten } from "flat"). The package is widely used and actively maintained, making it the recommended choice over hand-rolled recursive implementations.

How do I flatten JSON for CSV export?

Flatten each JSON record to a single level (all scalar values), then use the flattened keys as column headers. JavaScript: const rows = data.map(obj => flatten(obj, { safe: false })); const headers = [...new Set(rows.flatMap(r => Object.keys(r)))] — then build CSV by joining cells. Python: pd.json_normalize(records, sep="_").to_csv("output.csv", index=False). Use sep="_" (underscore) instead of sep="." — dots in column names cause issues in SQL and some spreadsheet tools. Arrays must be expanded with index notation or joined as strings before CSV export — CSV cells cannot contain arrays. For arrays of objects, index notation creates multiple columns per array element (items_0_name, items_1_name); alternatively, use pandas record_path to expand array-of-objects into separate rows.

How do I detect circular references when flattening JSON?

Track visited objects in a Set and check before recursing. If the object is already in the Set, throw immediately — do not recurse. JavaScript: function flatten(obj, prefix = "", result = { }, seen = new Set()) { if (seen.has(obj)) throw new Error("Circular reference at: " + prefix); seen.add(obj); /* ... recurse ... */ seen.delete(obj); return result; }. The seen.delete(obj) after processing children is critical — it allows diamond references (the same object reachable via two paths) which are valid. Without the delete, diamond references would incorrectly throw a circular reference error. Python uses id(obj) as the Set key since dicts are not hashable: if id(obj) in seen: raise ValueError(...); seen.add(id(obj)); ... ; seen.discard(id(obj)).

How do I flatten JSON for Elasticsearch indexing?

Elasticsearch supports nested objects natively — { "address": { "city": "NYC" } } is indexed correctly without pre-flattening. However, explicit field mapping with dot-notation keys ("address.city": { "type": "keyword" }) gives you precise control over field types. Flatten the document before indexing when you need every field explicitly typed in the mapping. Use flatten(doc, { safe: true }) to preserve arrays — Elasticsearch indexes arrays of scalars directly. Since Elasticsearch 7.3, the flattened field type accepts an entire nested object and handles dot-notation internally — use it when you want dynamic field handling without defining each sub-field explicitly. For nested arrays of objects requiring independent scoring, use Elasticsearch's nested type instead of flattening. See our Elasticsearch JSON guide for mapping examples.

Further reading and primary sources