How to Parse JSON in Bash

How to Parse JSON in Bash

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

  • jq parses the entire JSON document into an internal tree structure, similar to how a browser parses HTML into a DOM.
  • Filters like .name or .users[].name are jq‘s own query language, not regex. The [] operator means “iterate over every element of this array.”
  • -r converts jq‘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 the while loop read from jq‘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 of grep/sed/awk for anything beyond the most trivial JSON.
  • Use -r when you want to assign the result to a Bash variable without surrounding quotes.
  • Use --arg and --argjson when 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 jq filter version behavior in mind — very old jq versions (pre-1.6) lack some newer built-ins like ltrimstr variations or ascii.

Security Considerations

  • Never pass untrusted JSON directly into eval or command substitution without validating it through jq first; 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 empty will 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 jq call using -r with tab-separated output, rather than calling jq repeatedly: read -r status id name <<< "$(echo "$response" | jq -r '[.status, .data.id, .data.name] | @tsv')" This avoids spawning jq multiple 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”: jq isn’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 .field on something that’s actually a string or array, not an object — double check the JSON structure with jq . 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' or jq '.' to inspect the full structure.
  • Unicode or special characters look wrong: make sure your terminal locale supports UTF-8 (locale command) since jq outputs UTF-8 by default.

Common Mistakes to Avoid

  • Trying to parse JSON with grep/awk/sed for anything beyond a flat, single-line structure.
  • Forgetting -r and ending up with extra quotes in variables ("Alice" instead of Alice).
  • Piping into a while loop 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
Total
2
Shares

Leave a Reply

Previous Post
How to Download Files with Bash

How to Download Files with Bash

Next Post
How to Use Case Statements in Bash

How to Use Case Statements in Bash

Related Posts