Linux & Bash · Part I · Module 8 · Variables, Command Substitution and Arithmetic
Arithmetic in Bash
The shell can count — pass and fail totals, loop indices, percentages that do not need a decimal point. What it cannot do is fractions, and the two places where it quietly gives a wrong answer instead of an error are worth meeting deliberately rather than at two in the morning.
Intermediate18 min readLinuxPart IFundamentalsVariablesBash
Part I · Module 8 · Chapter 8.5 · Arithmetic in Bash
Chapter 8.1 established that every value is text, and that echo $n+1 gives 5+1. This chapter is about asking for the other thing.
1. Asking for Arithmetic
$ pass=18
$ fail=4
$ total=$(( pass + fail ))
$ echo "$total"
22
$ echo "pass rate: $(( pass * 100 / total ))%"
pass rate: 81%$(( expression )) evaluates the expression and becomes the result. The same shape as $( ) from Chapter 8.4, with a second pair of parentheses and an entirely different job.
Two things are different inside the double parentheses from everywhere else in the shell:
Variables do not need a dollar sign.
$ a=3
$ b=4
$ echo "$(( a + b )) $(( $a + $b ))"
7 7Both work. The bare form is conventional and easier to read, because inside an arithmetic context a bare word can only be a variable.
Spaces do not matter. $((a+b)), $(( a + b )) and $(( a+b )) are identical. This is a small relief after Chapter 8.1's rule that a space around = changes everything.
The operators
| Operator | Does |
|---|---|
+ - * / | The usual four |
% | Remainder |
** | Exponentiation |
++ -- | Increment and decrement |
+= -= *= /= | Compound assignment |
== != < <= > >= | Comparison — produces 1 or 0 |
&& || ! | Logical |
? : | Conditional |
& | ^ ~ << >> | Bitwise |
$ echo "$(( 2 ** 10 ))"
1024
$ echo "$(( 17 % 5 ))"
2
$ echo "$(( 1 << 8 ))"
256
$ echo "$(( 5 > 3 ))"
1Comparison yielding 1 and 0 matters later: it is how arithmetic conditions work in Module 16.
2. Integers Only
$ echo "7/2 = $(( 7 / 2 ))"
7/2 = 3
$ echo "99/100= $(( 99 / 100 ))"
99/100= 0
$ echo "-7/2 = $(( -7 / 2 ))"
-7/2 = -3There is no floating point. Division discards the fractional part rather than rounding — 7 / 2 is 3, not 3.5 and not 4. And it truncates toward zero, so -7 / 2 is -3, not -4.
Try to use a decimal point at all and it is a hard error:
$ echo $(( 3.5 + 1 ))
bash: 3.5 + 1 : syntax error: invalid arithmetic operator (error token is ".5 + 1 ")3. The Leading-Zero Trap
This one is genuinely nasty, because the input that triggers it is completely ordinary.
$ echo "010 -> $(( 010 ))"
010 -> 8A number with a leading zero is read as octal. 010 is eight.
And in base eight there is no digit 8 or 9, so:
$ echo "08 -> $(( 08 ))"
bash: 08: value too great for base (error token is "08")Why this matters: zero-padded values are everywhere. Month and day numbers from date +%m and date +%d. Run numbers formatted as run_007. Hours and minutes. Any of them with a leading zero behaves differently from the number it looks like, and the failure arrives on the eighth and ninth of the month — which is a bug report nobody can reproduce in October.
$ month=$(date +%m) # "10" in October, "08" in August
$ echo "$(( month + 1 ))"
bash: 08: value too great for base (error token is "08")The fix is to say which base you mean:
$ m=08
$ echo "$(( 10#$m ))"
810# forces base ten, whatever the leading zeros say. Use it on anything that arrives zero-padded from date, from a file name, or from a person.
4. Arithmetic as a Statement
$(( )) produces a value. (( )) — without the dollar sign — is a command that evaluates an expression for its side effects:
$ i=5
$ (( i++ ))
$ echo "$i"
6
$ (( i += 10 ))
$ echo "$i"
16Nothing is printed and nothing is substituted. It exists to change variables, and it is the natural way to write a counter.
Its exit status is surprising
$ i=1
$ (( i++ ))
$ echo "status: $?"
status: 0
$ i=0
$ (( i++ ))
$ echo "status: $?"
status: 1(( )) reports success when the result is non-zero and failure when the result is zero. That is deliberate — it makes (( a > b )) usable directly as a condition, since the comparison yields 1 for true. But it means a perfectly successful increment reports failure whenever the value it produced was zero. Chapter 16.3 returns to this from the other direction, and contrasts it with [ 0 ] — which is true, because [ is asking a completely different question.
i++ is the post-increment, so it produces the value before incrementing. Starting from i=0, that value is 0, so the status is 1 — even though i is now correctly 1.
let and declare -i
Two older ways to do the same thing:
$ let "x = 3 + 4"
$ echo "$x"
7
$ declare -i n
$ n="3+4"
$ echo "$n"
7let is the predecessor of (( )) and needs careful quoting. declare -i marks a variable as integer so that assignments to it are evaluated arithmetically — which is clever and surprising to anyone reading the assignment later. Both are worth recognising; neither is worth preferring over $(( )) and (( )).
5. When You Actually Need Fractions
Integer arithmetic covers most of what a script does — counting tests, computing a percentage to the nearest whole number, indexing a loop. When you genuinely need a decimal, the shell is not the tool, and there are three reasonable answers.
awk, for a formatted number:
$ pass=18
$ total=22
$ awk -v p="$pass" -v t="$total" 'BEGIN { printf "%.1f%%\n", 100*p/t }'
81.8%This is the one to reach for. awk is on every Unix system, -v passes values in safely without quoting problems, and printf gives you exact control over the number of decimal places. (Module 13 covers awk properly.)
bc, for a calculation:
$ echo "scale=2; 7/2" | bc
3.50bc is an arbitrary-precision calculator. scale sets the number of decimal places and defaults to zero, so echo "7/2" | bc gives 3 and the missing scale is the usual first surprise. bc -l loads the maths library and sets a scale of 20.
Scaled integers, when you cannot depend on either:
$ pass=18
$ total=22
$ tenths=$(( pass * 1000 / total ))
$ echo "pass rate: $(( tenths / 10 )).$(( tenths % 10 ))%"
pass rate: 81.8%Work in tenths or hundredths and place the decimal point yourself. Ugly, entirely portable, and occasionally the right call in a script that must run on a minimal system.
6. $RANDOM
$ echo "$RANDOM $RANDOM"
16387 27615A special variable giving a different integer from 0 to 32767 each time it is read. Useful for a quick seed or a unique-ish temporary name:
seed=$(( RANDOM % 1000 ))
tmp="/tmp/work_$$_$RANDOM"($$ is the shell's PID from Chapter 7.1, which makes the combination unlikely to collide.)
Two limits worth stating: it is not cryptographically secure, and RANDOM % n is slightly biased toward smaller values unless n divides 32768. For choosing a simulation seed neither matters. For anything involving security, use /dev/urandom.
7. A Realistic Summary
Counting results from a regression and reporting them:
#!/bin/bash
set -u
pass=0
fail=0
for log in sim/logs/*.log; do
if grep -q "TEST PASSED" "$log"; then
pass=$(( pass + 1 ))
else
fail=$(( fail + 1 ))
fi
done
total=$(( pass + fail ))
if (( total == 0 )); then
echo "no logs found"
exit 1
fi
echo "total : ${total}"
echo "passed : ${pass}"
echo "failed : ${fail}"
echo "rate : $(( pass * 100 / total ))%"
awk -v p="$pass" -v t="$total" 'BEGIN { printf "exact : %.2f%%\n", 100*p/t }'Four decisions in there worth naming:
pass=$(( pass + 1 )), not(( pass++ ))— an assignment whose exit status is always zero, so it is safe under anyset -eon any Bash.pass * 100 / total— multiply first. The other order gives 0% for anything short of perfection.(( total == 0 ))guards the division. Dividing by zero is a fatal arithmetic error, and an empty log directory is not an unusual situation.awkfor the exact figure, alongside the integer one. Two lines, both correct, neither pretending the shell can do something it cannot.
The for loop and if belong to Chapter 17.3 and Chapter 16.1; everything arithmetic is this chapter.
Practice
1 — The basics.
a=17; b=5
for op in '+' '-' '*' '/' '%' '**'; do
echo "$a $op $b = $(( a $op b ))"
done2 — Watch truncation change the answer.
pass=99; total=100
echo "divide first : $(( pass / total * 100 ))%"
echo "multiply first: $(( pass * 100 / total ))%"3 — Fall into the octal trap and climb out.
m=08
echo $(( m + 1 )) # fails
echo $(( 10#$m + 1 )) # works
echo $(( 010 )) # eight, silently4 — Measure the exit status.
i=1; (( i++ )); echo "from 1: status $?"
i=0; (( i++ )); echo "from 0: status $?"5 — Test the set -e behaviour on your Bash. It varies, so measure rather than assume:
bash --version | head -1
printf '#!/bin/bash\nset -e\ni=0\n(( i++ ))\necho reached\n' > a.sh
printf '#!/bin/bash\nset -e\ni=0\nlet i++\necho reached\n' > b.sh
chmod +x a.sh b.sh
./a.sh; echo " (( )) exit: $?"
./b.sh; echo " let exit: $?"6 — Three ways to a decimal.
p=18; t=22
awk -v p=$p -v t=$t 'BEGIN { printf "%.2f%%\n", 100*p/t }'
echo "scale=2; 100*$p/$t" | bc
x=$(( p * 1000 / t )); echo "$(( x / 10 )).$(( x % 10 ))%"7 — Divide by zero on purpose. Run echo $(( 1 / 0 )) and read the message, so you recognise it later.
8 — Clean a padded number. Confirm $(( )) strips whitespace:
c=" 42 "
echo "[$c] -> [$(( c ))]"Check Your Understanding
1. $(( 9 / 10 * 100 )) gives 0. What is the rule, and what is the fix?
2. $(( 09 + 1 )) fails but $(( 07 + 1 )) gives 8. Explain both.
3. i=0; (( i++ )) leaves i at 1 and reports exit status 1. Is the increment broken?
4. Why is count=$(( count + 1 )) a safer way to increment than (( count++ ))?
5. You need a pass rate to one decimal place. Give two ways, and say which you would put in a script that must run anywhere.
6. A script reads day=$(date +%d) and then does arithmetic on it. On which days of the month does it fail, and why?
8. Remember
$(( expression ))evaluates arithmetic and becomes the result. Inside it, variables need no dollar sign and spaces do not matter.- Integers only. There is no floating point, and a decimal point is a syntax error.
- Division truncates toward zero, so
7/2is 3 and-7/2is-3. - Multiply before you divide.
pass / total * 100gives 0;pass * 100 / totalgives the answer. - A leading zero means octal.
010is 8, and08is an error — which bites on zero-padded dates and run numbers. 10#forces base ten. Use it on anything padded that arrived from outside.(( ))without a dollar sign is a command that evaluates for side effects.(( ))returns success when the result is non-zero — so a correct(( i++ ))from zero reports failure.- Prefer
i=$(( i + 1 )). An assignment's status is always zero, on every Bash. set -ebehaviour around arithmetic varies between versions. Measure it on the Bash you have rather than trusting a rule of thumb.- For decimals use
awk, orbcwith an explicitscale, or scaled integers when nothing can be depended on. - Needing fractions is a signal that the calculation wants a different tool.
$RANDOMgives 0 to 32767. Fine for a seed, not for anything security-related.$(( value ))strips surrounding whitespace, which is handy for numbers captured from a command.
The shell can now hold text, take it apart, run commands for their output and count. The last thing it needs is a way to get a value from outside — from a person at a prompt, or from one line of a file at a time.
Where this fits
Part of the Linux & Bash curriculum.
