article thumbnail

jq

Command-Line JSON, Done Right

15 min read
#commandlinetools, #jq, #json, #friday2

JSON is the lingua franca of the modern web. It pours out of REST APIs, configuration files, log pipelines, cloud CLIs, and databases. But raw JSON is built for machines, not humans — and the moment you need to find something inside a deeply nested blob, or reshape it, or feed one tool's output into another, you hit a wall. jq is the tool that tears that wall down. It's a small, blazing-fast command-line program that slices, filters, maps, and transforms JSON with a syntax so expressive it's really a little programming language in disguise.

If you've ever piped an API response into grep and prayed, this article is for you.

A Brief History

jq was created by Stephen Dolan and first released in 2012. Dolan's insight was simple but powerful: JSON deserves the same treatment the Unix philosophy gave to text. Just as sed, awk, and grep let you stream and transform lines of text, jq would let you stream and transform structured JSON — as a first-class citizen of the pipe.

Written in portable C with zero runtime dependencies, jq compiles to a single self-contained binary, which is exactly why it spread everywhere. After several quiet years, maintenance moved to a community team under the jqlang organization on GitHub, which shipped jq 1.7 in 2023 — the first release in five years — followed by 1.7.1 and, more recently, 1.8. The project is alive and well, and the language is more capable than ever.

Cross-Platform: It Runs Everywhere

One of jq's greatest strengths is that it's genuinely available on every major platform — no WSL required. That single static binary compiles cleanly for Windows, macOS, and Linux alike. Note that, unlike curl, jq does not ship pre-installed with Windows — so a bare jq at the prompt will report "not recognized" until you install it. That's a one-line fix:

| Platform | Install | |----------|---------| | Windows | winget install jqlang.jq — or choco install jq, scoop install jq, or drop jq.exe on your PATH | | macOS | brew install jq | | Debian/Ubuntu | sudo apt install jq | | Fedora/RHEL | sudo dnf install jq | | Arch | sudo pacman -S jq |

Verify your install with:

jq --version

First, a Word on Quoting — So You Can Follow Along

Everything in this article is meant to be run as you read it. But there's one wrinkle to clear up first, because it trips up everyone the first time: how you quote a jq filter depends on your shell.

A jq filter is a little program you hand to jq as a single argument, so it has to reach jq intact without your shell chewing on it. On Linux and macOS, bash and zsh treat anything inside 'single quotes' as completely literal — so filters are wrapped in single quotes and any " inside them is left alone. On Windows, cmd doesn't recognize single quotes at all — it passes the ' characters straight through to jq, which then chokes with unexpected INVALID_CHARACTER — so filters are wrapped in "double quotes" instead, and any " inside the filter is escaped as \". PowerShell is in between: it accepts 'single quotes' like bash, but still needs the inner " written as \".

The whole rule fits in one table:

| Your shell | Wrap the filter in… | A " inside the filter | A shell variable | |------------|--------------------|--------------------------|-----------------| | bash / zsh — Linux, macOS, WSL, Git Bash | 'single quotes' | stays " | "$VAR" | | Windows cmd | "double quotes" | becomes \" | %VAR% | | Windows PowerShell | 'single quotes' | becomes \" | $VAR |

So you can copy-paste and follow along whatever you're running, every command below is shown twice — a bash / zsh line and a Windows line. The Windows line uses cmd's double-quote style; on PowerShell, swap the outer " back to ' and keep the \" escapes. (One more cmd-only quirk — piping JSON in with echo needs no quotes around the JSON at all — shows up in the very first example.)

The Core Idea: Everything Is a Filter

The mental model that unlocks jq is this: a jq program is a filter. JSON comes in one end, gets transformed, and comes out the other. Filters connect with the pipe | — exactly like the shell — so you build complex transformations by chaining simple ones.

The simplest filter of all is the identity filter, ., which pretty-prints and colorizes whatever it receives:

bash / zsh (and PowerShell):

echo '{"name":"WaSQL","year":2025}' | jq '.'

Windows cmd:

echo {"name":"WaSQL","year":2025} | jq "."

Output:

{
  "name": "WaSQL",
  "year": 2025
}

That alone — turning a minified one-line API response into readable, colorized JSON — is reason enough to install jq today.

Working From a File

To keep the focus on jq rather than on echo, the rest of the examples read a file, people.json. Create it now:

{
  "team": "WaSQL",
  "members": [
    { "name": "Ada",   "role": "dev", "active": true  },
    { "name": "Linus", "role": "ops", "active": false },
    { "name": "Grace", "role": "dev", "active": true  }
  ]
}

Reaching Into Objects and Arrays

Access object fields with .key, and drill down with more dots. Index arrays with [N], or explode them into a stream with [].

bash / zsh:

jq '.team' people.json              # "WaSQL"
jq '.members[0].name' people.json   # "Ada"
jq '.members[].name' people.json    # stream:  "Ada"  "Linus"  "Grace"

Windows:

jq ".team" people.json
jq ".members[0].name" people.json
jq ".members[].name" people.json

The [] operator is the heart of jq: it takes an array and emits each element as a separate value, which you then pipe into the next filter.

The Pipe: Composing Filters

Inside a jq program, | feeds the output of one filter into the next — just like the shell pipe, but operating on JSON values:

bash / zsh:

jq '.members[] | .name' people.json

Windows:

jq ".members[] | .name" people.json

This says: explode members into a stream, then for each one, extract .name. You'll build almost everything this way.

Selecting and Filtering

