jq — Gotchas
Null Handling
echo '{"name":"eth0"}' | jq '.ip'
# Output: null
This is a feature and a trap. jq never errors on missing keys — it returns null. You must guard explicitly if you need to detect absence.
echo '{"name":"eth0","ip":null}' | jq 'has("ip")'
# Output: true (key exists, even though value is null)
echo '{"name":"eth0","ip":null}' | jq '.ip != null'
# Output: false (value IS null)
echo '{"name":"eth0"}' | jq 'has("ip")'
# Output: false (key does not exist at all)
has("key") checks for key existence. .key != null checks for non-null value. They are not interchangeable.
echo '{"a":5}' | jq '.a + .b'
# Output: 5 (null is treated as identity for addition)
echo '{"a":5}' | jq '.a * .b'
# Output: null (null * anything = null)
Null propagation rules differ by operator. Addition ignores null (useful), but multiplication propagates it (surprising). Always check your inputs.
echo '{"name":"eth0"}' | jq '.ip // "no address"'
# Output: "no address"
echo '{"name":"eth0","ip":"10.0.0.1"}' | jq '.ip // "no address"'
# Output: "10.0.0.1"
// returns the right side when the left side is null or false. This is jq’s primary null-safety tool.
echo '{"name":"eth0"}' | jq '.ipv4 // .ipv6 // .ip // "none"'
# Output: "none"
String vs Number
echo '{"port":"443"}' | jq '.port + 1'
# Error: string ("443") and number (1) cannot be added
echo '{"port":"443"}' | jq '(.port | tonumber) + 1'
# Output: 444
API responses frequently encode numbers as strings. Always tonumber before arithmetic.
echo '["9","10","2","1"]' | jq 'sort'
# Output: ["1", "10", "2", "9"] (lexicographic -- "10" < "2")
echo '["9","10","2","1"]' | jq 'map(tonumber) | sort'
# Output: [1, 2, 9, 10] (numeric -- correct)
echo '[{"name":"a","port":"443"},{"name":"b","port":"80"},{"name":"c","port":"8080"}]' | \
jq 'sort_by(.port | tonumber)'
# Output: sorted by numeric port value, not alphabetic
Empty vs Null
echo '5' | jq 'if . > 10 then . else empty end'
# Output: (nothing -- no output, not even null)
echo '5' | jq 'if . > 10 then . else null end'
# Output: null
The distinction matters in pipelines. empty is consumed silently — it removes the element from the output stream. null passes through as a value.
echo '[1,2,3,4,5]' | jq '[.[] | select(. > 3)]'
# Output: [4, 5]
select(false) produces empty, which is why non-matching elements disappear rather than becoming null.
echo '[1,null,2,null,3]' | jq 'length'
# Output: 5 (null counts as an element)
echo '[1,null,2,null,3]' | jq '[.[] | select(. != null)] | length'
# Output: 3 (filter out nulls first)
try-catch
echo '"not a number"' | jq 'try tonumber catch "INVALID"'
# Output: "INVALID"
echo '[1,"two",3,"four",5]' | jq '[.[] | try tonumber]'
# Output: [1, 3, 5]
try without catch produces empty on error — failed elements vanish. Useful for processing dirty data where some records are malformed.
echo '{"status":"error","msg":"timeout"}' | \
jq 'if .status == "error" then error(.msg) else .data end'
# Error: timeout
Shell Quoting
# WRONG -- shell expands $1 as positional parameter
jq ".[] | {name: $1}" file.json
# CORRECT -- single quotes prevent shell expansion
jq '.[] | {name: .name}' file.json
HOST="web-01"
# WRONG -- shell expansion inside jq
jq ".[] | select(.host == \"$HOST\")" file.json
# CORRECT -- use --arg
jq --arg h "$HOST" '.[] | select(.host == $h)' file.json
--arg is always safer than quoting gymnastics. It handles escaping correctly for all inputs.
echo '[{"name":"a","val":1},{"name":"b","val":2}]' | jq '
.[]
| select(.val > 1)
| {name, doubled: (.val * 2)}
'
# Output: {"name": "b", "doubled": 4}
Multi-line jq expressions work naturally inside single quotes. No continuation characters needed.
Input Edge Cases
echo "" | jq '.'
# Error: parse error (Unexpected end of input)
# Fix: use -n when you might have no input
echo "" | jq -n 'try input catch "no input"'
# Output: "no input"
printf '{"a":1}\n{"a":2}\n' | jq '.a'
# Output:
# 1
# 2
jq processes each top-level JSON value independently by default. Use -s to collect them first if you need cross-value operations.
# From file -- jq reads it directly
jq '.name' /tmp/data.json
# From stdin -- pipe it
cat /tmp/data.json | jq '.name'
# From command substitution -- use process substitution
jq '.' <(curl -s https://jsonplaceholder.typicode.com/users/1)
Operator Precedence
# This groups as: .[] | (.name == "eth0")
echo '[{"name":"eth0"},{"name":"lo"}]' | jq '.[] | .name == "eth0"'
# Output:
# true
# false
echo '{"a":1,"b":2}' | jq '(.a + .b) * 2'
# Output: 6
echo '{"a":1,"b":2}' | jq '.a + .b * 2'
# Output: 5 (multiplication binds tighter)
Common Mistakes
# Without -r: quotes in the output
NAME=$(echo '{"name":"eth0"}' | jq '.name')
echo "$NAME"
# Output: "eth0" (with quotes -- breaks comparisons)
# With -r: clean string
NAME=$(echo '{"name":"eth0"}' | jq -r '.name')
echo "$NAME"
# Output: eth0
# Without array wrapper: multiple separate values
echo '[{"a":1},{"a":2},{"a":3}]' | jq '.[] | select(.a > 1)'
# Output:
# {"a": 2}
# {"a": 3}
# With array wrapper: proper array result
echo '[{"a":1},{"a":2},{"a":3}]' | jq '[.[] | select(.a > 1)]'
# Output: [{"a": 2}, {"a": 3}]
# = is assignment, == is comparison
echo '{"a":1}' | jq '.a == 1'
# Output: true
echo '{"a":1}' | jq '.a = 1'
# Output: {"a": 1} (assigns 1 to .a -- not a comparison)