jq — Gotchas

Null Handling

Missing keys return null silently
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.

null vs missing — has() vs != null
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.

Null in arithmetic — silent propagation
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.

The alternative operator // — null coalescing
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.

Chained alternatives
echo '{"name":"eth0"}' | jq '.ipv4 // .ipv6 // .ip // "none"'
# Output: "none"

String vs Number

Strings and numbers are not interchangeable
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.

Comparison trap — string vs numeric ordering
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)
sort_by with type coercion
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

empty produces no output at all
echo '5' | jq 'if . > 10 then . else empty end'
# Output: (nothing -- no output, not even null)
null produces an explicit null value
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.

empty in select
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.

Counting correctly — empty vs null in arrays
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

Graceful error handling
echo '"not a number"' | jq 'try tonumber catch "INVALID"'
# Output: "INVALID"
try without catch — suppress errors silently
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.

Error on purpose with error()
echo '{"status":"error","msg":"timeout"}' | \
    jq 'if .status == "error" then error(.msg) else .data end'
# Error: timeout

Shell Quoting

Single quotes protect jq expressions from the shell
# WRONG -- shell expands $1 as positional parameter
jq ".[] | {name: $1}" file.json

# CORRECT -- single quotes prevent shell expansion
jq '.[] | {name: .name}' file.json
Mix quoting for shell variables
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.

Heredoc for complex jq programs
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

Empty input crashes jq
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"
Multiple JSON values in input (not an array)
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.

Reading from a file vs stdin
# 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

Pipe has low precedence
# This groups as: .[] | (.name == "eth0")
echo '[{"name":"eth0"},{"name":"lo"}]' | jq '.[] | .name == "eth0"'
# Output:
# true
# false
Parentheses for explicit grouping
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

Forgetting -r for shell consumption
# 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
Forgetting to wrap select results in an array
# 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}]
Using = instead of == for comparison
# = 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)