jq for Network Engineers: Parsing APIs, Routing Tables, and Structured Logs
Modern network tooling speaks JSON. ip -j, ss -j, FortiGate’s REST API, FortiManager’s JSON-RPC API — all of it. grep and awk can mangle JSON in a pinch, but they do not understand its structure, and a one-character change in formatting breaks a regex-based parser. jq understands JSON natively and gives you a proper filter language for it.
This post covers jq from first principles through to the patterns that come up constantly when scripting against network infrastructure.
The Mental Model
jq filters take JSON in on stdin and produce JSON out on stdout. The simplest filter, ., is the identity — it just reformats:
echo '{"name":"eth0","mtu":1500}' | jq '.'
{
"name": "eth0",
"mtu": 1500
}
Filters compose with |, exactly like shell pipes, but operating on JSON values rather than text streams.
Basic Selection
Object fields
echo '{"name":"eth0","mtu":1500}' | jq '.name'
# "eth0"
echo '{"name":"eth0","mtu":1500}' | jq '.mtu'
# 1500
# Raw output without quotes
echo '{"name":"eth0"}' | jq -r '.name'
# eth0
The -r (raw) flag is essential when piping jq output into another shell command — without it, string values keep their surrounding quotes.
Array elements
echo '[1,2,3]' | jq '.[0]'
# 1
echo '[1,2,3]' | jq '.[-1]'
# 3
echo '[1,2,3]' | jq '.[1:3]'
# [2, 3]
Nested paths
echo '{"interface":{"name":"eth0","stats":{"rx_bytes":1024}}}' \
| jq '.interface.stats.rx_bytes'
# 1024
Iterating arrays
echo '[{"name":"eth0"},{"name":"eth1"}]' | jq '.[].name'
# "eth0"
# "eth1"
.[] explodes an array into a stream of its elements — every filter after it in the pipeline runs once per element.
Filtering with select()
select() keeps elements matching a condition, dropping the rest:
ip -j addr show | jq '.[] | select(.operstate == "UP")'
ip -j addr show | jq -r '.[] | select(.operstate == "UP") | .ifname'
Multiple conditions
ip -j addr show | jq -r '
.[] | select(.operstate == "UP" and (.ifname | startswith("eth")))
| .ifname
'
Numeric comparisons
ip -j addr show | jq '.[] | select(.mtu > 1500)'
Mapping and Transformation
map()
Apply a filter to every element and collect results as an array:
ip -j addr show | jq '[.[] | .ifname]'
# Equivalent, more idiomatic:
ip -j addr show | jq 'map(.ifname)'
Constructing new objects
ip -j addr show | jq '[.[] | {name: .ifname, state: .operstate}]'
[
{ "name": "lo", "state": "UNKNOWN" },
{ "name": "eth0", "state": "UP" }
]
This pattern — reshape a verbose API response into exactly the fields you need — is the single most useful thing jq does for network automation scripts.
Extracting nested arrays of addresses
ip -j addr show | jq -r '
.[] | .ifname as $iface
| .addr_info[]?
| "\($iface): \(.local)/\(.prefixlen)"
'
Output:
lo: 127.0.0.1/8
eth0: 192.168.1.10/24
eth0: 10.0.0.5/30
The as $iface syntax binds a value to a variable so it survives the pipe into the nested addr_info array — without it, you would lose access to the parent interface name once you descend into the array.
The ? after .addr_info[] suppresses errors for interfaces with no addresses (where addr_info might be null or an empty array) instead of halting the whole pipeline.
Sorting and Grouping
sort_by()
ss -tin state established \
| jq -s 'sort_by(.rtt) | .[0:5]'
(ss -tin does not actually emit JSON — see the section on combining tools below for the real version of this.)
group_by()
echo '[{"vrf":"a","mtu":1500},{"vrf":"b","mtu":1500},{"vrf":"a","mtu":9000}]' \
| jq 'group_by(.vrf)'
unique and unique_by
ip -j addr show | jq '[.[] | .operstate] | unique'
# ["DOWN", "UP", "UNKNOWN"]
Real-World Network Automation Patterns
FortiGate REST API: extract all firewall policy names and actions
curl -s -k \
-H "Authorization: Bearer $FGT_TOKEN" \
"https://$FGT_HOST/api/v2/cmdb/firewall/policy" \
| jq -r '.results[] | "\(.policyid): \(.name) -> \(.action)"'
FortiGate REST API: find all disabled policies
curl -s -k \
-H "Authorization: Bearer $FGT_TOKEN" \
"https://$FGT_HOST/api/v2/cmdb/firewall/policy" \
| jq -r '.results[] | select(.status == "disable") | .name'
FortiGate REST API: SD-WAN member health summary
curl -s -k \
-H "Authorization: Bearer $FGT_TOKEN" \
"https://$FGT_HOST/api/v2/monitor/virtual-wan/health-check" \
| jq -r '.results[] | "\(.name): \(.state) latency=\(.latency)ms"'
Combine ip -j and jq for a routing summary
ip -j route show \
| jq -r '.[] | select(.dst != "default") | "\(.dst) via \(.gateway // "direct") dev \(.dev)"'
Cross-reference link state and addresses in one pass
ip -j addr show \
| jq -r '.[] | select(.operstate == "UP") |
.ifname as $name |
(.addr_info[]? | select(.family == "inet") | .local) as $ip |
"\($name): \($ip)"
'
Parsing structured application logs (JSON lines)
Many modern services (and the MCP server behind this site) emit JSON-lines logs. jq filters them natively without grep-and-pray:
# All error-level log lines from today
cat /var/log/mcp/mcp.log \
| jq -r 'select(.level == "error") | "\(.timestamp) \(.message)"'
# Count log lines by event type
cat /var/log/mcp/mcp.log | jq -r '.event' | sort | uniq -c | sort -rn
# All contact-form rate-limit events with the offending IP
cat /var/log/mcp/mcp.log \
| jq -r 'select(.event == "contact_rate_limited") | .ip'
Diffing two JSON API responses
diff \
<(curl -s "$FGT_HOST/api/v2/cmdb/firewall/policy" | jq -S '.results') \
<(curl -s "$FGT_HOST/api/v2/cmdb/firewall/policy" | jq -S '.results' --request-after-change)
-S sorts object keys before output, which makes diff actually useful on JSON — without it, two semantically identical objects with keys in a different order would show as different.
Building JSON Output (Not Just Reading It)
jq can construct JSON as well as parse it — useful when a script needs to POST a payload:
jq -n --arg name "test-policy" --arg action "accept" \
'{name: $name, action: $action, srcintf: [{name: "lan"}]}'
{
"name": "test-policy",
"action": "accept",
"srcintf": [
{ "name": "lan" }
]
}
--arg safely injects a shell variable as a JSON string (handling escaping correctly, unlike string interpolation). Pipe straight into curl:
PAYLOAD=$(jq -n --arg name "test-policy" '{name: $name, action: "accept"}')
curl -s -k -X POST \
-H "Authorization: Bearer $FGT_TOKEN" \
-d "$PAYLOAD" \
"https://$FGT_HOST/api/v2/cmdb/firewall/policy"
Useful Builtins
| Function | Purpose |
|---|---|
length | Count of array elements / string length / object keys |
keys | Array of an object’s keys |
values | Array of an object’s values |
has("key") | True if object has the given key |
contains(x) | True if array/string/object contains x |
to_entries | Converts object to [{key, value}] array — useful before map |
from_entries | Reverses to_entries |
flatten | Flattens nested arrays |
add | Sums an array of numbers, or concatenates strings/arrays |
min_by(f) / max_by(f) | Element with min/max value of f |
tostring / tonumber | Type conversion |
empty | Produces no output — useful in select chains |
to_entries example: converting a config object to a table
echo '{"eth0":1500,"eth1":9000,"eth2":1500}' \
| jq -r 'to_entries[] | "\(.key): MTU \(.value)"'
Error Handling
Real-world JSON is messy — missing fields, nulls where you expect objects, arrays that are sometimes absent. Three operators handle this gracefully:
# ? suppresses an error if the path doesn't exist
echo '{}' | jq '.missing.path?'
# null (instead of an error)
# // provides a fallback for null or false
echo '{"name": null}' | jq '.name // "unknown"'
# "unknown"
# alternative form combining both
echo '{}' | jq '.addr_info[]?.local // "no address"'
A Complete Script: SD-WAN Health Dashboard
#!/usr/bin/env bash
set -euo pipefail
FGT_HOST="${FGT_HOST:?Set FGT_HOST}"
FGT_TOKEN="${FGT_TOKEN:?Set FGT_TOKEN}"
curl -s -k \
-H "Authorization: Bearer $FGT_TOKEN" \
"https://$FGT_HOST/api/v2/monitor/virtual-wan/health-check" \
| jq -r '
.results[] |
"\(.name)\t\(.state)\tlatency=\(.latency // "n/a")ms\tjitter=\(.jitter // "n/a")ms\tloss=\(.packet_loss // "n/a")%"
' \
| column -t -s$'\t'
This pulls SD-WAN Performance SLA state for every configured health-check, reshapes the nested JSON into a flat tab-separated structure, and renders it as an aligned table — a five-line script replacing what would otherwise be a much longer Python program for a task this simple.
What to Read Next
- Linux ip command — the source of the
ip -jJSON output used throughout this post; covers the full routing and policy routing model - Linux ss —
ss -jproduces JSON socket data thatjqcan filter the same way; useful for automating connection audits - Python for network engineers — for workflows more complex than one-liners, Python with
requestsand thejsonmodule provides the same API access with proper error handling and state management