The first time I had to pull a single field out of a JSON API response inside a Bash script, I tried to do it with grep and sed. It technically worked until the API changed the order of fields in the response and my regex broke silently. That experience taught me that Bash isn’t a JSON-native language, but with the right tool — jq — parsing JSON in shell scripts becomes not just possible, but genuinely pleasant. This article covers everything I’ve learned about parsing JSON in Bash, from quick one-liners to full automation pipelines.
Why JSON Parsing in Bash Is Tricky
Bash has no native understanding of structured data. Everything in Bash is a string, so a JSON object like {"name": "Alice", "age": 30} is, to Bash, just a plain line of text. That means naive tools like grep or awk can extract data that looks right in simple cases, but they break the moment:
- Whitespace or formatting changes
- Nested objects or arrays are involved
- Values contain escaped characters or Unicode
- Field order changes
That’s why the standard approach in the Bash world is to use a dedicated JSON parser: jq.
Installing jq
On most systems, jq is available directly from the package manager:
# Debian/Ubuntu
sudo apt-get install jq
# RHEL/CentOS/Fedora
sudo dnf install jq
# macOS (Homebrew)
brew install jq
Verify it installed correctly:
jq --version
Beginner Example: Extracting a Single Field
Suppose I have this JSON in a file called user.json:
{
"name": "Alice",
"age": 30,
"email": "alice@example.com"
}
To extract the name field:
jq -r '.name' user.json
Output:
Alice
The -r flag tells jq to output raw strings instead of quoted JSON strings, so I get Alice instead of "Alice".
Parsing JSON from a Variable
Often the JSON isn’t in a file — it’s the output of a curl command stored in a variable:
#!/usr/bin/env bash
response='{"status": "ok", "data": {"id": 42, "name": "Widget"}}'
status=$(echo "$response" | jq -r '.status')
id=$(echo "$response" | jq -r '.data.id')
name=$(echo "$response" | jq -r '.data.name')
echo "Status: $status"
echo "ID: $id"
echo "Name: $name"
Output:
Status: ok
ID: 42
Name: Widget
Notice how .data.id uses dot notation to reach into a nested object — this is one of jq‘s biggest advantages over regex-based extraction.
Working with Arrays
Given this JSON:
{
"users": [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
]
}
I can loop through the array and extract each name:
#!/usr/bin/env bash
json=$(cat users.json)
echo "$json" | jq -r '.users[].name'
Output:
Alice
Bob
To loop through and process each user individually in a Bash loop:
#!/usr/bin/env bash
while IFS= read -r name; do
echo "Processing user: $name"
done < <(jq -r '.users[].name' users.json)
I use < <(...) (process substitution) instead of piping into the while loop directly, because piping would run the loop in a subshell, which means any variables set inside the loop wouldn’t persist afterward.
How This Works Internally
jqparses the entire JSON document into an internal tree structure, similar to how a browser parses HTML into a DOM.- Filters like
.nameor.users[].namearejq‘s own query language, not regex. The[]operator means “iterate over every element of this array.” -rconvertsjq‘s JSON-string output (which includes quotes) into raw text, which is what you usually want when assigning to Bash variables.- Process substitution (
< <(...)) creates a temporary file descriptor that behaves like a file, letting thewhileloop read fromjq‘s output without spawning a subshell.
Real-World Use Case: Parsing an API Response
Here’s a script I actually use to check the latest release version of a GitHub repository via its API:
#!/usr/bin/env bash
set -euo pipefail
REPO="cli/cli"
response=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest")
version=$(echo "$response" | jq -r '.tag_name')
published=$(echo "$response" | jq -r '.published_at')
url=$(echo "$response" | jq -r '.html_url')
echo "Latest version of ${REPO}: ${version}"
echo "Published on: ${published}"
echo "Release page: ${url}"
Example output:
Latest version of cli/cli: v2.55.0
Published on: 2026-06-12T10:15:00Z
Release page: https://github.com/cli/cli/releases/tag/v2.55.0
Automation Example: Monitoring an API and Alerting on Change
I run this as a cron job to notify me if a service’s status changes:
#!/usr/bin/env bash
set -euo pipefail
STATUS_FILE="/tmp/last_status.txt"
API_URL="https://status.example.com/api/v2/status.json"
current_status=$(curl -s "$API_URL" | jq -r '.status.description')
if [ -f "$STATUS_FILE" ]; then
last_status=$(cat "$STATUS_FILE")
if [ "$current_status" != "$last_status" ]; then
echo "Status changed from '$last_status' to '$current_status'"
# send a notification here (e.g. mail, Slack webhook)
fi
fi
echo "$current_status" > "$STATUS_FILE"
Building JSON Output From Bash
Parsing isn’t the only direction — I frequently need to generate JSON from Bash variables, and jq handles that safely too, avoiding manual string concatenation that can break on special characters:
name="Alice"
age=30
jq -n --arg name "$name" --argjson age "$age" \
'{name: $name, age: $age}'
Output:
{
"name": "Alice",
"age": 30
}
Using --arg and --argjson is safer than string-building JSON manually, because jq handles escaping quotes and special characters automatically.
Best Practices
- Always use
jq(or a similarly robust parser) instead ofgrep/sed/awkfor anything beyond the most trivial JSON. - Use
-rwhen you want to assign the result to a Bash variable without surrounding quotes. - Use
--argand--argjsonwhen constructing JSON from shell variables to avoid injection or malformed output. - Check
jq‘s exit code ($?) after parsing to detect malformed JSON early. - Pin a specific
jqfilter version behavior in mind — very oldjqversions (pre-1.6) lack some newer built-ins likeltrimstrvariations orascii.
Security Considerations
- Never pass untrusted JSON directly into
evalor command substitution without validating it throughjqfirst; letting raw external data influence commands is a classic injection vector. - When constructing shell commands from JSON values, always quote the resulting variables (
"$var") to prevent word-splitting or glob expansion from user-controlled content. - If the JSON comes from an untrusted source, validate it’s well-formed before processing:
echo "$data" | jq emptywill exit non-zero if the JSON is invalid.
Optimization Tips
- If you need multiple fields from the same JSON blob, extract them all in a single
jqcall using-rwith tab-separated output, rather than callingjqrepeatedly:read -r status id name <<< "$(echo "$response" | jq -r '[.status, .data.id, .data.name] | @tsv')"This avoids spawningjqmultiple times, which matters when parsing JSON inside a large loop. - For very large JSON files, consider
jq‘s streaming mode (jq --stream) instead of loading the entire document into memory at once.
Troubleshooting
- “jq: command not found”:
jqisn’t installed; install it via your package manager as shown above. - “jq: error: Cannot index string with string”: usually means you’re trying to access
.fieldon something that’s actually a string or array, not an object — double check the JSON structure withjq .first to pretty-print and inspect it. - Empty output where you expected a value: check for typos in the field path, and confirm the field actually exists using
jq 'keys'orjq '.'to inspect the full structure. - Unicode or special characters look wrong: make sure your terminal locale supports UTF-8 (
localecommand) sincejqoutputs UTF-8 by default.
Common Mistakes to Avoid
- Trying to parse JSON with
grep/awk/sedfor anything beyond a flat, single-line structure. - Forgetting
-rand ending up with extra quotes in variables ("Alice"instead ofAlice). - Piping into a
whileloop directly instead of using process substitution, then wondering why variables set inside the loop don’t persist outside it. - Not validating JSON before parsing, leading to confusing downstream errors when a field is unexpectedly
null.
FAQs
Do I have to use jq, or are there alternatives? jq is by far the most common and best-supported tool for this in shell scripts. Alternatives include python3 -c "import json,sys; ..." or yq (which also handles YAML), but jq remains the standard for pure JSON work in Bash.
Can jq handle deeply nested JSON? Yes, you can chain as many .field accessors as needed, and use [] for arrays at any depth, e.g. .data.items[].details.price.
What if a field might not exist? Use jq -r '.field // "default"' to provide a fallback value if the field is missing or null.
Can I filter or transform JSON, not just extract fields? Yes, jq supports filtering, mapping, sorting, and even generating brand-new JSON structures — it’s closer to a small functional programming language than a simple query tool.
Summary
Parsing JSON in Bash without a real parser is a recipe for fragile scripts that break the moment the data shape changes even slightly. jq solves this cleanly, letting you extract nested fields, iterate over arrays, and even build new JSON output, all while keeping your Bash scripts readable and safe from injection issues. Once jq became part of my regular toolkit, I stopped writing brittle regex-based JSON parsers entirely.
References
- Official jq manual: https://jqlang.github.io/jq/manual/
- jq GitHub repository: https://github.com/jqlang/jq
- GNU Bash Reference Manual (process substitution): https://www.gnu.org/software/bash/manual/bash.html#Process-Substitution
