skip to content

A bash script running under `set -euo pipefail` counts matches with `((count++))` inside a loop, and it exits silently on the very first iteration with count starting at 0. Why does that line abort the script?

level: seniorimportance: should knowfreq 46%

answer

  1. the increment did happen
  2. postfix yields the old value
  3. zero value, failing status
  4. only breaks at exactly one number
  5. status of an assignment is boring

basics

~20 s

The post-increment count++ evaluates to the value before the increment, which is 0. A zero result makes the (( )) command exit with status 1, and a non-zero status on a bare command aborts the script. The variable was incremented; only the status looked like failure.

solid answer

~40 s

`(( ))` reports an exit status derived from the expression's value: non-zero gives 0, zero gives 1. `count++` is *post*-increment, so its value is what `count` held **before** the bump — 0 on the first pass. So the increment succeeds, `count` becomes 1, and the command still exits 1, which is enough to end the script. The tell is that it only ever happens on the iteration where the old value is zero, which makes it look intermittent. Fixes, in rough order of preference: `count=$(( count + 1 ))`, whose status is the assignment's and always 0; `((count++)) || true` to explicitly neutralise the status; or `((++count))`, which yields the new value 1 — correct here, but still fails if the new value happens to be zero, as when incrementing from -1.

code

bash · 7 lines
bash
#!/usr/bin/env bash
set -euo pipefail
count=0
((count++)) || true      # neutralise the status explicitly
((++count))              # pre-increment: value is 2, status 0
count=$(( count + 1 ))   # safest: status comes from the assignment
echo "count is $count"   # count is 3

go deeper

for a junior

Know that ((count++)) returns the value count had before the bump, and that a zero value means a failing exit status. Remember count=$(( count + 1 )) as the safe way to add one.

for a middle

Explain the three rules that combine: postfix yields the old value, (( )) inverts a zero value into status 1, and a failing bare command ends a set -e script. Show that the variable really was incremented.

for a senior

Diagnose it from symptoms — a silent exit on exactly one iteration, a trace that stops on the increment — and prescribe the assignment rewrite while explaining why pre-increment only relocates the problem.

for a principal

Own the convention: decide whether your codebase bans bare (( )) statements outright, how reviewers spot expressions that can reach zero, and where strict mode's benefits still outweigh footguns like this one.

## The three facts that combine into the bug Nothing here is exotic; the bug is the intersection of three individually reasonable rules. **1. `(( ))` returns a status, not a value.** The arithmetic command evaluates its expression, discards the number, and exits 0 when the result is non-zero and 1 when the result is zero. That inversion exists so `if (( a > b ))` reads naturally: C-true becomes shell-success. **2. `count++` is post-increment.** Like C, bash's `++` in postfix position increments the variable but *evaluates to the old value*. `count--` behaves the same way in the other direction. Prefix `++count` increments first and evaluates to the new value. **3. A failing command at the top level of a strict-mode script ends it.** The `-e` in `set -euo pipefail` makes bash exit when a simple or compound command returns non-zero outside a tested context. Put them together with `count=0`: ```bash count=0 ((count++)) # count becomes 1; expression value is the OLD 0; status 1 -> script exits ``` The increment genuinely happened. If you inspect `count` in a debugger or after removing `-e`, it is 1. Only the status lied. ## Why it presents as a ghost The failure fires exactly once — on the pass where the pre-increment value is zero — and then never again, because from 1 onward the old value is non-zero and the status is 0. So a script that processes a thousand files dies immediately on the first one, or, if the counter is initialised somewhere non-obvious, dies only on the runs where the loop body is entered before anything else has bumped it. Add a debug `echo` and the behaviour appears to change, because the echo returns 0 and, depending on where you put it, may become the last command in the chain. The same trap is not limited to `++`. Any `(( ))` whose expression happens to evaluate to zero exits 1: ```bash (( total -= total )) # value 0 -> status 1 (( flag = 0 )) # a perfectly successful assignment -> status 1 (( found = matches )) # dies whenever matches is 0 let count++ # `let` has exactly the same status rule ``` ## The repair options, and their tradeoffs ```bash count=$(( count + 1 )) # status belongs to the assignment: always 0 ((count++)) || true # keeps ++, makes the failure explicit and harmless ((count++)) || : # same idea with the : builtin : $(( count++ )) # expansion feeds the no-op builtin, which exits 0 ((++count)) # pre-increment: value 1 here, so status 0 ``` `count=$(( count + 1 ))` is the one to reach for in reviewed code. It is boring, it never carries a surprising status, and a reader does not have to reason about prefix versus postfix. `|| true` is fine and self-documenting when you want to keep the terse `++` idiom, though it also swallows a genuine arithmetic error such as a malformed expression. `((++count))` is the trap people most often recommend and the one that fails again later: pre-increment only moves the problem, because the new value can also be zero — `count=-1; ((++count))` exits 1 just as surely. ## Reviewing for it Treat any bare `(( ))` at statement level in a strict-mode script as a question during review: *can this expression evaluate to zero?* If it can, either the status is neutralised or the line is rewritten as an assignment. Counters, flag assignments, subtractions that can cancel, and bit-clearing operations are the usual candidates. Arithmetic used purely as a condition — inside `if`, `while`, a `&&` chain, or a C-style `for` header — is never at risk, because there the status is being consumed as a decision rather than treated as success or failure. ShellCheck will not flag the counter case for you; it is a semantic hazard, not a syntax one, so it lives in your review checklist rather than in the linter. ## What to say in an interview Name the mechanism before the fix: post-increment yields the old value, `(( ))` maps a zero value to exit status 1, and strict mode acts on that status. Then give the assignment rewrite as the default and mention `|| true` as the terse alternative. Volunteering that `((++count))` is only a partial fix is the detail that separates someone who has debugged this from someone who has read about it.

  • Why is `((++count))` only a partial fix rather than a general one?
    Pre-increment evaluates to the *new* value, so it is safe when counting up from zero. It still exits 1 whenever the new value is zero — incrementing from -1, or any expression that lands on zero. It fixes this instance of the trap, not the class. An assignment such as `count=$(( count + 1 ))` removes the hazard entirely.
  • Does the same hazard exist for a `(( ))` used as an `if` or `while` condition?
    No. In a tested context the status is being consumed as a decision, which is exactly what the construct is for, so `while (( n > 0 ))` and `if (( count )); then` are safe regardless of the value. The hazard is specific to a bare arithmetic command at statement level, where a zero result reads as a failed command.
  • Would `let count++` behave any differently here?
    No — `let` runs the identical evaluator and follows the same rule: it returns 1 when the last expression evaluates to zero. `let count++` at zero aborts a strict-mode script exactly like `((count++))`. The two forms differ in quoting ergonomics, not in status semantics, which is why `(( ))` is the recommended modern spelling.
  • How would you find this in a script you did not write, given the failure is silent?
    Run it with `bash -x` to see the last command executed before the exit, and check the shell's exit status. Trace output ends on the offending `(( ))` line while the variable shows an updated value — the signature of the trap. Then scan for bare `(( ))` statements whose expression can reach zero.

saying these in an interview costs you the question

  • Claiming the increment never happened
  • Saying (( )) failed because count was undefined
  • Recommending ++count as a complete fix
  • Blaming pipefail rather than the arithmetic status
  • Assuming ShellCheck would have caught it

context