[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-jq-exit-codes-bash-scripts":3},{"content":4,"lastModified":5},"\u003Cp>The cron job that ruined my Tuesday did not fail. It exited 0, printed that the queue was idle, and restarted the worker on top of two jobs that were mid-flight.\u003C\u002Fp>\n\n\u003Cp>I wrote that script a year ago and had not opened it since. It polls our queue API for running jobs and restarts the worker when there are none. Tuesday there were two, and it restarted anyway. I spent the morning replaying a batch that should have taken twenty minutes.\u003C\u002Fp>\n\n\u003Cp>The culprit was a \u003Ccode>jq\u003C\u002Fcode> command. If you use jq to drive bash conditionals, the exit code is doing more work than you think.\u003C\u002Fp>\n\n\u003Ch2>What -e actually tests\u003C\u002Fh2>\n\n\u003Cp>jq on its own exits 0 whenever it managed to parse the input, no matter what it found. A missing key, a null, an empty array: all 0. That is the entire reason \u003Ccode>-e\u003C\u002Fcode> exists.\u003C\u002Fp>\n\n\u003Cp>The manual is brief about it. With \u003Ccode>-e\u003C\u002Fcode>, jq exits 0 if the last value it printed was neither false nor null, 1 if that value was false or null, and 4 if it printed nothing at all. The other codes are plumbing: 2 for a usage problem, 3 for a compile error in your filter, 5 for a runtime error like indexing a string.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">$ jq -e '.timeout' &lt;&lt;&lt; '{\"retries\":3}'; echo \"exit=$?\"\nnull\nexit=1\n\n$ jq -e '.[] | select(.state == \"running\")' &lt;&lt;&lt; '[{\"state\":\"queued\"}]'; echo \"exit=$?\"\nexit=4\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Four is the code people do not expect. A bare jq check that finds nothing exits 4, so under \u003Ccode>set -e\u003C\u002Fcode> bash stops your script right there. That was not my bug, but I see it in review constantly.\u003C\u002Fp>\n\n\u003Ch2>The pair of brackets that flips the answer\u003C\u002Fh2>\n\n\u003Cp>Here is my actual bug, reduced to its essentials.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">if jq -e '[.jobs[] | select(.state == \"running\")]' jobs.json &gt; \u002Fdev\u002Fnull; then\n  echo \"queue is idle, restarting the worker\"\nfi\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Wrapping a filter in \u003Ccode>[ ]\u003C\u002Fcode> makes jq collect the results into an array, and that array is a value. It is not false and it is not null, even when empty. So the exit code is 0, the \u003Ccode>if\u003C\u002Fcode> takes the true branch, and the script reports an idle queue with two jobs running.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">$ jq -e '[.jobs[] | select(.state == \"running\")]' jobs.json; echo \"exit=$?\"\n[]\nexit=0\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>I add brackets reflexively, because collected output is nicer to read than a run of separate objects. The bare version is the one that tests something. When I want a yes or no, \u003Ccode>jq -e 'any(.jobs[]; .state == \"running\")'\u003C\u002Fcode> prints true or false and sets the exit code to match.\u003C\u002Fp>\n\n\u003Cfigure>\n  \u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F17112932\u002Fpexels-photo-17112932.jpeg?auto=compress&cs=tinysrgb&w=1200\" alt=\"A two-monitor desktop setup with code open on the left screen and a backlit keyboard on a wooden desk\" loading=\"lazy\" \u002F>\n\u003C\u002Ffigure>\n\n\u003Ch2>Missing, null, and false\u003C\u002Fh2>\n\n\u003Cp>A key that does not exist and a key whose value is null both make jq print null, and both exit 1 under \u003Ccode>-e\u003C\u002Fcode>. To tell them apart, ask the object rather than the value.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">$ jq -e 'has(\"timeout\")' &lt;&lt;&lt; '{\"timeout\":null}'; echo \"exit=$?\"\ntrue\nexit=0\n\n$ jq -e '.timeout != null' &lt;&lt;&lt; '{\"timeout\":null}'; echo \"exit=$?\"\nfalse\nexit=1\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>I use \u003Ccode>has()\u003C\u002Fcode> for config validation, where a deleted key and an explicit null mean different things downstream.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>\u002F\u002F\u003C\u002Fcode> is the other half of this, and it is quietly dangerous. It produces the values on the left that are neither false nor null, which means a flag explicitly set to false counts as absent.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">$ jq -c '.enabled \u002F\u002F true' &lt;&lt;&lt; '{\"enabled\":false}'\ntrue\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Numbers and empty strings survive, so the bug is asymmetric. \u003Ccode>0 \u002F\u002F 5\u003C\u002Fcode> keeps the 0, and a boolean flag ends up true.\u003C\u002Fp>\n\n\u003Ch2>Reading jq output one item at a time\u003C\u002Fh2>\n\n\u003Cp>Most of my jq loops want raw strings. \u003Ccode>read -r\u003C\u002Fcode> protects spaces. It does not protect newlines, and one value containing a newline becomes two iterations.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">$ jq -r '.names[]' names.json\nweb 01\nline\nbreak\nplain\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Those are three names, not four. The middle one is a single string with a newline inside it. The \u003Ccode>for name in $(...)\u003C\u002Fcode> form is worse, since word splitting also breaks on the space in web 01.\u003C\u002Fp>\n\n\u003Cp>jq has a flag for this. \u003Ccode>--raw-output0\u003C\u002Fcode> writes a NUL byte after each value instead of a newline, and the manual says outright that it exists for values containing newlines.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">while IFS= read -r -d '' name; do\n  printf '[%s]\\n' \"$name\"\ndone &lt; &lt;(jq --raw-output0 '.names[]' names.json)\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>One caveat: if a value contains a NUL byte, jq gives up and exits non-zero. That is the right call, since a NUL cannot survive in a shell variable anyway. The same framing also lets you hand the items to \u003Ca href=\"\u002Fposts\u002Fxargs-p-parallel-shell-jobs\u002F\">xargs -0\u003C\u002Fa>.\u003C\u002Fp>\n\n\u003Ch2>Three ways I have misread the exit code\u003C\u002Fh2>\n\n\u003Cp>A check is only worth writing if the code reading it gets the status.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">jq -e '.jobs[] | select(.state == \"failed\")' jobs.json | wc -l\necho $?   # 0, and not jq's 0\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>A pipeline reports the status of its last command, so jq's 4 never reaches you. \u003Ccode>set -o pipefail\u003C\u002Fcode> fixes that, which is why \u003Ca href=\"\u002Fposts\u002Fbash-strict-mode-set-euo-pipefail\u002F\">strict mode\u003C\u002Fa> and jq checks turn up in the same script.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\">port=$(jq -e '.port' config.json)        # fails here under set -e\nlocal port=$(jq -e '.port' config.json)  # does not\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>local\u003C\u002Fcode> and \u003Ccode>declare\u003C\u002Fcode> are builtins. They run, they succeed, and their success overwrites the status of the substitution inside them. I have shipped that bug twice. Assign on one line, mark it local on the next.\u003C\u002Fp>\n\n\u003Cp>The third one comes from a habit. \u003Ccode>jq -e .\u003C\u002Fcode> is a bad JSON validator, because a valid document whose top level is \u003Ccode>false\u003C\u002Fcode> or \u003Ccode>null\u003C\u002Fcode> exits 1 and your validation step rejects good input. Use \u003Ccode>jq empty\u003C\u002Fcode>, which parses and prints nothing: 0 for valid input, 5 for anything else.\u003C\u002Fp>\n\n\u003Ch2>What I do now\u003C\u002Fh2>\n\n\u003Cp>jq gets asked questions in a form that has only one answer. \u003Ccode>any()\u003C\u002Fcode> instead of a bracketed array, \u003Ccode>has()\u003C\u002Fcode> when missing and null differ, \u003Ccode>--raw-output0\u003C\u002Fcode> when the values come from a system I do not control, and \u003Ccode>-e\u003C\u002Fcode> only where something reads the exit code.\u003C\u002Fp>\n\n\u003Cp>The wrapper I reach for most is four lines:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-bash\"># jq -er, with a message when the value is absent or null\nneed() {\n  jq -er \"$2\" \"$1\" || { echo \"config: $2 missing in $1\" &gt;&amp;2; return 1; }\n}\nport=$(need config.json '.port') || exit 1\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>It reads strictly, so a null port stops the deploy instead of arriving as the four-character string \"null\" in a connection string. Strict reading also rejects \u003Ccode>false\u003C\u002Fcode>, which is what I want for ports and exactly wrong for boolean flags.\u003C\u002Fp>\n\n\u003Cp>The queue script now uses \u003Ccode>any(...)\u003C\u002Fcode>, and I left a comment explaining why there are no brackets in it. Past me is precisely the person who would add them back. The jq recipes I stop retyping live in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa>.\u003C\u002Fp>\n","2026-10-04",1791168103938]