Skip to main content

How to Flatten JSON: Nested Objects to Dot-Path Keys (and Back)

A practical guide to flattening nested JSON into single-level dot-path keys for CSV export, env vars, and diffing — plus how to unflatten it cleanly.

Published By Li Lei
#json #data-conversion #developer-tools #config #csv

How to Flatten JSON: Nested Objects to Dot-Path Keys (and Back)

Nested JSON is great for a program to read and a pain for almost everything else. A diff tool buries the one changed field three braces deep. A spreadsheet has no idea what to do with an object inside a cell. A .env file wants flat keys, not a tree. Flattening solves all three by collapsing the structure into single-level keys joined by a delimiter — and the JSON Flatten / Unflatten tool does it both directions without uploading a byte.

What flattening actually does

Flattening walks the object and turns every path to a leaf value into one key. Each nesting level becomes a segment, joined by your delimiter. The values themselves never change — only the keys get rewritten.

So {"a":{"b":1}} becomes {"a.b":1}. A deeper object like {"user":{"address":{"city":"NYC"}}} collapses to {"user.address.city":"NYC"}. The string "NYC" is still the string "NYC", and the number 1 is still the number 1 — not "1". That type preservation matters more than it sounds, because a key-value store that suddenly stringifies your port number will break the first connection attempt.

Arrays get the same treatment. The list ["x","y"] under a key tags flattens to either tags.0 / tags.1 (numeric dot index) or tags[0] / tags[1] (bracket index), depending on which array style you pick. Both are valid; they suit different downstream tools, which I'll come back to.

A worked example

Here is a small but realistic config object — the kind you'd hand to a service at boot:

{
  "service": "checkout",
  "db": {
    "host": "localhost",
    "port": 5432
  },
  "features": {
    "retry": true,
    "regions": ["us-east", "eu-west"]
  }
}

Flatten it with the default dot delimiter and numeric array indices, and you get:

{
  "service": "checkout",
  "db.host": "localhost",
  "db.port": 5432,
  "features.retry": true,
  "features.regions.0": "us-east",
  "features.regions.1": "eu-west"
}

Look at what survived the trip. 5432 is still a number. true is still a boolean. The two array items became features.regions.0 and features.regions.1. Every leaf value is byte-for-byte what it was; the entire transformation lives in the key names. Switch the array style to brackets and those two keys read features.regions[0] and features.regions[1] instead — same values, different notation.

Why flat keys are easier to work with

Three jobs get noticeably simpler once the tree is gone.

Environment variables and key-value stores. Consul, etcd, AWS Parameter Store, and a plain .env file all expect flat keys. You can't write a nested object into DB_HOST. Flatten with the underscore delimiter and db.host becomes DB_HOST, ready to drop straight into an env file with no second rename pass.

Diffing two configs. This is the one that saved me an afternoon. Two service configs looked identical, but staging kept failing one request. Eyeballing nested braces got me nowhere. I flattened both versions and pasted them into the JSON Diff tool, and the culprit popped out as a single line: features.checkout.retryLimit had drifted from 3 to 0. A change buried three levels deep showed up as one flat key instead of hiding inside collapsed braces I had to expand by hand.

Spreadsheet and CSV export. A nested record can't be a row, but a flat one can. Every leaf becomes a column header — address.city, address.zip, tags.0, tags.1 — and the whole object pastes in as a single line. If you're going further into tabular land, pair this with the JSON to CSV converter, which is built for exactly that hand-off.

Picking a delimiter (and the one trap to avoid)

The dot is the default because it matches how most config languages and dotted-path libraries already write keys. Underscore is one click away for env-style output. You can also type any custom separator.

The trap: never flatten with a delimiter that already appears inside your keys. If a key is literally "user.name" and you flatten with ., the unflatten step can no longer tell the real dot from a level boundary — it'll split user.name into two levels and rebuild the wrong shape. When your keys contain dots, switch to _ or some character that never shows up in your data. Flattening {"file.name":{"ext":"json"}} with / gives you {"file.name/ext":"json"}, where the literal dot is unmistakably part of the key.

Unflattening: getting the tree back

Flattening is only half the round-trip. Switch the direction to Unflatten, paste the flat object, and the tool splits each key on your delimiter and rebuilds the nesting level by level. So {"user.address.city":"NYC"} returns to {"user":{"address":{"city":"NYC"}}}.

Array index notation is what makes this reliable. When a segment is a number — or a bracket index under bracket style — unflatten rebuilds it as an array element rather than an object key. That means {"tags.0":"x","tags.1":"y"} comes back as {"tags":["x","y"]}, a real array, not an object with "0" and "1" keys.

The one rule for a clean round-trip is to use matching options on both sides. Flatten with a[0] bracket style, then unflatten with the a.0 setting, and those numeric segments rebuild as object keys instead of array elements — the shape silently changes. Same delimiter, same array style, both directions, and the tree comes back exactly as it left. This is what makes the i18n workflow work: flatten a translation tree like {"home":{"title":"Hi"}} into home.title for your translators, then unflatten their returned file back into the nested shape your i18n library loads, with no manual key rebuilding.

When you need to query, not flatten

Flattening is the right move when you want every leaf as a top-level key. But if you only need a few values out of a deep object, you don't have to flatten the whole thing first — a path query is faster. The JSONPath query tester lets you pull $.features.regions[0] directly. And if your input is just messy rather than nested-and-flat, run it through the JSON formatter to indent and validate it before you do anything else; invalid JSON in means garbage out.

Quick mental model

Think of flattening as trading depth for width. A nested object hides its values behind structure; a flat object lays every value on one line, addressed by a full path. The data is identical — db.port and db: { port } carry the same 5432. You're choosing which shape your next tool can actually read: a tree for programs, a flat key list for env files, diffs, and spreadsheets. Pick the delimiter that doesn't collide with your keys, keep the array style consistent across both directions, and the conversion is lossless every time.


Made by Toolora · Updated 2026-06-13