skip to content

Control Flow and Loops

How a script decides and repeats: exit-status truthiness, the test constructs, integer arithmetic, case dispatch, and loops. Bash conditionals run commands rather than evaluate expressions, and that mental switch is the thing interviewers most want to watch you make.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

25

In bash, what does `echo $(( 10 / 3 ))` print, and how do you compute a percentage or an average with a decimal point inside a bash script?

level: juniorimportance: must knowfreq 58%

answer

  1. no decimal point exists here
  2. truncates, and toward zero
  3. multiply before you divide
  4. delegate to awk or bc
  5. printf formats but cannot divide

basics

~20 s

It prints 3. Bash arithmetic is integer-only, so division truncates toward zero and there is no fractional result. For decimals you call out to another tool — awk or bc — or scale the integers up before dividing and format the result with printf.

solid answer

~50 s

Bash has no floating-point arithmetic at all; every value in `$(( ))` is an integer, so `10 / 3` truncates to 3 and `1 / 2` is 0. Truncation is toward zero, so `$(( -7 / 2 ))` is -3 rather than -4, and `%` follows to match. If you actually need decimals, you delegate: `awk 'BEGIN { printf "%.2f\n", 10/3 }'` is the most portable choice since awk is everywhere, and `echo "scale=2; 10/3" | bc` works when bc is installed. A third option keeps everything in bash: scale up first, as in `$(( part * 100 / total ))` for an integer percentage, and add half the divisor before dividing if you want rounding instead of truncation. Note that bash's `printf` can *format* a float you were given, but it cannot compute one.

code

bash · 6 lines
bash
#!/usr/bin/env bash
passed=7 total=9
echo "integer:  $(( 100 * passed / total ))%"          # 77
echo "rounded:  $(( (100 * passed + total / 2) / total ))%"  # 78
awk -v p="$passed" -v t="$total" 'BEGIN { printf "awk:      %.2f%%\n", 100 * p / t }'
printf '%s\n' "scale=2; 100 * $passed / $total" | bc

go deeper

for a junior

Say clearly that bash does integer math only, so $(( 10 / 3 )) is 3, and name awk or bc as the way to get a decimal. Know that a literal like 1.5 is a syntax error inside $(( )).

for a middle

Explain truncation toward zero, the sign of %, and the multiply-before-divide rule for percentages. Be ready to show the scaled-integer trick that keeps a script dependency-free.

for a senior

Reason about the environment: bc is often missing from slim images, awk is not, and silent 64-bit overflow can corrupt byte-count math. Show how you would pass values into awk safely with -v.

for a principal

Frame the boundary decision — at what point numeric requirements, rounding rules or precision mean the task should leave the shell entirely rather than accumulate fixed-point workarounds in a script others must maintain.

