Skip to content
How-To

How to Flatten Nested JSON (and Unflatten It Back)

·8 min read
How to Flatten Nested JSON (and Unflatten It Back) - How-To guide from JSON Prettifier

Quick answer: To flatten nested JSON, walk the structure recursively and build one key per leaf value by joining the path with a dot, so user.address.city replaces three levels of nesting and tags.0 replaces the first array element. In Python use a short recursive function or pandas.json_normalize(); in JavaScript use a recursive function or the flat package; on the command line use jq's leaf_paths. Unflattening reverses the process by splitting each key on the delimiter.

Your analytics team wants last month's orders in a spreadsheet. The export endpoint returns each order with a nested customer object, a nested shipping.address, and an array of items. Spreadsheets have columns, not nesting. Before any of that data lands in Excel, Elasticsearch, or a machine-learning feature table, it has to be flattened. Here is how to do it in every environment you are likely to touch, and how to get the original shape back afterwards.

Try the JSON to CSV Converter on a sample array, then check the exported columns and values before using the file.

What Flattened JSON Looks Like

Start with a small nested document:

{
  "id": 42,
  "user": {
    "name": "Priya",
    "address": { "city": "Pune", "zip": "411001" }
  },
  "tags": ["admin", "beta"]
}

Flattened with dot notation, every value sits at the top level under its full path:

{
  "id": 42,
  "user.name": "Priya",
  "user.address.city": "Pune",
  "user.address.zip": "411001",
  "tags.0": "admin",
  "tags.1": "beta"
}

Two conventions exist for array indexes. Dot notation treats the index like any other key (tags.0). Bracket notation keeps arrays visually distinct (tags[0]), which is what JavaScript property access and many query languages use. Pick one and stick with it; the dot form is simpler to generate, the bracket form is easier to unflatten unambiguously because digits inside brackets can only mean an index.

Nested pathDot notationBracket notation
user → address → cityuser.address.cityuser.address.city
tags → element 0tags.0tags[0]
items → element 1 → skuitems.1.skuitems[1].sku

Flatten JSON in JavaScript

A recursive function handles objects and arrays in about fifteen lines. This version keeps empty objects and arrays as values instead of dropping them, which matters when you need to unflatten later:

function flatten(value, prefix = "", out = {}) {
  const isObject = value !== null && typeof value === "object";
  if (!isObject) {
    out[prefix] = value;
    return out;
  }
  const entries = Array.isArray(value)
    ? value.map((v, i) => [String(i), v])
    : Object.entries(value);
  if (entries.length === 0) {
    out[prefix] = Array.isArray(value) ? [] : {};
    return out;
  }
  for (const [key, child] of entries) {
    flatten(child, prefix ? prefix + "." + key : key, out);
  }
  return out;
}

const flat = flatten(order);
// { "id": 42, "user.name": "Priya", "user.address.city": "Pune", ... }

If you would rather not own that code, the flat package on npm has been the standard choice for years:

npm install flat
import { flatten, unflatten } from "flat";

flatten(order);                          // dot-delimited keys
flatten(order, { delimiter: "/" });      // user/address/city
flatten(order, { safe: true });          // leave arrays as values
unflatten(flatObject);                   // back to nested

The safe option is worth knowing: it keeps arrays intact as single values rather than expanding them into tags.0, tags.1. That is usually what you want when the array is a list of strings and you are not exporting to a fixed set of columns.

Flatten JSON in Python

The same recursion, written for dict and list:

def flatten(value, prefix="", out=None):
    if out is None:
        out = {}
    if isinstance(value, dict) and value:
        for k, v in value.items():
            flatten(v, f"{prefix}.{k}" if prefix else str(k), out)
    elif isinstance(value, list) and value:
        for i, v in enumerate(value):
            flatten(v, f"{prefix}.{i}" if prefix else str(i), out)
    else:
        out[prefix] = value
    return out

flat = flatten(order)
# {'id': 42, 'user.name': 'Priya', 'user.address.city': 'Pune', ...}

The and value checks are what keep empty containers from vanishing: an empty dict falls through to the else branch and is stored as-is.

