How to convert csv to nested json grouped by column
- Step 1Prepare a flat CSV with the grouping column present — Your CSV needs the column you want to group by — e.g.
category,owner,region. Each row stays one record; the grouping column's value decides which bucket the row lands in. - Step 2Drop the CSV onto the converter above — Accepts
.csv,.tsv,.txt. The header row is read so the grouping dropdown can list the available columns. PapaParse auto-detects the delimiter. - Step 3Choose Grouped by column as the output mode — Selecting Grouped by column reveals a Group records by column dropdown listing your headers. The other modes (Array, NDJSON) produce a flat list instead.
- Step 4Pick the grouping column — Choose the column whose distinct values become the top-level keys. If you leave it unselected, the tool falls back to a plain array — so make sure a column is chosen for grouped output.
- Step 5Keep type inference on for typed nested records — Inference applies inside each grouped record, so a
priceorcountfield stays a number within the nested arrays. Turn it off if you want every value as a string. - Step 6Set indent and download — Indent (2 or 4 spaces) formats the grouped object; Minified keeps it compact. Click Download JSON and check the keys match the distinct values you expected (watch for an
_unknownbucket from blank group cells).
Output modes and resulting shape
Grouped mode is the one that produces nesting. The others are flat lists.
| Output mode | Shape | Use when |
|---|---|---|
| Grouped by column | { "key": [ {row}, {row} ] } | You want rows bucketed under a parent value |
| Array of objects | [ {row}, {row} ] | A flat list (default) |
| NDJSON | one {row} per line | Line-delimited ingestion |
How the group key is determined
The grouping column's cell value, stringified, becomes the top-level key. Blank or missing values fall into _unknown.
| Group cell value | Top-level key | Note |
|---|---|---|
Electronics | "Electronics" | Used verbatim |
2026 (numeric, inference on) | "2026" | Key is stringified — inferred number becomes a string key |
| `` (empty) | "_unknown" | Blank group value bucketed here |
| missing (ragged row) | "_unknown" | No value for the group column → _unknown |
Books and books | two keys "Books", "books" | Grouping is case-sensitive — distinct keys |
Cookbook
Grouping recipes that turn a flat CSV into one-level nested JSON.
Products grouped by category
ExampleGrouped mode, group column = category. Each category becomes a key holding its product rows; price stays a number.
Input (products.csv):
name,category,price
Laptop,Electronics,999
Mouse,Electronics,25
Novel,Books,15
Output (Grouped by column: category):
{
"Electronics": [
{ "name": "Laptop", "category": "Electronics", "price": 999 },
{ "name": "Mouse", "category": "Electronics", "price": 25 }
],
"Books": [
{ "name": "Novel", "category": "Books", "price": 15 }
]
}Tasks grouped by owner
ExampleGroup column = owner. Useful for a per-person view in a config or dashboard feed.
Input:
task,owner,done
Deploy,ada,false
Review,ada,true
Design,linus,false
Output (Grouped by column: owner):
{
"ada": [
{ "task": "Deploy", "owner": "ada", "done": false },
{ "task": "Review", "owner": "ada", "done": true }
],
"linus": [ { "task": "Design", "owner": "linus", "done": false } ]
}Blank group values collect under _unknown
ExampleA row with no region lands in the _unknown bucket so it is not lost.
Input:
customer,region
Acme,EMEA
Globex,
Output (Grouped by column: region):
{
"EMEA": [ { "customer": "Acme", "region": "EMEA" } ],
"_unknown": [ { "customer": "Globex", "region": "" } ]
}Case-sensitive grouping creates separate keys
ExampleGrouping does not fold case, so Books and books are two keys. Normalize the column first if you want them merged.
Input:
title,shelf
A,Books
B,books
Output (Grouped by column: shelf):
{
"Books": [ { "title": "A", "shelf": "Books" } ],
"books": [ { "title": "B", "shelf": "books" } ]
}
→ normalize case with csv-case-converter first to mergeBuild deeper nesting with json-unflattener afterward
ExampleGrouped is one level. For a parent/child tree from dotted headers, convert to an array here, then unflatten.
Input:
id,address.city,address.zip
1,Paris,75001
Step 1 (Array mode here):
[ { "id": 1, "address.city": "Paris", "address.zip": 75001 } ]
Step 2 (json-unflattener):
[ { "id": 1, "address": { "city": "Paris", "zip": 75001 } } ]Errors and edge cases
Real errors and silent failures sourced from each platform's own documentation. Match the wording to the row, fix what the row says to fix.
Blank or missing group value goes to _unknown
By designRows whose grouping cell is empty or absent collect under a single _unknown top-level key rather than being dropped. If you see an _unknown bucket you did not expect, some rows are missing the group value — clean or fill that column in the source CSV before converting.
Grouped mode selected but no column chosen
Falls backIf you pick Grouped by column but do not select a grouping column, the output falls back to a plain array (no grouping). Always confirm a column is chosen in the Group records by column dropdown so you get the nested object you expect.
Numeric group values become string keys
ExpectedJSON object keys are always strings. If you group by a numeric column (e.g. year), inference may type the value 2026 as a number inside the record, but the top-level key is the stringified "2026". This is normal JSON behaviour, not a defect.
Grouping is case-sensitive
ExpectedBooks and books produce two separate keys — the grouper does not fold case or trim. If you want them merged, normalize the grouping column first with csv-case-converter (or trim with the cleaner) before converting.
Group key still appears inside each record
By designThe grouping column is not removed from the inner records — each row keeps its category/owner/region field even though it is now redundant with the key. If you want it dropped from the records, remove that column first with csv-column-remover, then convert.
Only one level of grouping is supported
Not supported hereGrouped mode buckets by exactly one column. You cannot group by category then sub-group by subcategory in a single pass. For a deeper tree, convert to an array and reshape with a script, or expand dotted headers into nested objects using json-unflattener.
Dotted headers are not turned into nested objects
Not supported hereGrouping is about bucketing rows under a key — it does not interpret address.city style headers as nesting. Those stay literal keys inside the records. Use the array-then-json-unflattener recipe in the cookbook to build parent/child objects from dotted headers.
Free tier row/file cap
LimitFree tier caps at 2 MB / 500 rows. Grouping a large dataset (e.g. all orders by customer) may exceed it — Pro raises the cap to 100 MB / 100,000 rows. The number of distinct groups does not have its own limit; the cap is on the input size and row count.
Frequently asked questions
How do I group my CSV rows under a parent key?
Select Grouped by column as the output mode, then choose the grouping column in the Group records by column dropdown. Every distinct value in that column becomes a top-level JSON key whose value is the array of rows that share it — { "Electronics": [ … ], "Books": [ … ] }.
What happens to rows with an empty group value?
They collect under a single _unknown key rather than being dropped, so no data is lost. If you see an unexpected _unknown bucket, some rows are missing the grouping value — fill or clean that column in the source CSV first.
Why is the grouping key a string when my column is numeric?
JSON object keys are always strings. Even if inference types the value 2026 as a number inside each record, the top-level group key is the stringified "2026". That is standard JSON, not a limitation of the tool.
Can I group by two columns to get nested sub-groups?
No — grouped mode buckets by exactly one column, producing one level of nesting. For category → subcategory trees, convert to an array here and reshape with a small script, or use json-unflattener on dotted headers to build deeper structure.
Is grouping case-insensitive?
No, it is case-sensitive and does not trim. Books and books become two separate keys. To merge them, normalize the grouping column first with csv-case-converter so all values share one casing before you convert.
Does the grouping column still appear inside each record?
Yes — the rows keep their original fields, so category is present both as the key and inside each record. If the redundancy bothers you, remove that column with csv-column-remover before converting.
How do I build true nested objects (parent.child) instead of grouping?
Grouping buckets rows; it does not build nested objects from dotted headers. Convert to an Array here, then run it through json-unflattener, which expands address.city style keys into nested { address: { city } } objects.
Is my data uploaded during conversion?
No. PapaParse parses and the grouped JSON is built in your browser. The data never reaches a JAD Apps server. Only an anonymous run counter is stored when signed in, and you can opt out.
How large a CSV can I group?
Free tier: 2 MB / 500 rows. Pro: 100 MB / 100,000 rows. Pro+Media: 500 MB / 500,000 rows. Developer: 5 GB, no row cap. The number of distinct groups is not separately limited — only the input size and row count are.
Does type inference still work inside the grouped arrays?
Yes. Inference applies to the cell values exactly as in array mode, so numbers and booleans stay typed inside each nested record. Turn inference off if you want every value as a string within the groups.
Can I view the nested result before downloading?
The preview pane shows the first 1.5 KB of the generated JSON so you can confirm the keys and shape. For a fuller inspection of a large grouped object, run the download through json-tree-viewer.
Can I automate the grouping in a pipeline?
Yes. Pair the @jadapps/runner once and POST the CSV to 127.0.0.1:9789/v1/tools/csv-to-json/run with outputMode: grouped and groupByColumn set. A typical use: a scheduled export grouped by region for a per-region config feed. Data stays on your machine.
Privacy first
Conversion runs locally in your browser. No file is uploaded — only metadata counters are saved for signed-in dashboard stats.