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.cityreplaces three levels of nesting andtags.0replaces the first array element. In Python use a short recursive function orpandas.json_normalize(); in JavaScript use a recursive function or theflatpackage; on the command line use jq'sleaf_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 path | Dot notation | Bracket notation |
|---|---|---|
| user → address → city | user.address.city | user.address.city |
| tags → element 0 | tags.0 | tags[0] |
| items → element 1 → sku | items.1.sku | items[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.cityare 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=20style 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:
- Use a delimiter that cannot appear in your keys, such as
/or a tab character. - Escape the delimiter inside keys (for example
a\.b) and unescape when unflattening. - 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.