pandas json_normalize for tabular data

When the goal is a DataFrame, skip the hand-written function. pandas.json_normalize() flattens nested objects into dotted column names in one call:

import pandas as pd

df = pd.json_normalize(orders)            # list of order dicts
df.columns
# Index(['id', 'user.name', 'user.address.city', 'user.address.zip', 'tags'])

pd.json_normalize(orders, sep="_")        # user_address_city instead

Notice that tags stayed as a list inside one cell. json_normalize flattens objects, not arrays. For arrays of objects, which is the case that actually matters for exports, use record_path to pick the array and meta to carry parent fields down to every row:

order = {
    "id": 42,
    "customer": "Priya",
    "items": [
        {"sku": "A-100", "qty": 2},
        {"sku": "B-220", "qty": 1}
    ]
}

pd.json_normalize(order, record_path="items", meta=["id", "customer"])
#      sku  qty  id customer
# 0  A-100    2  42    Priya
# 1  B-220    1  42    Priya

That one-row-per-array-element shape is what a spreadsheet or SQL table expects. The JSON to CSV guide builds on this to produce a file Excel opens cleanly.

Flatten JSON With jq

jq has no flatten for objects (its flatten builtin is for arrays), but leaf_paths yields every path to a scalar, and getpath reads the value at a path. Combine them:

jq '[leaf_paths as $p | {
        key: ($p | map(tostring) | join(".")),
        value: getpath($p)
      }] | from_entries' order.json

Output:

{
  "id": 42,
  "user.name": "Priya",
  "user.address.city": "Pune",
  "user.address.zip": "411001",
  "tags.0": "admin",
  "tags.1": "beta"
}

map(tostring) is needed because array indexes come back as numbers and join wants strings. To see the paths alone, jq -c 'paths' prints each one as an array, including intermediate object paths; paths(scalars) restricts it to leaves, which is exactly what leaf_paths is shorthand for. There is more of this in the jq cheat sheet.

Unflatten JSON Back to Nested

Reversing the process means splitting each key, walking down the structure, and creating containers on the way. The only decision is whether a segment creates an object or an array. The usual heuristic: if the next segment is all digits, create an array.

function unflatten(flat) {
  const result = {};
  for (const [path, value] of Object.entries(flat)) {
    const keys = path.split(".");
    let cursor = result;
    keys.forEach((key, i) => {
      if (i === keys.length - 1) {
        cursor[key] = value;
        return;
      }
      if (cursor[key] === undefined) {
        cursor[key] = /^\d+$/.test(keys[i + 1]) ? [] : {};
      }
      cursor = cursor[key];
    });
  }
  return result;
}

unflatten({ "user.name": "Priya", "tags.0": "admin", "tags.1": "beta" });
// { user: { name: "Priya" }, tags: ["admin", "beta"] }

In Python the same idea with setdefault:

def _set(container, key, value):
    """Assign into a dict by key or into a list by index, growing the list."""
    if isinstance(container, list):
        idx = int(key)
        while len(container) <= idx:
            container.append(None)
        if container[idx] is None:
            container[idx] = value
        return container[idx]
    return container.setdefault(key, value)

def unflatten(flat):
    result = {}
    for path, value in flat.items():
        keys = path.split(".")
        cursor = result
        for key, nxt in zip(keys, keys[1:]):
            cursor = _set(cursor, key, [] if nxt.isdigit() else {})
        _set(cursor, keys[-1], value)
    return result

unflatten({"user.name": "Priya", "items.0.sku": "A-100", "tags.1": "beta"})
# {'user': {'name': 'Priya'}, 'items': [{'sku': 'A-100'}], 'tags': [None, 'beta']}

If you only ever flatten objects (no arrays), drop the digit check and every segment becomes a dict key. That is simpler and avoids the ambiguity described in the gotchas below.

A quick way to confirm a round trip worked: format the original and the unflattened result in JSON Prettifier with Sort Keys turned on, then diff the two outputs. Sorting removes key-order noise so only real structural differences show up. For a line-by-line view, the compare two JSON files guide covers the tooling.