## Bash arithmetic is integers, full stop Every value the shell's arithmetic evaluator handles is a signed integer, in practice 64-bit. There is no float type, no automatic promotion, and no error when precision is lost — the fractional part is simply dropped. ```bash echo $(( 10 / 3 )) # 3 echo $(( 1 / 2 )) # 0 echo $(( 7 / 8 )) # 0 ``` Writing a literal with a decimal point is not a workaround; it is a syntax error in the arithmetic evaluator: ```bash echo $(( 1.5 + 1 )) # bash: 1.5 + 1: syntax error: invalid arithmetic operator ``` This is a bash-specific limitation rather than a shell-wide one — ksh93 and zsh do support floating-point arithmetic — but a script written for bash cannot rely on that. ## Truncation, not rounding, and it goes toward zero Division truncates rather than rounding: `$(( 9 / 10 ))` is 0, not 1. For negative operands bash follows C99 and truncates **toward zero**, which is worth stating precisely because floor division would give a different answer: ```bash echo $(( -7 / 2 )) # -3 (toward zero, not -4) echo $(( -7 % 2 )) # -1 (sign follows the dividend) ``` The remainder operator is defined so that `(a / b) * b + (a % b) == a`, which is why `%` can return a negative number — a real surprise if you were using it to index into an array or bucket a hash. Overflow is equally silent. Exceed the 64-bit range and the value wraps around with no diagnostic, so a script multiplying byte counts can produce a confidently wrong negative number. ## Three ways to get a decimal **awk** — the most portable, since awk is mandated by POSIX and present on essentially every system including minimal containers: ```bash awk -v p="$passed" -v t="$total" 'BEGIN { printf "%.1f%%\n", 100 * p / t }' ``` Passing values with `-v` rather than interpolating them into the program text also keeps script variables out of awk's source, which matters when the values are not fully trusted. **bc** — a calculator built for this, but note that it is *not* installed by default on many slim images, and that `scale` must be set explicitly or it also truncates: ```bash echo "scale=2; 100 * $passed / $total" | bc printf '%s\n' "scale=4; 22/7" | bc ``` Without `scale=2`, `echo "10/3" | bc` prints 3 — the same truncation you were trying to escape. `bc -l` loads the math library and defaults scale to 20. **Scaled integers inside bash** — often the best answer when you only need one or two decimal places, because it adds no dependency: ```bash pct=$(( 100 * passed / total )) # whole-number percent, truncated rounded=$(( (100 * passed + total / 2) / total )) # add half the divisor to round tenths=$(( 1000 * passed / total )) # then insert the point when printing printf '%d.%01d%%\n' $(( tenths / 10 )) $(( tenths % 10 )) ``` Multiply **before** you divide — `$(( 100 * passed / total ))` is right, `$(( 100 * (passed / total) ))` is always 0 or 100 because the inner division truncates first. That ordering mistake is the single most common bug in shell percentage math. ## printf formats, it does not compute Bash's `printf` builtin understands `%f` and friends, so `printf '%.2f\n' 3.14159` prints 3.14. It is a formatter: it cannot divide. `printf '%.2f\n' "$(( 10 / 3 ))"` prints 3.00, because the truncation already happened in the arithmetic before printf ever saw the value. `printf` is also how you zero-pad an integer (`printf -v month '%02d' "$m"`) and how you store the result in a variable with `-v` instead of forking a command substitution. ## When to stop If a script is doing enough numeric work that you are threading values through awk repeatedly, that is a signal the job has outgrown the shell. Bash is glue for processes; a task whose core is arithmetic — statistics, currency, anything where rounding rules are part of the requirement — is cheaper and safer in awk itself, Python, or a real program. Reaching for that judgment early is a better answer than an elaborate fixed-point implementation in shell.

  • What does `$(( -7 / 2 ))` evaluate to in bash, and why is that worth knowing?
    It is -3, not -4: bash follows C and truncates toward zero rather than flooring. The remainder matches, so `$(( -7 % 2 ))` is -1. It matters whenever you use `%` to bucket or index, because a negative remainder can produce an out-of-range index from what looks like safe modulo arithmetic.
  • Why is `$(( 100 * (passed / total) ))` almost always wrong?
    The inner division runs first in integer arithmetic, so unless passed equals or exceeds total it truncates to 0 and the whole expression is 0. Multiplying before dividing — `$(( 100 * passed / total ))` — keeps the precision you need. It is the classic ordering bug in shell percentage math.
  • Between awk and bc for decimal math in a script, which would you default to and why?
    awk, because POSIX mandates it and it is present on effectively every system including slim container images, while bc is frequently absent. awk also takes values through `-v` instead of string-interpolated program text, and it formats in the same step with printf. bc is fine when you know it is installed and want `scale` semantics.
  • Can bash's printf be used to divide two numbers?
    No. `printf` only formats values it is given, so `printf '%.2f\n' "$(( 10 / 3 ))"` prints 3.00 — the truncation already happened in the arithmetic. printf is still useful for zero-padding integers and, with `-v`, for assigning a formatted string without a subshell.

saying these in an interview costs you the question

  • Expecting $(( 10 / 3 )) to print 3.33
  • Thinking printf can perform the division
  • Assuming bc is installed everywhere
  • Dividing before multiplying in a percentage
  • Believing division rounds instead of truncating

context

open as a page

In a bash script you write `case "$1" in start|stop) A ;; *.log) B ;; *) C ;; esac`. How does bash compare the word to each pattern, and what happens when more than one pattern could match?

level: juniorimportance: must knowfreq 65%

basics

~20 s

Bash compares the word against each pattern using shell glob rules, not regular expressions, and a pattern must match the whole word. Clauses are tested top to bottom and the first match wins; every later clause is skipped.

open as a page

In bash, what value does the special parameter `$?` hold, which value means success, and why does inserting an `echo` between a command and its `$?` check break the check?

level: juniorimportance: must knowfreq 82%

basics

~20 s

In bash, $? holds the exit status of the most recently completed foreground command: 0 means success and 1-255 mean some kind of failure. Every command resets it, including echo, so an intervening command overwrites the status you meant to test.

open as a page

In a bash script, what do the test operators -e, -f, -d, -s and -r check about a path, and why can `[ -e "$p" ]` be true while `[ -f "$p" ]` is false for the same path?

level: juniorimportance: must knowfreq 74%

basics

~20 s

In bash, -e means the path exists at all, -f means it is a regular file, -d means it is a directory, -s means it exists with a size above zero, and -r means the current user can read it. A directory exists, so -e is true and -f is false.

