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)"'
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

FunctionPurpose
lengthCount of array elements / string length / object keys
keysArray of an object’s keys
valuesArray of an object’s values
has("key")True if object has the given key
contains(x)True if array/string/object contains x
to_entriesConverts object to [{key, value}] array — useful before map
from_entriesReverses to_entries
flattenFlattens nested arrays
addSums an array of numbers, or concatenates strings/arrays
min_by(f) / max_by(f)Element with min/max value of f
tostring / tonumberType conversion
emptyProduces 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.

  • Linux ip command — the source of the ip -j JSON output used throughout this post; covers the full routing and policy routing model
  • Linux ssss -j produces JSON socket data that jq can filter the same way; useful for automating connection audits
  • Python for network engineers — for workflows more complex than one-liners, Python with requests and the json module provides the same API access with proper error handling and state management