Where Flattened JSON Is Used

  • CSV and spreadsheet export. Columns are flat by definition. Dotted headers like user.address.city are readable and reversible.
  • Search indexes. Elasticsearch and Kibana address nested object fields by their dotted path, so a flattened view of a document matches how you will query it. Dotted field names are also how most log search tools display structured events.
  • Feature engineering. Models consume fixed-width vectors. Flattening turns a nested user profile into named features (account.plan, usage.last30.requests) that line up across every row.
  • Diffing and review. Two flattened objects can be compared key by key, which is easier than diffing indentation-sensitive nested text.
  • Config overrides. Environment variables and CLI flags are flat; DATABASE.POOL.MAX=20 style keys get unflattened into the nested config object.

Gotchas When Flattening JSON

Keys that contain the delimiter

This is the one that bites in production. These two documents flatten to the same output:

{ "a.b": 1 }          // key literally contains a dot
{ "a": { "b": 1 } }   // nested

Both produce { "a.b": 1 }, and unflattening will always pick the nested interpretation. Real data has dotted keys more often than you would expect: domain names as keys, version numbers, file names, IP addresses. Three ways out:

  1. Use a delimiter that cannot appear in your keys, such as / or a tab character.
  2. Escape the delimiter inside keys (for example a\.b) and unescape when unflattening.
  3. Reject input with delimiter-bearing keys up front if the flattened form must round-trip exactly.

Arrays of objects explode into columns

Flattening an order with 30 line items produces items.0.sku through items.29.sku. Across a thousand orders with varying item counts, you get a sparse table with hundreds of mostly empty columns. When the array represents rows, use record_path (pandas) or an equivalent explode step and keep the parent fields as meta. The arrays versus objects post explains why the array-of-objects shape is so common in API responses in the first place.

Empty containers disappear

A naive flattener only emits leaf values, so "tags": [] and "meta": {} leave no trace. The functions above preserve them, but jq's leaf_paths and many libraries do not. If the distinction between "empty list" and "missing" matters to a consumer, test for it.

Numeric object keys become arrays on the way back

An object keyed by ID, like { "101": {...}, "205": {...} }, flattens to 101.name, 205.name. The digit heuristic then unflattens it into an array with 206 slots, almost all null. If your data has numeric-looking object keys, either disable array detection or use bracket notation, where only [205] means an index and .205 stays a key.

null is a leaf, undefined is not

null flattens to a key with a null value, which is correct. In JavaScript, undefined properties are dropped by JSON.stringify anyway, so flatten before serializing if you need to know they existed. The difference matters for PATCH-style updates, covered in null vs missing vs empty string.

Frequently Asked Questions

What does it mean to flatten JSON?
Flattening converts a nested JSON structure into a single-level object where each key is the full path to a value, such as user.address.city instead of user containing address containing city. Arrays become indexed paths like tags.0 and tags.1.
How do I flatten nested JSON in Python?
For a single document, a ten-line recursive function that walks dicts and lists and joins the keys with a dot is enough. For tabular output, pandas.json_normalize(data) flattens nested objects into dotted column names, and its record_path and meta arguments explode an inner array into one row per element.
How do I flatten a JSON object in JavaScript?
Write a recursive function that iterates Object.entries, recurses into objects and arrays with a growing prefix, and writes primitives to the output object under the joined path. If you want it maintained for you, the flat package on npm exports flatten() and unflatten() with configurable delimiters.
Can flattening JSON lose data?
Yes, in two cases. Empty objects and empty arrays have no leaf values, so a naive flattener drops them entirely. And if an original key contains the delimiter, like a key literally named a.b, the flattened path becomes ambiguous and unflattening produces a different structure.
How do I unflatten JSON back into a nested object?
Split each key on the delimiter, walk or create intermediate objects for every segment except the last, and assign the value at the final segment. To restore arrays, create an array instead of an object whenever the next segment is all digits. The flat package and most flatten libraries ship an unflatten() that does exactly this.

Check the Shape Before and After

Paste nested JSON, format it with Sort Keys, and see every path at a glance. Runs entirely in your browser; nothing is uploaded.

Format My JSON