jq Reshaping
Working with JSON
Chapter 5 · Reshaping Data with jq
Chapter 4 was about looking at JSON. This chapter is about changing its shape: turning a list of orders into totals per customer, an array into an object keyed by ID, a nested structure into CSV, or a
document with some fields removed. It builds on the filters, options and quoting rules from Working with JSON 4, and uses two sample files: the shop.json from that chapter and a new
orders.json of six orders. The chapter ends with a section on what changed in jq 1.7 and 1.8, because several things you will read online about jq are out of date, and one example in jq's own release notes
does not hold in the version I tested.
NEWS.md for 1.8.2, and each feature I list was run. Not run: jq 1.6 or older, jq 1.7.x or 1.8.0 (so statements about what older versions did come from the release notes, not from my
tests), Linux and macOS, and the date and time functions, regular-expression functions beyond test and scan, and input/inputs.
The Sample Data
Building Objects and Arrays
An object literal builds a new object from the current input; an array literal collects every result of its contents into one array. I ran these on orders.json:
Filter (with -c) | Result |
|---|---|
.[] | {id, customer, n: (.items | length)} | six objects, e.g. {"id":1,"customer":"ada","n":2} |
map({id, total: (.items | map(.qty * .price) | add // 0)}) | [{"id":1,"total":8.99},{"id":2,"total":4.25},{"id":3,"total":5.34},{"id":4,"total":0},{"id":5,"total":17.97},{"id":6,"total":7.96}] |
.[0] | {id, first: .items[0].sku, skus: [.items[].sku], "order-id": .id, ("k" + "ey"): 1} | {"id":1,"first":"A1","skus":["A1","C3"],"order-id":1,"key":1} |
.[0] | {id, items: [.items[] | {sku, line: (.qty * .price)}]} | {"id":1,"items":[{"sku":"A1","line":7},{"sku":"C3","line":1.99}]} |
The rules: {id} is shorthand for {id: .id}; a key that is not a plain word needs quotes ("order-id"); a key computed from an expression goes in parentheses
(("k" + "ey")). Order 4 has an empty items list, so .items | map(...) | add would give null; add // 0 turns that into 0 (the // operator
is explained below). Notice the total for order 4 is 0 in the output.
Two ways to combine objects. + merges at the top level, with the right side winning; * merges recursively. I ran both on {a:{b:1,c:2}} and {a:{c:3,d:4}}:
+ gave {"a":{"c":3,"d":4}} (the whole a replaced, b lost) and * gave {"a":{"b":1,"c":3,"d":4}}.
Entries: Turning Objects into Lists and Back
To work on an object's keys as data, convert it to a list of {key, value} pairs with to_entries, change the list, then convert back with from_entries. with_entries(f) does
all three steps:
Filter on .owner (-c) | Result |
|---|---|
to_entries | [{"key":"first","value":"Ada"},{"key":"last","value":"Lovelace"},{"key":"phone","value":null}] |
with_entries(select(.value != null)) | {"first":"Ada","last":"Lovelace"} (drops null fields) |
with_entries(.key |= ascii_upcase) | {"FIRST":"Ada","LAST":"Lovelace","PHONE":null} |
with_entries(.value |= (. // "n/a")) | {"first":"Ada","last":"Lovelace","phone":"n/a"} |
map_values(. // "-") | {"first":"Ada","last":"Lovelace","phone":"-"} |
del(.phone) | {"first":"Ada","last":"Lovelace"} |
from_entries is also the way to build an object from an array. Three ways to turn the four products into an object keyed by SKU all gave {"A1":40,"B2":0,"C3":120,"D4":15} for the stock levels:
map({(.sku): .stock}) | add, map({key: .sku, value: .stock}) | from_entries, and INDEX(.sku) | map_values(.stock).
What from_entries accepts is narrower than people assume. I tried eleven entry shapes on jq 1.8.2:
| Entry | Result |
|---|---|
{"key":"a","value":1}, {"name":"a","value":1}, {"Name":"a","Value":1} | {"a":1} (works) |
{"Key":"a","Value":2} | {"a":2} (works) |
{"k":"a","v":1} and {"K":"a","V":1} | error: Cannot use null (null) as object key |
{"key":"a","v":1} | {"a":null} (the value was not found, so it is silently null) |
{"key":5,"value":1} | error: Cannot use number (5) as object key |
{"key":true,"value":1} | error: Cannot use boolean (true) as object key |
{"key":null,"value":1} | error: Cannot use null (null) as object key |
{"key":"a"} | {"a":null} |
So: use key and value exactly (or name/Name, Key/Value), make keys strings with tostring, and be suspicious of a result full of
nulls. A wrong value name does not error; it just gives null.
Sorting, Grouping and Picking
Filter on orders.json (-c) | Result |
|---|---|
group_by(.customer) | map({customer: .[0].customer, orders: length}) | [{"customer":"ada","orders":3},{"customer":"grace","orders":2},{"customer":"linus","orders":1}] |
group_by(.status) | map({status: .[0].status, ids: map(.id)}) | [{"status":"cancelled","ids":[4]},{"status":"pending","ids":[2,6]},{"status":"shipped","ids":[1,3,5]}] |
unique_by(.customer) | map(.id) | [1,2,4] (the first order of each customer) |
[.[].customer] | unique | ["ada","grace","linus"] |
sort_by(.customer, -.id) | map("\(.customer)\(.id)") | ["ada6","ada3","ada1","grace5","grace2","linus4"] (customer ascending, then id descending) |
sort_by(.date) | reverse | map(.id) | [6,5,4,3,2,1] |
min_by(.date).id, max_by(.id).id | 1 and 6 |
[.[].items[]] | group_by(.sku) | map({sku: .[0].sku, qty: (map(.qty) | add)}) | [{"sku":"A1","qty":3},{"sku":"B2","qty":3},{"sku":"C3","qty":8},{"sku":"D4","qty":6}] |
[.[] | select(.status != "cancelled")] | group_by(.customer) | map({customer: .[0].customer, spent: (map(.items | map(.qty * .price) | add) | add)}) | [{"customer":"ada","spent":22.29},{"customer":"grace","spent":22.22}] |
map(select(.customer | IN("ada","linus"))) | map(.id) | [1,3,4,6] |
[limit(2; .[] | .id)]; first(.[] | select(.status == "pending")).id | [1,2]; 2 |
[.[].id] | .[2:4], .[:2], .[-2:] | [3,4], [1,2], [5,6] |
group_by(f) sorts by f first and returns an array of arrays, one per distinct value, which is why .[0] inside each group is a handy way to name the group. Each group is a plain array,
so length and map work on it directly. unique_by keeps the first item of each group. A group-by result is the usual first step of a "totals per X" report.
Variables, reduce and foreach
EXPR as $name | ... stores a value for use later in the same expression. It does not change the input: . is still the same. It also destructures:
.[0] as {customer: $c, items: [{sku: $first}]} | [$c, $first] gave ["ada","A1"], and
.items | . as [$a, $b] | [$a.sku, $b.sku] on order 5 gave ["A1","B2"].
reduce folds a stream into one value. foreach does the same but emits each intermediate state:
The pattern .[$key] += 1 works because a missing key is null and null + 1 is 1: it needs no setup. Notice the key order in the second result: reduce keeps the
order it first saw each status (shipped, pending, cancelled), while group_by sorted them alphabetically. The two results are the same data in a different
order, and compare JSON from two different filters with jq -S (sort keys), as the exercise does. reduce on an empty stream returns the starting value: reduce empty as $x (0; . + 1) gave 0.
reduce range(5) as $x (0; .+$x | select($x!=2)) "prints 8" (the state kept its old value when select produced nothing). I ran that exact
filter on jq 1.8.2 and it printed 7, and the foreach example printed [[0,0],[1,1],[3,3],[4,7]] instead of the notes' [[0,0],[1,1],[3,4],[4,8]]. The reason is in the 1.8.1 notes:
"Revert the change of reduce/foreach state variable in 1.8.0". In 1.8.2 an empty update resets the state to null, so the next step computed null + 3, which jq says is 3 (I ran it). Avoid depending on this: never let the update of a
reduce produce empty; use if inside it to return the unchanged state.
Conditionals, the // Operator, try and Early Exit
if COND then A elif COND2 then B else C end is an expression. empty produces no output, which drops the item: map(if .status == "cancelled" then empty else .id end) gave
[1,2,3,5,6]. The else branch is optional since jq 1.7 (the missing branch is .): 5 | if . > 3 then "big" end printed "big". In a condition, only
false and null are false: I tested [1, null, false, 0, "", [], {}] and got falsy for null and false only. 0, "", [] and {}
are all truthy.
a // b ("alternative") means "a, unless it is false, null or empty, then b". Same truthiness rule: [false, null, 0] | map(. // "default") gave
["default","default",0], so a real 0 is kept. map(.items[0].sku // "none") gave ["A1","B2","D4","none","A1","C3"] (order 4 has no items). With a stream on the left it gives
all the truthy values: (false, 1, 2) // 9 printed 1 and 2, and [(false, null) // 9] gave [9].
try catches an error. try EXPR alone produces nothing on error; try EXPR catch HANDLER runs the handler on the error message; EXPR? is shorthand for try EXPR:
| Filter | Result |
|---|---|
"abc" | tonumber | error: string ("abc") cannot be parsed as a number |
"abc" | try tonumber | no output, no error |
"abc" | try tonumber catch "not a number" | "not a number" |
["1","x","3"] | map(try tonumber catch null) | [1,null,3] |
["1","x","3"] | map(tonumber? // -1) | [1,-1,3] |
try error("boom") catch . | "boom" |
try error({code: 42}) catch .code | 42 (you can raise any value) |
{"a":1} | try .a.b catch "caught: \(.)" | "caught: Cannot index number with string (\"b\")" |
Early exit: first(f) and limit(n; f) stop producing as soon as they have enough, which matters for infinite streams: [limit(3; repeat("x"))] gave ["x","x","x"].
label $out | 1, 2, break $out, 3 printed only 1 and 2. Do not overuse ? and try: they hide real mistakes (a misspelt filter inside try just silently produces nothing).
Updating in Place: =, |=, +=, //=, del
Filter (-n, -c) | Result |
|---|---|
{} | .a.b.c = 1 | {"a":{"b":{"c":1}}} (missing levels are created) |
{"a":[1,2,3]} | .a[1] = 9 | .a |= map(. * 2) | {"a":[2,18,6]} |
{"a":1} | .b //= 5 | .a //= 7 | {"a":1,"b":5} (//= only fills missing or null) |
{"a":1} | .a += 1 | .a *= 3 | {"a":6} |
{"a":[1,2,3]} | del(.a[0]); del(.a[] | select(. > 1)) | {"a":[2,3]}; {"a":[1]} |
[1,2,3,4] | del(.[0,2]) | [2,4] (both indexes are removed from the original, not one after the other) |
= assigns a value computed from the whole input; |= updates a location using its own current value (.a |= map(. * 2)). Prefer |= when the new value depends on
the old one. Every update returns the whole modified document, not just the part you changed.
Paths: paths, getpath, setpath, walk, pick
A path is an array like ["a","b",1,"c"] that leads to a value. The sample is {"a":{"b":[10,{"c":null}],"d":"x"},"e":true}:
Filter (-c) | Result |
|---|---|
[paths] | [["a"],["a","b"],["a","b",0],["a","b",1],["a","b",1,"c"],["a","d"],["e"]] |
[paths(scalars)] | [["a","b",0],["a","d"],["e"]] (paths to values that are not objects or arrays; the null at ["a","b",1,"c"] is not included) |
getpath(["a","b",1,"c"]) | null |
getpath(["a","nope","x"]) | null (the same answer for a missing path) |
setpath(["a","b",0]; 99) | .a.b | [99,{"c":null}] |
path(.a.b[1].c) | ["a","b",1,"c"] |
delpaths([["a","d"],["e"]]) | {"a":{"b":[10,{"c":null}]}} |
[.. | numbers] | [10] (.. visits every value, at every depth) |
[paths | map(tostring) | join(".")] | ["a","a.b","a.b.0","a.b.1","a.b.1.c","a.d","e"] |
walk(if type == "number" then . + 1 else . end) | {"a":{"b":[11,{"c":null}],"d":"x"},"e":true} |
walk(if type == "object" then del(.c) else . end) | {"a":{"b":[10,{}],"d":"x"},"e":true} |
pick(.a.d, .e) | {"a":{"d":"x"},"e":true} |
pick(.a.b[1].c) | {"a":{"b":[null,{"c":null}]}} (an array index is padded with null) |
pick(.zzz) | {"zzz":null} (a path that does not exist is added as null) |
Take two warnings from the table. getpath cannot tell "the value is null" from "there is no such path"; to ask whether a path exists, compare against the list of paths
([paths] | map(. == ["a","b",1,"c"]) | any gave true, and false for a made-up path). And pick manufactures nulls for anything absent, so a typo in a
pick looks like data. walk(f) applies a function to every value from the inside out (on [[1,2],[3]], a rule that joins each array's items with commas gave "1,2,3", so the inner arrays were converted before the outer one), which is the tool for changes that apply "everywhere", such as upper-casing all strings or deleting a key at any depth. leaf_paths, which older
tutorials use, no longer exists: it was removed in 1.7, and [leaf_paths] gave leaf_paths/0 is not defined. Use paths(scalars).
Output Formats: @csv, @tsv, @sh, @uri and Friends
Format strings turn an array or string into text for another program. Use them with -r so the quotes of the JSON string do not appear. First, CSV with a header row:
@csv quotes strings, leaves numbers bare, writes null as an empty field and true as true. To test that it quotes correctly, I built a file of five items whose names contained a comma,
a double quote, a newline, a null and an accented letter, wrote it as CSV with jq, and read the result back with Python's csv module, comparing with the original. All five rows matched. As a control, I built the same
rows by joining with commas (no quoting) and the same check reported mismatches on four rows. The first result, as CSV text:
@csv accepts only an array of scalars: giving it an object failed with object ({"sku":"A1","name":"Tea",...}) cannot be csv-formatted, only array, and a row containing an array failed with
array (["drink","hot"]) is not valid in a csv row. Flatten first: (.tags // [] | join("|")) turned the tags into one field (and Milk's missing tags into an empty string).
| Format | Example input | Output (-r) |
|---|---|---|
@tsv | ["a\tb", "x\ny", "back\\slash"] (tab, newline, backslash) | fields joined by a real tab; the tab, newline and backslash inside fields are written as the two characters \t, \n and \\ |
@sh | "it's a test; echo INJECTED" | 'it'\''s a test; echo INJECTED' — when I ran that through eval printf, the output was the literal text, and nothing was executed |
@sh on an array | ["a b", "c'd", 5] | 'a b' 'c'\''d 5 (one quoted word per item) |
@uri | "a b&c=d/é?" | a%20b%26c%3Dd%2F%C3%A9%3F |
@urid (1.8) | "a%20b%26c" | a b&c; and @uri | @urid returned the original string |
@base64 / @base64d | "héllo wörld" | aMOpbGxvIHfDtnJsZA==, and @base64 | @base64d returned the original; "x" | @base64d failed: trailing base64 byte found |
@html | "<a href=\"x\">T&C</a>" | <a href="x">T&C</a> |
@json, @text, tojson, tostring | {"a":[1,"x"]} | all four printed {"a":[1,"x"]} for this object (for the string "x", @json and tojson gave "x" with the quotes, @text and tostring gave x) |
String interpolation is "text \(expression) text", and a format name in front applies that format to each interpolated part: "Total: \(.products | map(.price) | add)" gave Total: 10.63;
@uri "https://example.com/search?q=\(.owner.first + " & " + .owner.last)" gave https://example.com/search?q=Ada%20%26%20Lovelace (the literal text is left alone, the interpolated part is encoded);
@sh "echo \(.owner.first) \(.shop)" gave echo 'Ada' 'Corner Shop'. Always use @sh when jq output goes into a shell command: it is what stopped the "INJECTED" test string from running
(Working with JSON 11 comes back to injection).
Your Own Functions and Modules
def name: body; defines a function that works on its input; def name(a; b): body; takes filter parameters, separated with semicolons. I tested:
Definition and call (-c) | Result |
|---|---|
def double: . * 2; .products | map(.price | double) | [7,8.5,3.98,1.78] |
def addvat(rate): . * (1 + rate); .products | map(.price | addvat(0.2) | . * 100 | round / 100) | [4.2,5.1,2.39,1.07] |
def inStock: select(.stock > 0); [.products[] | inStock | .name] | ["Tea","Biscuits","Milk"] (a function can be a filter that drops items) |
def fac: if . <= 1 then 1 else . * (. - 1 | fac) end; [range(1;7) | fac] | [1,2,6,24,120,720] (recursion works) |
def f(x): x + 1; [f(1, 10)] and def f($x): $x + 1; [f(1, 10)] | [2,11] for both |
def f: 1; def f: 2; f | 2 (the later definition replaces the earlier) |
[...] | def avg: add / length; avg on the four stock numbers | 43.75 |
A plain parameter like rate is a filter that is re-evaluated each time it is used (so it could be .stock); a $rate parameter is evaluated once to a value. For simple values, $name is the safer form. For
real reuse, put definitions in a module, a file ending in .jq. I wrote lib/shoplib.jq with three functions:
import "name" as prefix; keeps the functions behind a prefix (s::money); include "name"; pastes them in with no prefix. -L dir (long form --library-path) says where to look.
Without it, jq did not find the module, with a clear message and exit status 3.
What Changed in jq 1.7 and 1.8 (Each One Run)
jq changed more in 2023 to 2026 than in the previous five years, and many online answers assume 1.6. The list below comes from the project's NEWS.md; every row was run on 1.8.2 and the result is shown. A feature
marked 1.7 or 1.8 is not there in older versions: if a script must run on an old jq, check jq --version first.
| Since | Feature | Result on 1.8.2 |
|---|---|---|
| 1.7 | pick(stream) | {"a":1,"b":{"c":2},"x":null} for pick(.a, .b.c, .x); for an array, [1,2,3,4] | pick(.[2]) gave [null,null,3] |
| 1.7 | debug(msgs) | wrote ["DEBUG:","value is 1"] to stderr and passed the input on |
| 1.7 | scan($re; $flags) | "abAB" | [scan("ab"; "i")] gave ["ab","AB"] |
| 1.7 | abs | [-5, 5, -0.5] | map(abs) gave [5,5,0.5] |
| 1.7 | if without else | 5 | if . > 3 then "big" end gave "big" |
| 1.7 | $binding as an object key; .a.b.[0] | "k" as $k | {$k, ($k): 1} gave {"k":1}; {a:{b:[1,2]}} | .a.b.[0] gave 1 |
| 1.7 | leaf_paths removed | leaf_paths/0 is not defined |
| 1.7 | -b (Windows) and --raw-output0 | -b: Chapter 4; --raw-output0 separated outputs with a zero byte (a 00 b 00) |
| 1.8 | trim, ltrim, rtrim | " hello " | [trim, ltrim, rtrim] gave ["hello","hello "," hello"]; 1 | trim failed: trim input must be a string |
| 1.8 | trimstr | "foobarfoo" | trimstr("foo") gave "bar" |
| 1.8 | add(generator) | {"xs":[1,2,3]} | .sum = add(.xs[]) gave {"xs":[1,2,3],"sum":6} |
| 1.8 | skip(n; f) | [1,2,3,4,5] | [skip(2; .[])] gave [3,4,5] |
| 1.8 | toboolean | ["true","false"] | map(toboolean) gave [true,false] |
| 1.8 | @urid | "%6a%71" | @urid gave jq |
| 1.8 (breaking) | tonumber rejects spaces | " 12 " | tonumber failed; " 12 " | trim | tonumber gave 12 |
| 1.8 (breaking) | index counts characters, not bytes | "héllo" | index("l") gave 2; utf8bytelength gave 6 |
| 1.8 (breaking) | limit with a negative count is an error; ltrimstr on a non-string is an error | limit doesn't support negative count; 1 | ltrimstr("a") failed with startswith() requires string inputs |
| 1.8 | --indent 0 no longer implies compact output | printed the object one item per line with no indentation |
One feature I expected is not there: toarray. [1] | toarray failed with toarray/0 is not defined on 1.8.2, although I had seen it recommended. Another thing to know is
the 1.8.0 change in how as binds against operators: [-1 as $x | 1, $x] gave [1,-1] on 1.8.2 (the notes say it used to be [-1,-1]), and
1 | . + 2 as $x | -$x gave -3. If an old script behaves differently, these notes are the first place to look.
{"k":"b","v":2} as a valid from_entries input from memory. It failed with Cannot use null (null) as object key on 1.8.2, and a wrong value name silently gave null.
How to avoid it: test each form a function claims to accept; the table above is what jq 1.8.2 actually did.2. I trusted an example from the 1.8.0 release notes. It does not hold on 1.8.2 because 1.8.1 reverted the change. How to avoid it: read the notes of every release after the one you quote, and run examples on the version you will use.
3. My first interpolation tests printed
null everywhere. My helper function passed -n (no input) along with the data file, so there was nothing to interpolate. How to avoid it: when
every result is null, check the options before the filter.4. My first "two ways" comparison reported differences that were not differences.
group_by sorts the keys and reduce keeps first-seen order, so the text differed while the data was identical.
How to avoid it: compare with jq -S, and keep a deliberately different pair as a control so a "same" verdict means something.5. My check for "does this path exist" used
index and said false for a path that does exist. index on an array of arrays searches for a sub-array, not an item. How to avoid it:
test a check on a case where you know the answer is true as well as one where it is false.6. I assumed
toarray existed. How to avoid it: run a function once before you put it in a lesson; "I have seen it recommended" is not evidence.
Hands-On Exercises
Using orders.json, write filters that produce: one summary object per order (id, customer, item count, total); spend per customer excluding cancelled orders; orders per status; units sold per SKU; a running count of items with foreach; and the busiest date with its order ids. For the three "totals" questions answer each two ways (group_by and reduce) and have a script compare the two answers with jq -S. Include a deliberately different pair to prove the comparison can report a difference.
On the owner object, drop null fields, upper-case the keys and default the null values. Then find out exactly which entry shapes from_entries accepts by trying eleven (key/value, name/value, k/v, numeric and boolean keys, a missing value and so on) and recording the result or error of each. Build an object keyed by SKU in three ways. On a small nested document, show paths, getpath for a present and a missing path, an existence check that returns true for a real path and false for a fake one, setpath, del, walk with a before-and-after count, pick (including a missing path) and the removed leaf_paths.
Write a CSV (with a header) from five records whose names contain a comma, a double quote, a newline, a null and an accented letter, and verify it by parsing it back with Python's csv module; include a control built with join(",") that must fail the same check. Show @sh making a hostile string safe to eval, and @uri/@urid and @base64/@base64d round trips. Write a module with three functions and use it with import and include, including the error when -L is missing. Finally run the release-notes reduce example and your list of 1.7 and 1.8 features on your own jq version, and say which results differ from the notes.
Chapter 5 Quick Reference
- Build:
{id, total: (...)}(shorthand, parentheses for computed keys),[ ... ]collects all results;+merges objects at the top level,*recursively;add // 0for an empty list - Entries:
to_entries,from_entries(usekey/value,name/NameorKey/Value; keys must be strings;k/vfailed on 1.8.2),with_entries(f),map_values(f),INDEX(.k) group_by(f)sorts by f then makes one array per value;unique_by,sort_by(a, -b),min_by/max_by,limit,first; compare two filters' results withjq -SEXPR as $v | ...(also destructuring);reduce STREAM as $x (init; update);foreachemits each state; never let a reduce update produceempty(1.8.0 notes say 8, 1.8.2 gave 7)- Only
falseandnullare false;a // bkeeps0and"";try,try ... catch,?;label/break;first(f)stops early - Update:
=(value from the whole input),|=(from the old value),+=,//=,del(path),delpaths - Paths:
paths,paths(scalars),getpath(null for missing and for null),setpath,path(f),..,walk(f),pick(...)(pads missing paths with null);leaf_pathswas removed in 1.7 - Formats (use
-r):@csv(array of scalars only),@tsv,@sh(always for shell commands),@uri/@urid,@base64/@base64d,@html,@json; a format before an interpolated string applies to each interpolated part def f: ...;,def f(x; y): ...;($xevaluates once); modules:-L dir,import "m" as p;(p::f) orinclude "m";; a missing module gives exit status 3- New since 1.7:
pick,debug(msgs),scan($re; $flags),abs,ifwithoutelse,--raw-output0. New in 1.8:trim/ltrim/rtrim,trimstr,add(f),skip,toboolean,@urid;tonumberrejects spaces andindexcounts characters.toarraywas not defined on 1.8.2
type, properties, required, items, enum, $ref and
$defs), why format is only an annotation by default, and writing a schema for a real API response. Start with Working with JSON 6.