select(condition) keeps only the values for which the condition is true — this is your WHERE clause. When the condition contains no string literal, only the outer quote changes:

bash / zsh — only the active members' names:

jq '.members[] | select(.active) | .name' people.json

Windows:

jq ".members[] | select(.active) | .name" people.json
"Ada"
"Grace"

Compare against a string value and the inner " now has to be escaped on Windows:

bash / zsh — everyone whose role is "dev":

jq '.members[] | select(.role == "dev")' people.json

Windows (cmd shown; PowerShell: swap outer " for '):

jq ".members[] | select(.role == \"dev\")" people.json

Combine conditions with and, or, and not, and use test("regex") for pattern matching:

bash / zsh — names starting with A:

jq '.members[] | select(.name | test("^A"))' people.json

Windows:

jq ".members[] | select(.name | test(\"^A\"))" people.json

Transforming with map, and Building New Shapes

jq shines at reshaping data. Wrap a filter in { } or [ ] to construct brand-new objects and arrays on the fly:

bash / zsh — reshape each member into a smaller object:

jq '.members | map({ person: .name, works: .active })' people.json

Windows:

jq ".members | map({ person: .name, works: .active })" people.json

bash / zsh — turn the array of objects into a simple name list:

jq '[ .members[].name ]' people.json      # ["Ada","Linus","Grace"]

Windows:

jq "[ .members[].name ]" people.json

map(f) applies a filter to every element of an array (like .members[] | f but keeping the result as an array). String interpolation with \( ) lets you compose text — and because the surrounding string uses ", Windows needs the escapes:

bash / zsh:

jq -r '.members[] | "\(.name) is a \(.role)"' people.json

Windows:

jq -r ".members[] | \"\(.name) is a \(.role)\"" people.json
Ada is a dev
Linus is a ops
Grace is a dev

Raw Output and Tabular Formats

By default jq prints JSON — so strings come out with quotes. Two flags matter constantly:

jq even speaks CSV and TSV, which makes it a surprisingly good ETL glue tool:

bash / zsh:

jq -r '.members[] | [.name, .role, .active] | @csv' people.json

Windows:

jq -r ".members[] | [.name, .role, .active] | @csv" people.json
"Ada","dev",true
"Linus","ops",false
"Grace","dev",true

A Toolbox of Built-in Functions

jq ships with a rich standard library. A few you'll reach for daily:

| Function | What it does | |----------|--------------| | length | length of a string, array, or object | | keys | sorted array of an object's keys | | has("k") | does the object have this key? | | to_entries / from_entries | convert between objects and key/value arrays | | sort_by(f) | sort an array by a computed key | | group_by(f) | group array elements by a computed key | | unique | dedupe and sort | | add | sum numbers / concatenate arrays / merge | | min_by / max_by | element with the smallest/largest key | | select(f) | keep values where f is true | | // | alternative operator — a default when the left side is null/false |

bash / zsh:

jq '.members | length' people.json                                             # 3
jq '.members | group_by(.role) | map({role: .[0].role, count: length})' people.json
jq '.members[] | .nickname // "N/A"' people.json                               # default when missing

Windows:

jq ".members | length" people.json
jq ".members | group_by(.role) | map({role: .[0].role, count: length})" people.json
jq ".members[] | .nickname // \"N/A\"" people.json

Passing in Shell Variables Safely

Don't glue shell strings into your filter — pass them in with --arg (string) or --argjson (any JSON). It's safer and quote-proof, and it's the one place all three shells genuinely differ:

bash / zsh:

role="dev"
jq --arg r "$role" '.members[] | select(.role == $r) | .name' people.json

Windows cmd:

set role=dev
jq --arg r %role% ".members[] | select(.role == $r) | .name" people.json

Windows PowerShell:

$role = "dev"
jq --arg r $role '.members[] | select(.role == $r) | .name' people.json

Inside the filter, $r is a jq variable (created by --arg), written the same way in every shell — only the part that reads the shell's own role variable changes.

The Classic Pairing: curl + jq

This is where jq earns its permanent spot in your toolkit. Fetch an API and immediately carve out exactly what you need. This filter has no string literals, so again only the outer quote changes:

bash / zsh:

curl -s https://api.github.com/repos/jqlang/jq \
  | jq '{ name, stars: .stargazers_count, issues: .open_issues_count }'

Windows (one line):

curl -s https://api.github.com/repos/jqlang/jq | jq "{ name, stars: .stargazers_count, issues: .open_issues_count }"
{
  "name": "jq",
  "stars": 35558,
  "issues": 479
}

One request, one filter, and you've turned a 100-field API response into the three fields you actually care about.

When Quoting Gets Ugly: Use a Filter File

For anything gnarly — several string literals, multiple lines, regexes full of backslashes — stop fighting the shell and put the program in a .jq file, then run it with -f. This works identically on every platform, with no escaping at all:

active-devs.jq:

.members[]
| select(.active and .role == "dev")
| .name

Run it (same command on Linux, macOS, and Windows):

jq -f active-devs.jq people.json
"Ada"
"Grace"

It also keeps complex jq programs readable and version-controllable — treat them like the little scripts they are.

Why jq Belongs in Your Toolkit

jq is one of those rare tools that pays for its small learning curve within the first hour — and then keeps paying, every single day, for years. Install it, learn ., [], |, and select(), and you'll wonder how you ever wrestled JSON without it.

Next time you catch yourself piping an API response into grep, stop — and reach for jq instead.

Enjoyed this article? Share it with someone who'd love it too.

Most covered topics