Skip to content

Changing JSON

Everything so far has read JSON. This chapter writes it — or rather, it produces changed JSON, because of the fact that shapes everything here:

jq never edits a file in place. It reads JSON and prints JSON. Making the change stick is a separate, deliberate step, and the step has a right way and a catastrophic way, both shown below.

The workbench is settings.json, a small service config: retries 3, a maximum of 5, notifications off, two targets.

Assignment: =

The filter .retries = 5 outputs the whole document with that one value replaced:

$ jq '.retries = 5' settings.json
{
  "max_retries": 5,
  "notify": false,
  "retries": 5,
  "schedule": {
    "hour": 2,
    "minute": 30
  },
  "service": "backup",
  "targets": [
    "atlas-01",
    "birch-02"
  ]
}

Assignment always returns the entire modified document — that's jq's only way to report a change. And the file itself is untouched, which the very next command proves:

$ jq '.retries' settings.json
3

Still 3 on disk. Nothing is written until you redirect output somewhere (the ritual, below).

The right side of = is a filter evaluated against the document — so it can pull a value from elsewhere in the same data. "Set retries to the configured maximum":

$ jq '.retries = .max_retries' settings.json
{
  "max_retries": 5,
  "notify": false,
  "retries": 5,
  "schedule": {
    "hour": 2,
    "minute": 30
  },
  "service": "backup",
  "targets": [
    "atlas-01",
    "birch-02"
  ]
}

Same shape as the literal 5, arrived at by reference. Assignment creates keys that don't exist yet, too: .owner = "ops" would add the key. Deeply nested targets work the same way; the left side is just a path:

$ jq '.models[0].details.context_length = 65536 | .models[0].details.context_length' models.json
65536

(The trailing path selects the changed value back out of the whole-document result, so the effect is visible without printing five models.)

Update-assignment: |=, and why it isn't =

|= looks similar and means something different: "transform the value at this path, using a filter that receives the old value as its input." The everyday use is adjusting a value in terms of itself:

$ jq '.retries |= . + 1' settings.json
{
  "max_retries": 5,
  "notify": false,
  "retries": 4,
  "schedule": {
    "hour": 2,
    "minute": 30
  },
  "service": "backup",
  "targets": [
    "atlas-01",
    "birch-02"
  ]
}

Inside |=, . is the old value at the path — the number 3 — not the document. Which means the = trick from the last section doesn't merely behave differently under |=, it fails outright:

$ jq '.retries |= .max_retries' settings.json
jq: error (at settings.json:14): Cannot index number with string "max_retries"

Read the error with the rule in hand and it's exact: the filter tried .max_retries on the number 3. So the choice between them is a question about where the new value comes from:

New value comes from… Use
anywhere in the document .path = …
the old value at the path .path |= …

A memory hook: = replaces, |= pipes the old value through — it's the assignment form of the | you've been using all along.

Deleting: del

del(path) removes a key, or an array element:

$ jq 'del(.notify)' settings.json
{
  "max_retries": 5,
  "retries": 3,
  "schedule": {
    "hour": 2,
    "minute": 30
  },
  "service": "backup",
  "targets": [
    "atlas-01",
    "birch-02"
  ]
}

$ jq 'del(.targets[0])' settings.json
{
  "max_retries": 5,
  "notify": false,
  "retries": 3,
  "schedule": {
    "hour": 2,
    "minute": 30
  },
  "service": "backup",
  "targets": [
    "birch-02"
  ]
}

Deleting an array element renumbers what follows — birch-02 was index 1, and is the whole array now. del composes like everything else: several paths, a select before it, a reshape after it.

Paths also exist as data. The paths builtin lists every path in a document — the map of what assignment and del can address:

$ jq -r 'paths | join(".")' settings.json
max_retries
notify
retries
schedule
schedule.hour
schedule.minute
service
targets
targets.0
targets.1

The ritual: how a change reaches the file

Now the step jq won't do for you. The wrong way first, demonstrated on a throwaway copy:

$ cp settings.json scratch.json
$ jq '.retries = 9' scratch.json > scratch.json
$ wc -c scratch.json
0 scratch.json

The file is empty, the command "succeeded," and no error was printed. The reason is the shell, not jq: > scratch.json truncates the file before jq starts, so jq reads an empty file, prints nothing, and the redirect writes that nothing over your data. This is the single most destructive habit available with jq, and it is one redirect away at all times.

The right way writes to a new name and renames over the original, only if jq succeeded:

$ jq '.retries = 5' settings.json > settings.new.json && mv settings.new.json settings.json
$ jq '.retries' settings.json
5

(rename the file first, run the commands, and your copy of settings.json now has retries 5 — every later chapter assumes the original, so change it back with the same ritual, = 3, if you follow along from here.)

The && matters: if jq fails — a typo'd filter, a broken input file — the mv never runs and the original survives. Two habits follow from all of this: never redirect jq's output onto its input, ever; and keep the original until the replacement has been read back and believed, which the rename ritual gives you for free.