JSON, CSV and APIs
Handling structured data in scripts: jq and curl in Bash, ConvertFrom-Json, Import-Csv and Invoke-RestMethod in PowerShell, and calling web APIs safely.
Why parse instead of grep
JSON
(JavaScript Object Notation) and
CSV are the
formats in which systems hand data to scripts: an export from the
billing system, the response of a ticketing API, the inventory of a
cloud account. Both have rules that line-based tools do not know.
JSON may arrive on one line or pretty-printed over fifty; a CSV
field may contain a comma inside quotes. A grep that
works today breaks the day the producer formats its output
differently. A parser reads the structure and does not care about
layout. The formats themselves are explained in
JSON and Other Data Formats.
Most examples on this page use the same outage ticket, as an API might return it:
{"ticket":"INC-20419","priority":2,"tags":["outage","storm"],"site":{"city":"Utrecht"},"meters":[{"id":"M-1042","kwh":4521},{"id":"M-1043","kwh":312}]}
jq: JSON in Bash
jq is a command-line JSON processor: it
reads JSON, applies a filter written in its own small language, and
writes JSON (or plain text with -r). A filter is a path
such as .site.city; [] walks through an
array; | inside the filter passes results on, just like
a shell pipe. Without -r, strings keep their JSON
quotes:
cat > ticket.json <<'EOF'
{"ticket":"INC-20419","priority":2,"tags":["outage","storm"],"site":{"city":"Utrecht"},"meters":[{"id":"M-1042","kwh":4521},{"id":"M-1043","kwh":312}]}
EOF
jq '.ticket' ticket.json
jq -r '.ticket' ticket.json
jq -r '.site.city' ticket.json
jq -r '.tags[]' ticket.json
jq -r '.tags | join(",")' ticket.json
jq '.meters | map(.kwh) | add' ticket.json
"INC-20419" INC-20419 Utrecht outage storm outage,storm 4833
select() filters, and string interpolation
"\(…)" or the formatters @csv and
@tsv turn objects into lines that a shell loop can
read. Values from the shell go in with
--arg name value (always a string) or
--argjson name value (parsed as JSON); never paste a
shell variable into the filter text itself:
cat > ticket.json <<'EOF'
{"ticket":"INC-20419","priority":2,"tags":["outage","storm"],"site":{"city":"Utrecht"},"meters":[{"id":"M-1042","kwh":4521},{"id":"M-1043","kwh":312}]}
EOF
echo "Meters above 1000 kWh:"
jq -r '.meters[] | select(.kwh > 1000) | .id' ticket.json
echo "As CSV:"
jq -r '.meters[] | [.id, .kwh] | @csv' ticket.json
echo "One meter, chosen in the shell:"
meter=M-1043
jq -r --arg id "$meter" '.meters[] | select(.id == $id) | "\(.id): \(.kwh) kWh"' ticket.json
echo "Loop in Bash:"
jq -r '.meters[] | [.id, .kwh] | @tsv' ticket.json |
while IFS=$'\t' read -r id kwh; do
printf ' %s uses %5d kWh\n' "$id" "$kwh"
done
Meters above 1000 kWh: M-1042 As CSV: "M-1042",4521 "M-1043",312 One meter, chosen in the shell: M-1043: 312 kWh Loop in Bash: M-1042 uses 4521 kWh M-1043 uses 312 kWh
Missing values and errors
A path that does not exist gives null, not an error.
// supplies a default, and -e makes
jq exit with status 1 when the last result is
null or false, which a script can test.
Invalid JSON is a real error: jq prints a parse error and exits with
a status above 1 (4 in jq 1.6, as shown below):
cat > ticket.json <<'EOF'
{"ticket":"INC-20419","priority":2,"tags":["outage","storm"],"site":{"city":"Utrecht"},"meters":[{"id":"M-1042","kwh":4521},{"id":"M-1043","kwh":312}]}
EOF
jq -r '.assignee' ticket.json
jq -r '.assignee // "unassigned"' ticket.json
if ! jq -e '.assignee' ticket.json > /dev/null; then
echo "Ticket has no assignee"
fi
echo '{"ticket": INC-20419}' > broken.json
jq '.ticket' broken.json
echo "jq exit status: $?"
null unassigned Ticket has no assignee parse error: Invalid numeric literal at line 1, column 21 jq exit status: 4
Building JSON safely
JSON built by gluing strings breaks, or becomes an injection risk,
as soon as a value contains a quote or a backslash. Let
jq -n (no input) build it: every
--arg value is escaped correctly:
summary='Storm damage at "Utrecht Noord" substation'
ticket=INC-20419
echo "Glued by hand (invalid JSON):"
echo "{\"ticket\": \"$ticket\", \"summary\": \"$summary\"}"
echo "Built with jq:"
jq -n --arg ticket "$ticket" --arg summary "$summary" --argjson priority 2 \
'{ticket: $ticket, summary: $summary, priority: $priority}'
Glued by hand (invalid JSON):
{"ticket": "INC-20419", "summary": "Storm damage at "Utrecht Noord" substation"}
Built with jq:
{
"ticket": "INC-20419",
"summary": "Storm damage at \"Utrecht Noord\" substation",
"priority": 2
}
ConvertFrom-Json: JSON as objects
ConvertFrom-Json is the
PowerShell cmdlet that parses a JSON string into objects: JSON
objects become PSCustomObjects, arrays become arrays,
and numbers, booleans and null get matching .NET types.
From then on everything from
the object pipeline
applies: dot notation, Where-Object,
Measure-Object. Read a whole file with
Get-Content -Raw, which returns one string instead of
an array of lines:
'{"ticket":"INC-20419","priority":2,"tags":["outage","storm"],"site":{"city":"Utrecht"},"meters":[{"id":"M-1042","kwh":4521},{"id":"M-1043","kwh":312}]}' |
Set-Content ticket.json
$t = Get-Content -Path ticket.json -Raw | ConvertFrom-Json
"Ticket: $($t.ticket), priority $($t.priority) ($($t.priority.GetType().Name))"
"City: $($t.site.city)"
"Tags: $($t.tags -join ', ')"
"Meters: $($t.meters.id -join ', ')"
"Total: $(($t.meters | Measure-Object -Property kwh -Sum).Sum) kWh"
"Above 1000 kWh: $(($t.meters | Where-Object kwh -gt 1000).id)"
"Assignee: $($t.assignee ?? 'unassigned')"
Ticket: INC-20419, priority 2 (Int64) City: Utrecht Tags: outage, storm Meters: M-1042, M-1043 Total: 4833 kWh Above 1000 kWh: M-1042 Assignee: unassigned
Here the numbers really are numbers (Int64), so the
comparison kwh -gt 1000 is numeric, unlike with CSV
data. A missing property is $null, and the
null-coalescing operator ?? (PowerShell 7 and later)
supplies a default, like // in jq.
-AsHashtable returns hash tables instead of objects,
which is useful for keys that are not valid property names or that
differ only in case.
One conversion surprises people: ConvertFrom-Json turns
strings that look like ISO 8601 dates into
DateTime values. That is convenient for date
arithmetic, but a script that only passes the value on may change
its format. Since PowerShell 7.5,
-DateKind String keeps such values as text:
$json = '{"ticket":"INC-20419","opened":"2026-10-06T09:00:00Z"}'
$default = $json | ConvertFrom-Json
$asText = $json | ConvertFrom-Json -DateKind String
"Default: $($default.opened.GetType().Name)"
"-DateKind String: $($asText.opened.GetType().Name) '$($asText.opened)'"
$closed = [datetime]::Parse('2026-10-06T17:00:00Z').ToUniversalTime()
"Hours open until 17:00 UTC: $(($closed - $default.opened.ToUniversalTime()).TotalHours)"
Default: DateTime -DateKind String: String '2026-10-06T09:00:00Z' Hours open until 17:00 UTC: 8
ConvertFrom-Json is also more lenient than the JSON
standard: it accepts, for example, a trailing comma. That is fine
for reading, but do not take "PowerShell could read it" as proof
that another system will. Test-Json checks a string,
optionally against a JSON Schema.
ConvertTo-Json and serialization depth
ConvertTo-Json goes the other way and turns objects
into JSON text. Its
serialization depth, the
number of nested levels it writes out in full, is only 2 by default.
Anything deeper is replaced by the name of its type, and since
PowerShell 7.1 a warning says so. Pass -Depth whenever
data is nested (the maximum is 100):
$report = [ordered]@{
ticket = 'INC-20419'
site = [ordered]@{
city = 'Utrecht'
feeder = [ordered]@{
id = 'F-12'
cable = [ordered]@{ type = 'XLPE'; lengthKm = 3.2 }
}
}
}
$report | ConvertTo-Json -Compress
$report | ConvertTo-Json -Compress -Depth 5
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
{"ticket":"INC-20419","site":{"city":"Utrecht","feeder":{"id":"F-12","cable":"System.Collections.Specialized.OrderedDictionary"}}}
{"ticket":"INC-20419","site":{"city":"Utrecht","feeder":{"id":"F-12","cable":{"type":"XLPE","lengthKm":3.2}}}}
[ordered] keeps the keys in the order you wrote them; a
plain hash table would write them in an unpredictable order.
-Compress leaves out the indentation, which suits APIs
and log lines. jq has no such limit: it always writes the full
structure.
JSON Lines
JSON Lines (also called NDJSON,
newline-delimited JSON) is a format with one complete JSON value per
line. It combines the structure of JSON with the line orientation of
logs: a file can be appended to line by line, and read, filtered or
split with normal line tools. Many logging systems and bulk APIs use
it. jq -c writes it, and jq reads it
naturally because it processes one value after another;
-s (slurp) collects all values into one array when you
need totals:
cat > readings.jsonl <<'EOF'
{"meter":"M-1042","kwh":4521,"status":"ok"}
{"meter":"M-1043","kwh":312,"status":"estimated"}
{"meter":"M-1044","kwh":987,"status":"ok"}
EOF
jq -r 'select(.status != "ok") | .meter' readings.jsonl
jq -s 'map(.kwh) | add' readings.jsonl
echo "Back to one line per value:"
jq -c '{meter, kwh}' readings.jsonl
M-1043
5820
Back to one line per value:
{"meter":"M-1042","kwh":4521}
{"meter":"M-1043","kwh":312}
{"meter":"M-1044","kwh":987}
PowerShell reads the file line by line and converts each line separately:
@'
{"meter":"M-1042","kwh":4521,"status":"ok"}
{"meter":"M-1043","kwh":312,"status":"estimated"}
{"meter":"M-1044","kwh":987,"status":"ok"}
'@ | Set-Content readings.jsonl
$readings = Get-Content readings.jsonl | ForEach-Object { $_ | ConvertFrom-Json }
($readings | Where-Object status -ne 'ok').meter
($readings | Measure-Object -Property kwh -Sum).Sum
'Back to one line per value:'
$readings | ForEach-Object {
[ordered]@{ meter = $_.meter; kwh = $_.kwh } | ConvertTo-Json -Compress
}
M-1043
5820
Back to one line per value:
{"meter":"M-1042","kwh":4521}
{"meter":"M-1043","kwh":312}
{"meter":"M-1044","kwh":987}
CSV in both shells
Import-Csv is the PowerShell
cmdlet that reads a CSV file and returns one object per row, with
the header line as property names. It understands quoting, so a
field such as "Utrecht, Noord" stays one value. Every
property is a string, as
the pipeline page
shows; convert before you compare. Export-Csv writes
objects back; in PowerShell 7 it quotes every field unless you ask
for -UseQuotes AsNeeded, and it no longer writes the
#TYPE line that made
-NoTypeInformation necessary in Windows PowerShell 5.1:
@'
MeterId,Site,Kwh
M-1042,"Utrecht, Noord",4521
M-1044,Delft,987
'@ | Set-Content readings.csv
$rows = Import-Csv -Path readings.csv
"Rows: $($rows.Count), site of first row: '$($rows[0].Site)'"
"Kwh is a $($rows[0].Kwh.GetType().Name)"
$rows | Select-Object MeterId, @{ Name = 'MWh'; Expression = { [int]$_.Kwh / 1000 } } |
Export-Csv -Path mwh.csv
Get-Content mwh.csv
$rows | Export-Csv -Path asneeded.csv -UseQuotes AsNeeded
Get-Content asneeded.csv
Rows: 2, site of first row: 'Utrecht, Noord' Kwh is a String "MeterId","MWh" "M-1042","4.521" "M-1044","0.987" MeterId,Site,Kwh M-1042,"Utrecht, Noord",4521 M-1044,Delft,987
Bash has no CSV parser built in. awk -F, and
cut -d, work only for simple CSV without quoted fields;
on the file above they split "Utrecht, Noord" in two
and every later column shifts. When the data may contain quotes,
hand the parsing to a tool that knows CSV. Python's
csv module is installed on almost every Linux server:
cat > readings.csv <<'EOF'
MeterId,Site,Kwh
M-1042,"Utrecht, Noord",4521
M-1044,Delft,987
EOF
echo "awk -F, (wrong):"
awk -F, 'NR > 1 { print " " $1 " | " $2 " | " $3 }' readings.csv
echo "Python csv module (right):"
python3 -c '
import csv, sys
for row in csv.DictReader(open(sys.argv[1], newline="")):
print(" " + " | ".join([row["MeterId"], row["Site"], row["Kwh"]]))
' readings.csv
awk -F, (wrong): M-1042 | "Utrecht | Noord" M-1044 | Delft | 987 Python csv module (right): M-1042 | Utrecht, Noord | 4521 M-1044 | Delft | 987
Dedicated tools such as Miller (mlr) and csvkit do the
same from the command line, if you are allowed to install them. And
if you control the export, ask for JSON or tab-separated values
instead.
Calling web APIs
An
API
(application programming interface) over HTTP is how scripts talk to
ticketing systems, monitoring, cloud platforms and network
controllers. Bash scripts use
curl
for the request and jq for the response.
Invoke-RestMethod is the
PowerShell cmdlet that sends an HTTP request and converts a JSON (or
XML) response into objects in one step; its sibling
Invoke-WebRequest returns the raw response with status
code and headers.
The requests below need a network and a real token, so they are not run here; the outputs are illustrative. Tomasz Nowak from the NOC (network operations center) fetches the open outage tickets from the ticketing API, with the token in an environment variable (on a shared server, keep it off curl's command line too; see Keeping secrets out of scripts):
curl -fsS --max-time 30 --retry 3 \
-H "Authorization: Bearer $KESTREL_API_TOKEN" \
-H 'Accept: application/json' \
'https://api.kestrel.example/v1/tickets?status=open' |
jq -r '.items[] | "\(.ticket)\t\(.priority)\t\(.summary)"'
INC-20419 2 Storm damage Utrecht Noord INC-20437 3 Meter feed delay Delft
$headers = @{ Authorization = "Bearer $env:KESTREL_API_TOKEN" }
$response = Invoke-RestMethod -Uri 'https://api.kestrel.example/v1/tickets?status=open' `
-Headers $headers -TimeoutSec 30 -MaximumRetryCount 3 -RetryIntervalSec 5
$response.items | Select-Object ticket, priority, summary
ticket priority summary ------ -------- ------- INC-20419 2 Storm damage Utrecht Noord INC-20437 3 Meter feed delay Delft
What the curl options do, and their PowerShell counterparts:
| Purpose | curl | Invoke-RestMethod |
|---|---|---|
| Fail on HTTP 4xx/5xx |
-f (exit status 22);
--fail-with-body also shows the error body
|
Reports an error by default;
-SkipHttpErrorCheck to handle the status
yourself
|
| No progress meter, but show errors | -sS |
Not needed |
| Time limit | --max-time 30 |
-ConnectionTimeoutSeconds 30 (alias
-TimeoutSec;
-OperationTimeoutSeconds for stalled reads)
|
| Retries | --retry 3 (transient errors only) |
-MaximumRetryCount 3 -RetryIntervalSec 5
(retries every 4xx/5xx status, not only transient ones; a
429 with Retry-After uses that delay)
|
| Header | -H 'Name: value' |
-Headers @{ Name = 'value' } |
| HTTP status in a variable | -w '%{http_code}' |
-StatusCodeVariable code |
| Parse JSON response | Pipe to jq |
Automatic |
Sending data works the same way in reverse: build the body with a
JSON tool and send it with the right content type. With
-d @-, curl reads the body from standard input, so the
JSON never appears on the command line:
jq -n --arg ticket INC-20419 --arg note 'Crew on site 14:20' '{ticket: $ticket, note: $note}' |
curl -fsS --max-time 30 -X POST \
-H "Authorization: Bearer $KESTREL_API_TOKEN" \
-H 'Content-Type: application/json' \
-d @- 'https://api.kestrel.example/v1/notes'
$body = [ordered]@{ ticket = 'INC-20419'; note = 'Crew on site 14:20' } | ConvertTo-Json -Depth 5
try {
Invoke-RestMethod -Method Post -Uri 'https://api.kestrel.example/v1/notes' `
-Headers $headers -ContentType 'application/json' -Body $body -ErrorAction Stop
} catch {
Write-Error "Adding the note failed: $($_.Exception.Message)"
exit 1
}
Working from a recorded response
Develop and test the processing part against a saved response instead of the live API: you avoid rate limits, you can test edge cases (an empty list, a missing field) by editing the file, and the script's logic can be checked without a network. Here is the processing half of Tomasz's script in both shells, running on a recorded response that also contains a ticket without a priority:
cat > open-tickets.json <<'EOF'
{"items":[
{"ticket":"INC-20419","priority":2,"summary":"Storm damage Utrecht Noord"},
{"ticket":"INC-20437","priority":3,"summary":"Meter feed delay Delft"},
{"ticket":"INC-20440","summary":"Portal login errors"}
],"next":null}
EOF
jq -r '.items[] | [.ticket, (.priority // "none"), .summary] | @tsv' open-tickets.json |
while IFS=$'\t' read -r ticket priority summary; do
printf '%-10s P%-5s %s\n' "$ticket" "$priority" "$summary"
done
echo "Urgent (priority 1 or 2): $(jq '[.items[] | select((.priority // 9) <= 2)] | length' open-tickets.json)"
INC-20419 P2 Storm damage Utrecht Noord INC-20437 P3 Meter feed delay Delft INC-20440 Pnone Portal login errors Urgent (priority 1 or 2): 1
@'
{"items":[
{"ticket":"INC-20419","priority":2,"summary":"Storm damage Utrecht Noord"},
{"ticket":"INC-20437","priority":3,"summary":"Meter feed delay Delft"},
{"ticket":"INC-20440","summary":"Portal login errors"}
],"next":null}
'@ | Set-Content open-tickets.json
$response = Get-Content -Path open-tickets.json -Raw | ConvertFrom-Json
foreach ($item in $response.items) {
'{0,-10} P{1,-5} {2}' -f $item.ticket, ($item.priority ?? 'none'), $item.summary
}
"Urgent (priority 1 or 2): $(@($response.items | Where-Object { ($_.priority ?? 9) -le 2 }).Count)"
INC-20419 P2 Storm damage Utrecht Noord INC-20437 P3 Meter feed delay Delft INC-20440 Pnone Portal login errors Urgent (priority 1 or 2): 1
The "next":null field hints at
pagination: many APIs
return results in pages and give a link or cursor to the next one. A
script that reads only the first page silently misses data, so loop
until next is empty.
Calling APIs safely
- Keep secrets out of the script. Read the token from an environment variable, a file only your account can read, or a secrets manager (see keeping credentials safe and Writing Robust and Safe Scripts). Never commit it to Git, and never put it in a URL, where it ends up in proxy and server logs.
-
Mind the process list. Command-line arguments,
such as a token after
curl -H, can be visible to other users on the same machine while the command runs. On shared servers, let curl read headers from a protected file (-H @headers.txt) or a config file (-K). -
Never disable certificate checks with
curl -kor-SkipCertificateCheckoutside a lab. Fix the trust store instead; a script that ignores certificates will also talk to an attacker in the middle. -
Fail loudly. Use
curl -for check the status code, and wrapInvoke-RestMethodintry/catchwith-ErrorAction Stop(see Error Handling). A script that writes an HTML error page into a JSON file causes confusing failures later. - Be gentle. Set timeouts, retry only transient errors with a pause, respect rate limits (HTTP 429), and only retry requests that are safe to repeat (idempotence and retries).
- Do not log secrets or personal data. When you log a request for troubleshooting, log the URL path and status, not the headers or full body.
Side by side
| Task | Bash | PowerShell |
|---|---|---|
| Read a JSON file | jq '.' file.json |
Get-Content file.json -Raw | ConvertFrom-Json
|
| Nested value | jq -r '.site.city' |
$t.site.city |
| Filter an array | .meters[] | select(.kwh > 1000) |
$t.meters | Where-Object kwh -gt 1000 |
| Default for a missing value | .assignee // "unassigned" |
$t.assignee ?? 'unassigned' |
| Write JSON | jq -n --arg … |
ConvertTo-Json -Depth 5 |
| JSON Lines | jq -c, jq -s |
Get-Content | ForEach-Object { $_ | ConvertFrom-Json
}
|
| Read CSV |
Python csv, mlr;
awk -F, only for simple files
|
Import-Csv |
| Write CSV | jq -r '… | @csv' |
Export-Csv |
| Call an API | curl -fsS … | jq |
Invoke-RestMethod |