The wrong-turn theory was that the API was down. A teammate's deploy script had started tagging every release latest instead of the real version number, and we spent an hour blaming the registry before I looked at the actual parse. The script read the version out of a JSON response with grep '"version"' | cut -d'"' -f4. It had worked for two years. Then the API started pretty-printing its output with the version nested one level deeper under a metadata object, grep matched a different "version" field that happened to appear first — a schema version, always "latest" — and cut dutifully handed it back. No error. Just the wrong string, confidently, every time. That's the trap with regex on JSON: it doesn't fail, it lies.
Why regex on JSON is guessing
grep, cut, sed, and awk are line-oriented tools, and JSON has no meaningful lines. {"a":1,"b":2} and the same object pretty-printed across six lines are identical data, but every line-based pattern you write depends on one specific layout. The API is free to change that layout any time — a new field, a reorder, a different indent, a value that contains a " or a : — and your pattern silently starts matching the wrong bytes. jq parses the whole document into a data structure first and then addresses it by path, so .metadata.version means that field regardless of how it's printed.
The three flags that matter
-r, //, and -e are the whole game for scripting. -r because a quoted string is not the string you want in a variable. // because real APIs omit optional fields and null is not a number. -e because "the field wasn't there" and "the field was empty" are different problems and you want your script to tell them apart.
Filtering with select()
When the response is a list and you only want some of it, select() is the filter:
Piping that into while IFS= read -r id; do …; done is the clean way to act on each match. Building these expressions by hand gets fiddly fast, which is exactly what the jq Filter Builder is for — paste a real response, click the fields you want, and copy the filter out.
Why jq over python -c
The obvious alternative is python3 -c 'import json,sys; print(json.load(sys.stdin)["name"])', and on a box that's guaranteed to have Python it's a fine fallback. I still reach for jq first because it's built for exactly this: the filters compose left to right like a shell pipe, -r and // and select are one token each instead of a line of Python, and it streams. The tradeoff is honest — jq is a dependency you have to install, and its syntax has a learning curve past the basics. For pulling a handful of fields out of an API response in a bash script, that curve pays for itself the first time an endpoint reformats and your jq path keeps working while a regex would have broken.
The response you're parsing should already be a verified 2xx — see making the request safely with curl. And when a parse fails because the API changed shape, that's exactly the kind of thing worth alerting to Slack rather than discovering downstream.
Run this script on a real Linux server
Get $200 free credit — DigitalOcean
Get $200 Free →Affiliate link · we earn a commission