Conditionals and Tests · about 35 min

Arithmetic with (( ))

Why this matters

Scripts count things constantly: retries left, files processed, disk usage percentages, error lines in a log. You met $(( )) in module 7 for computing a value. This lesson adds its sibling (( )), which turns arithmetic into a condition, so you can write if (( errors > 10 )) instead of the clumsier if [ "$errors" -gt 10 ]. You will also learn the two classic traps of shell arithmetic: zero means "false", and leading zeros mean octal.

Two forms, two jobs

$(( expression ))   arithmetic EXPANSION: replaced by the result, as text
(( expression ))    arithmetic COMMAND:   produces no text, sets an exit status
$ echo $(( 7 * 6 ))
42
$ (( 7 * 6 )); echo $?
0
$ (( 7 - 7 )); echo $?
1

The rule for the command form: status 0 if the value is non-zero, 1 if it is zero. This is the reverse of exit statuses, on purpose: it makes (( )) read like a condition in C or Python, where 0 is false and anything else is true. Comparisons yield 1 for true and 0 for false, so (( a > b )) succeeds exactly when a is greater.

Like [[, (( )) is a keyword: bash parses the inside itself. That is why < and > mean "less than" and "greater than" here rather than redirection, and why you can drop the $ before variable names.

The operators

Inside (( )) and $(( )) you have C's integer operators:

OperatorsMeaning
+ - * / %add, subtract, multiply, divide (truncates toward zero), remainder
**power
< <= > >= == !=comparisons (1 or 0)
&& || !logical and, or, not
= += -= *= /= %=assignment
++ --increment, decrement (before or after the name)
cond ? a : bchoose a or b
( )grouping
$ x=17
$ echo $(( x / 5 )) $(( x % 5 )) $(( x ** 2 ))
3 2 289
$ (( x > 10 && x < 20 )) && echo "teen"
teen
$ (( x += 3 )); echo $x
20
$ echo $(( x > 15 ? 1 : 0 ))
1

Note x / 5 is 3, not 3.4: bash arithmetic is integer-only, using 64-bit signed numbers (up to about 9.2 quintillion; beyond that it silently wraps around). For decimals, use bc or awk: echo 'scale=2; 17/5' | bc prints 3.40.

Names inside (( )) are variables even without $. An unset or empty variable counts as 0, and a variable holding another name is evaluated recursively. Writing $x also works; the difference only shows with odd values, and the plain name is the usual style.

if with (( ))

#!/bin/bash
# disk.sh: warn when usage of / exceeds a limit
limit=${1:-80}
used=$(df --output=pcent / | tail -n 1 | tr -dc '0-9')
if (( used >= limit )); then
    echo "WARNING: / is ${used}% full (limit ${limit}%)"
    exit 1
fi
echo "ok: / is ${used}% full"
$ bash disk.sh
ok: / is 41% full
$ bash disk.sh 30
WARNING: / is 41% full (limit 30%)

df --output=pcent / prints a header and a line like 41%; tail -n 1 keeps the number line and tr -dc '0-9' deletes every character that is not a digit (-d delete, -c complement). Your percentage will differ.

Validating input before arithmetic

Arithmetic on garbage does surprising things. A non-number like abc is taken as a variable name (usually unset, so 0), and 3x is a syntax error:

$ n=abc; (( n > 5 )) && echo big || echo small
small
$ n=3x; (( n > 5 ))
bash: ((: 3x: value too great for base (error token is "3x")

Worse, because bash evaluates names inside arithmetic, feeding user input straight into (( )) can even run commands in crafted cases. So check that a value is a number first. A glob pattern inside [[ ]] is enough for non-negative integers:

if [[ -z $n || $n == *[!0-9]* ]]; then
    echo "error: not a whole number: $n" >&2
    exit 2
fi

*[!0-9]* means "somewhere there is a character that is not a digit". Together with the empty check, that accepts exactly the strings made of digits. (Allowing a minus sign, or using a regular expression with =~, comes in module 15.)

The octal trap

A leading zero makes a number octal (base 8), and 0x makes it hexadecimal:

$ echo $(( 010 )) $(( 0x10 ))
8 16
$ m=08; (( m > 6 ))
bash: ((: 08: value too great for base (error token is "08")
$ (( 10#$m > 6 )) && echo "after June"
after June

10# forces base 10. You need it whenever numbers come from dates (date +%m gives 08), times, or zero-padded file names like img_009.png. Note that 10# needs the $, because it works on the digits: 10#$m.

The zero-status trap

Since (( )) fails when the value is 0, a statement used just for its side effect can "fail":

$ i=0
$ (( i++ )); echo "status $? i=$i"
status 1 i=1

i++ evaluates to the old value, 0, so the status is 1. In a normal script this does not matter. Under set -e (module 12), which aborts on any failing command, it ends your script at the first increment. Write (( i += 1 )) (value 1, status 0), (( ++i )), or i=$(( i + 1 )) instead.

Common mistakes

Exercises

leap combines validation with a compound condition; the checker tries century years and bad input and checks the exit status. budget reads numbers from standard input and must exit 1 only when over budget.

Recap

Going further: read help let, which lists every arithmetic operator in precedence order. Try echo $(( 2**63 )) to see the 64-bit wrap-around, and echo $(( 16#ff )) for other bases.

Exercises

Exercise 1 · quiz

The status of (( ))

What does this print?

count=0
(( count )) && echo yes || echo no
(( count++ )) ; echo $?

Pick the two lines it prints.

Answer
  • no / 1

(( expr )) exits 0 when the expression's value is non-zero, and 1 when it is zero, so it reads naturally as true/false: (( count )) is false while count is 0, printing no. Then count++ is a post-increment: its value is the old value, 0, so the status is 1 even though count is now 1. This bites scripts that use set -e (module 12), where (( count++ )) with count at 0 silently ends the script. (( count += 1 )) or count=$(( count + 1 )) avoid it.

Exercise 2 · quiz

Leading zeros

A script reads the month from a date as m=08 and runs (( m > 6 )). bash prints value too great for base (error token is "08"). Why?

Answer
  • In arithmetic, a number with a leading 0 is read as octal (base 8), and 8 is not an octal digit.

Like C, bash arithmetic reads 010 as octal (8 decimal) and 0x10 as hexadecimal (16). 08 and 09 are therefore errors. Dates and times are full of leading zeros, so strip them or force base 10 with the 10# prefix: (( 10#$m > 6 )).

Exercise 3 · write code

Leap years

Write a script that takes a year as its only argument and prints YEAR is a leap year or YEAR is not a leap year.

The rule: a year is a leap year if it is divisible by 4, except years divisible by 100, which are leap years only if also divisible by 400 (so 2000 was, 1900 was not).

If the argument is missing or is not made only of digits, print error: YEAR must be a positive whole number and exit with status 2. Use (( )) for the arithmetic.

File: script.sh

Starting point

#!/bin/bash
year=$1

Example with arguments 2024

expected output

2024 is a leap year

Example with arguments 2023

expected output

2023 is not a leap year
Hint 1

Validate first: [[ -z $year || $year == *[!0-9]* ]] is true for an empty value or one containing a non-digit.

Hint 2

% is the remainder: (( year % 4 == 0 )) means divisible by 4. Combine with &&, || and parentheses inside one (( )).

Hint 3

(( year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ))

Reference solution
#!/bin/bash
year=$1
if [[ -z $year || $year == *[!0-9]* ]]; then
    echo "error: YEAR must be a positive whole number"
    exit 2
fi
if (( 10#$year % 4 == 0 && ( 10#$year % 100 != 0 || 10#$year % 400 == 0 ) )); then
    echo "$year is a leap year"
else
    echo "$year is not a leap year"
fi

Exercise 4 · write code

Over budget?

Standard input holds one whole number per line: expenses in dollars. The first argument is the budget. Read all the numbers, add them up, and print exactly:

total: N

followed by a second line that is either within budget or over budget by M (where M is total minus budget). Exit 0 when within budget (total equal to the budget counts as within) and 1 when over.

Use while read -r n; do ...; done to read the lines (it runs the loop body once per line, with $n set to the line; loops are explained in the next module) and (( )) for the sums.

File: script.sh

Starting point

#!/bin/bash
budget=$1
total=0
while read -r n; do
    : # add n to total here
done

Example with arguments 100

input

20
30
15

expected output

total: 65
within budget

Example with arguments 65

input

20
30
15

expected output

total: 65
within budget
Hint 1

(( total += n )) adds inside the loop. Note: no $ is needed for variable names inside (( )).

Hint 2

After the loop: if (( total > budget )); then ...; exit 1; fi and print within budget otherwise. $(( total - budget )) gives the difference as text.

Reference solution
#!/bin/bash
budget=$1
total=0
while read -r n; do
    (( total += n ))
done
echo "total: $total"
if (( total > budget )); then
    echo "over budget by $(( total - budget ))"
    exit 1
fi
echo "within budget"