open as a page

In bash, what is the difference between `$(( ))` and `(( ))`, and what does each one produce?

level: middleimportance: must knowfreq 68%

basics

~20 s

$(( )) is arithmetic expansion: it substitutes the computed integer into the command line. (( )) is an arithmetic command: it evaluates the same expression, prints nothing, and returns exit status 0 when the result is non-zero and 1 when it is zero.

open as a page

In bash, what does `if` actually evaluate — for example in `if grep -q ERROR app.log; then ...` — and what does the `!` do in `if ! grep -q ERROR app.log`?

level: middleimportance: must knowfreq 68%

basics

~20 s

Bash's if runs a command and branches on its exit status: status 0 takes the then branch, anything else takes elif/else. There is no boolean expression type. A leading ! inverts that status, so the then branch runs when the command fails.

open as a page

In a bash script, `count=0; find . -name '*.log' | while read -r f; do count=$((count+1)); done; echo "$count"` prints 0 no matter how many files matched. Why is the count lost, and what are the two standard ways to restructure the loop so it survives?

level: middleimportance: must knowfreq 72%

basics

~20 s

Each stage of a bash pipeline runs in its own process, so the loop increments count inside a child shell that exits when the pipeline ends. Feed the loop from a redirection or a process substitution instead of a pipe.

open as a page

The canonical bash idiom for reading a file line by line is `while IFS= read -r line; do ...; done < file.txt`. What does each of `IFS=`, `-r` and the redirection after `done` contribute, and what breaks if you leave them out?

level: middleimportance: must knowfreq 66%

basics

~20 s

IFS= stops read stripping leading and trailing whitespace from the line, -r stops it treating backslashes as escapes, and the redirection after done feeds the whole loop from the file so it stays in the current shell.

open as a page

A bash script contains `if [ $answer = yes ]; then`. It works in testing but in production fails with `[: =: unary operator expected` or `[: too many arguments`. What is `[` really, why do those errors happen, and how does `[[ $answer = yes ]]` behave differently?

level: middleimportance: must knowfreq 82%

basics

~20 s

In bash, [ is an ordinary command, not syntax: the shell word-splits and globs its arguments first, so an empty or multi-word $answer changes the argument count and test reports a usage error. [[ is a shell keyword, so the expansion stays one word and needs no quotes.

open as a page

A bash cleanup script uses `for f in $(ls *.log); do rm -- "$f"; done` and fails with "No such file or directory" on a file named `app server.log`. Why does the loop break, and what is the correct way to iterate over those files?

level: juniorimportance: should knowfreq 60%

basics

~20 s

Command substitution output is split on whitespace, so a filename containing a space becomes two loop items. Drop ls entirely and let the glob supply the list: for f in *.log, which yields one item per file no matter what the names contain.

open as a page

In bash, what does declaring a variable with `declare -i` change about later assignments to it, and how does that compare with a plain assignment using `$(( ))`?

level: middleimportance: should knowfreq 30%

basics

~20 s

declare -i marks a variable as integer, so every later assignment to it is evaluated as an arithmetic expression rather than stored as text. Non-numeric input silently becomes 0, and += means addition instead of string concatenation. A plain assignment with $(( )) makes that evaluation visible at each use site.

open as a page

In bash you write `case $answer in y|Y) confirm ;; esac`, leaving the word unquoted. Which expansions does bash apply to that word before matching, and what changes if you quote a pattern instead, as in `"*")`?

level: middleimportance: should knowfreq 42%

basics

~20 s

Bash applies tilde, parameter, arithmetic and command-substitution expansion plus quote removal to the case word, but never word splitting or pathname expansion — so an unquoted word is safe even with spaces. Quoting a pattern makes its wildcards literal.

open as a page

In bash, why is `cmd1 && cmd2 || cmd3` not a safe replacement for if/then/else, and in what case does cmd3 run even though cmd1 succeeded?

level: middleimportance: should knowfreq 46%

basics

~20 s

In bash, && and || have equal precedence and associate left to right, so cmd1 && cmd2 || cmd3 means (cmd1 && cmd2) || cmd3. If cmd1 succeeds but cmd2 returns non-zero, cmd3 still runs — an if/then/else can never do that.

open as a page

In a bash script, `n=5; for i in {1..$n}; do echo "$i"; done` prints `{1..5}` instead of the numbers 1 through 5. Why does that happen, and which loop forms do iterate a variable number of times?

level: middleimportance: should knowfreq 52%

basics

~20 s

Brace expansion runs before parameter expansion, so bash sees {1..$n}, does not recognise it as a numeric range, and leaves it as text that later becomes {1..5}. Use a C-style loop, for ((i=1; i<=n; i++)), or iterate over seq output.

open as a page

With a=5 and b=10 in a bash script, what do `[ "$a" -gt "$b" ]`, `[ "$a" > "$b" ]` and `[[ $a > $b ]]` each do, and which of them silently creates a file?

level: middleimportance: should knowfreq 58%

basics

~20 s

-gt compares integers, so it is false. Inside [ ], > is not an operator: the shell reads it as output redirection, creates a file named 10, and tests the single argument 5, which is true. Inside [[ ]], > compares strings, so 5 beats 10.

open as a page

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%

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.

open as a page

A CI deploy script dispatches on its first argument with `case "$1" in start) do_start ;; stop) do_stop ;; esac` and then exits. Someone runs `./deploy statr`: nothing is printed, the exit status is 0, and the pipeline reports success. Why does bash behave that way, and how would you harden the dispatcher?

level: seniorimportance: should knowfreq 50%

basics

~20 s

A bash case whose word matches no pattern runs nothing and returns exit status 0, so an unrecognised subcommand is indistinguishable from success. Harden it with a final catch-all clause that prints usage to stderr and exits non-zero.

open as a page

A CI step running a bash script reports exit code 127 on one host, 126 on another, and 137 on a third. What does each of those exit codes tell you?

level: seniorimportance: should knowfreq 55%

basics

~20 s

In bash, 127 means the command was not found, 126 means it was found but could not be executed, and 137 is 128+9 — the process was terminated by signal 9, typically an out-of-memory or forced kill. All three come from the shell, not from the program's own error codes.

open as a page

A bash script runs `while read -r host; do ssh "$host" uptime; done < hosts.txt` but only ever connects to the first host in the file. What is consuming the rest of the input, and how do you fix the loop?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The loop body inherits the loop's standard input, and ssh reads standard input to forward it to the remote command — so the first ssh drains hosts.txt. Run ssh with -n, or redirect its input from /dev/null.

open as a page

A team standardises on `[[ ]]` for every bash script and stops requiring quotes inside conditionals, on the grounds that `[[ ]]` makes quoting unnecessary. Which failure modes does that genuinely remove, and where does dropping the quotes still bite?

level: seniorimportance: should knowfreq 40%

basics

~20 s

[[ ]] stops word splitting and globbing of its operands, so unquoted file and string tests really are safe. It does not make the right side of ==, != or =~ literal, does not validate the operands of -eq, and the unquoted habit breaks the moment you leave the brackets.

open as a page

In a bash script, a `for` loop is nested inside a `while` loop. What do `break 2` and `continue 2` do in the inner body, and what does a plain `break` inside a `case` block within a loop affect?

level: juniorimportance: nice to knowfreq 30%

basics

~20 s

break 2 exits two levels of enclosing loop, so both the for and the while; continue 2 abandons the current inner iteration and starts the next iteration of the outer loop. A break inside case affects the enclosing loop, because case is not a loop.

open as a page

A bash backup script sets `month=$(date +%m)` and then computes `$(( month + 1 ))`. It works most of the year but in August fails with `value too great for base (error token is "08")`. What happened, and how do you fix it?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

A number with a leading zero is octal in bash arithmetic, so 08 is invalid — octal has no digit 8. Force base 10 with the base#number syntax, writing $(( 10#$month + 1 )), or strip the leading zero before doing the math.

open as a page

In bash 4, what is the difference between ending a `case` clause with `;;`, with `;&`, and with `;;&`?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

In bash, ;; ends the case as soon as a clause runs. ;& falls through and runs the next clause's body without testing its pattern. ;;& keeps testing the remaining patterns and runs every later clause that also matches.

open as a page

Inside bash's `[[ ]]`, how do the `==` and `=~` operators differ, and what changes when you quote the right-hand side, as in `[[ $v == "$p" ]]`?

level: middleimportance: nice to knowfreq 40%

basics

~20 s

In bash's [[ ]], == matches the left value against the right side as a shell pattern, while =~ matches it against an extended regular expression and fills the BASH_REMATCH array. Quoting the right side makes it a literal string, so neither kind of matching happens.

open as a page

A bash deploy script calls `run_migration`, then finishes with `echo "migration done"`, and the CI job reports success even when the migration fails. Why does the script exit 0, and how do you make it report the real status?

level: seniorimportance: nice to knowfreq 34%

basics

~20 s

A bash script's exit status is the status of the last command it ran, and here that is the successful echo, which masks the migration failure. Capture the status immediately with rc=$? and end with exit "$rc", or fail early on the migration itself.

open as a page